Skip to content

Commit 2744eff

Browse files
chore: add abstraction for worker operations (#9578)
**Description** This PR introduces a pluggable hooks framework that abstracts Zero-related operations in the worker package, enabling Dgraph to run in embedded mode without a separate Zero process -- many "unit" tests that aren't reliant on a full networked cluster would be great candidates for the abstraction. ### Motivation To support embedded deployment scenarios where Dgraph runs as a library within another application or in testing, the tight coupling between the worker package and Zero network operations needed to be abstracted. This allows alternative implementations to provide UID assignment, timestamp allocation, and transaction coordination without requiring a running Zero cluster. ### Changes #### New Package: `hooks/` | File | Description | |------|-------------| | [hooks/config.go](cci:7://file:///Users/matthew/code/dgraph/hooks/config.go:0:0-0:0) | Defines [ZeroHooks](cci:2://file:///Users/matthew/code/dgraph/hooks/config.go:16:0-22:1) interface and configuration management | | [hooks/init.go](cci:7://file:///Users/matthew/code/dgraph/hooks/init.go:0:0-0:0) | Package documentation | | [hooks/config_test.go](cci:7://file:///Users/matthew/code/dgraph/hooks/config_test.go:0:0-0:0) | Comprehensive unit tests with race detection | ### Testing - 8 unit tests with `-race` flag covering: - Enable/Disable lifecycle - Custom vs default hooks precedence - Panic on missing configuration - [ZeroHooksFns](cci:2://file:///Users/matthew/code/dgraph/hooks/config.go:24:0-30:1) wrapper delegation - Concurrent access safety **Checklist** - [x] The PR title follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) syntax, leading with `fix:`, `feat:`, `chore:`, `ci:`, etc. - [x] Code compiles correctly and linting (via trunk) passes locally - [x] Tests added for new functionality, or regression tests for bug fixes added as applicable --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4f44957 commit 2744eff

5 files changed

Lines changed: 496 additions & 80 deletions

File tree

hooks/config.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package hooks
7+
8+
import (
9+
"context"
10+
"sync/atomic"
11+
12+
"github.com/dgraph-io/dgo/v250/protos/api"
13+
"github.com/dgraph-io/dgraph/v25/protos/pb"
14+
)
15+
16+
// ZeroHooks abstracts the Zero-coordinated operations the worker depends on:
17+
// UID/namespace/timestamp assignment, transaction commit, and mutation application.
18+
// The default implementation talks to a Zero cluster over the network; embedded
19+
// deployments supply their own via Config.ZeroHooks.
20+
//
21+
// The worker entry points set pb.Num.Type before calling AssignUIDs and AssignNsIDs,
22+
// so implementations receive a fully-typed request and need not set it themselves.
23+
type ZeroHooks interface {
24+
AssignUIDs(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error)
25+
AssignTimestamps(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error)
26+
AssignNsIDs(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error)
27+
CommitOrAbort(ctx context.Context, tc *api.TxnContext) (*api.TxnContext, error)
28+
ApplyMutations(ctx context.Context, m *pb.Mutations) (*api.TxnContext, error)
29+
}
30+
31+
// ZeroHooksFns adapts a set of plain functions into a ZeroHooks implementation,
32+
// letting callers override individual operations without declaring a new type.
33+
type ZeroHooksFns struct {
34+
AssignUIDsFn func(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error)
35+
AssignTimestampsFn func(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error)
36+
AssignNsIDsFn func(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error)
37+
CommitOrAbortFn func(ctx context.Context, tc *api.TxnContext) (*api.TxnContext, error)
38+
ApplyMutationsFn func(ctx context.Context, m *pb.Mutations) (*api.TxnContext, error)
39+
}
40+
41+
func (h ZeroHooksFns) AssignUIDs(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
42+
return h.AssignUIDsFn(ctx, num)
43+
}
44+
45+
func (h ZeroHooksFns) AssignTimestamps(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
46+
return h.AssignTimestampsFn(ctx, num)
47+
}
48+
49+
func (h ZeroHooksFns) AssignNsIDs(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
50+
return h.AssignNsIDsFn(ctx, num)
51+
}
52+
53+
func (h ZeroHooksFns) CommitOrAbort(ctx context.Context, tc *api.TxnContext) (*api.TxnContext, error) {
54+
return h.CommitOrAbortFn(ctx, tc)
55+
}
56+
57+
func (h ZeroHooksFns) ApplyMutations(ctx context.Context, m *pb.Mutations) (*api.TxnContext, error) {
58+
return h.ApplyMutationsFn(ctx, m)
59+
}
60+
61+
// Config holds the configuration for embedded mode operation.
62+
type Config struct {
63+
// ZeroHooks overrides the Zero operations the worker performs. When nil, the
64+
// default network-backed hooks registered by the worker package are used.
65+
ZeroHooks ZeroHooks
66+
}
67+
68+
// defaultHooksHolder wraps a ZeroHooks so it can live in an atomic.Pointer.
69+
// An interface can't be stored in atomic.Pointer directly, and atomic.Value would
70+
// panic if two implementations with different concrete types were ever registered.
71+
type defaultHooksHolder struct {
72+
hooks ZeroHooks
73+
}
74+
75+
var (
76+
// globalConfig holds the active embedded configuration. A nil value means
77+
// embedded mode is off, so embedded state is derived from this single pointer.
78+
globalConfig atomic.Pointer[Config]
79+
80+
// defaultZeroHooks holds the fallback hooks registered by the worker package.
81+
defaultZeroHooks atomic.Pointer[defaultHooksHolder]
82+
)
83+
84+
// Enable activates embedded mode with the given configuration.
85+
// This must be called before any Dgraph operations.
86+
func Enable(cfg *Config) {
87+
globalConfig.Store(cfg)
88+
}
89+
90+
// Disable deactivates embedded mode.
91+
func Disable() {
92+
globalConfig.Store(nil)
93+
}
94+
95+
// IsEnabled returns true if embedded mode is currently active.
96+
func IsEnabled() bool {
97+
return globalConfig.Load() != nil
98+
}
99+
100+
// GetConfig returns the current embedded configuration, or nil if not enabled.
101+
func GetConfig() *Config {
102+
return globalConfig.Load()
103+
}
104+
105+
// SetDefaultZeroHooks registers the fallback ZeroHooks used when embedded mode is
106+
// disabled or Config.ZeroHooks is nil. The worker package calls this from its init.
107+
func SetDefaultZeroHooks(h ZeroHooks) {
108+
defaultZeroHooks.Store(&defaultHooksHolder{hooks: h})
109+
}
110+
111+
// GetHooks returns the active Zero hooks.
112+
// If embedded mode is not enabled, it returns the default hooks implementation.
113+
func GetHooks() ZeroHooks {
114+
if cfg := globalConfig.Load(); cfg != nil && cfg.ZeroHooks != nil {
115+
return cfg.ZeroHooks
116+
}
117+
if h := defaultZeroHooks.Load(); h != nil {
118+
return h.hooks
119+
}
120+
panic("no ZeroHooks configured - ensure worker package is imported or hooks.Enable() is called")
121+
}

hooks/config_test.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/*
2+
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package hooks
7+
8+
import (
9+
"context"
10+
"sync"
11+
"testing"
12+
13+
"github.com/dgraph-io/dgo/v250/protos/api"
14+
"github.com/dgraph-io/dgraph/v25/protos/pb"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// mockZeroHooks is a test implementation of ZeroHooks
19+
type mockZeroHooks struct {
20+
assignUIDsCalled bool
21+
assignTimestampsCalled bool
22+
assignNsIDsCalled bool
23+
commitOrAbortCalled bool
24+
applyMutationsCalled bool
25+
}
26+
27+
func (m *mockZeroHooks) AssignUIDs(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
28+
m.assignUIDsCalled = true
29+
return &pb.AssignedIds{StartId: 1, EndId: 10}, nil
30+
}
31+
32+
func (m *mockZeroHooks) AssignTimestamps(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
33+
m.assignTimestampsCalled = true
34+
return &pb.AssignedIds{StartId: 100}, nil
35+
}
36+
37+
func (m *mockZeroHooks) AssignNsIDs(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
38+
m.assignNsIDsCalled = true
39+
return &pb.AssignedIds{StartId: 1}, nil
40+
}
41+
42+
func (m *mockZeroHooks) CommitOrAbort(ctx context.Context, tc *api.TxnContext) (*api.TxnContext, error) {
43+
m.commitOrAbortCalled = true
44+
return &api.TxnContext{CommitTs: 200}, nil
45+
}
46+
47+
func (m *mockZeroHooks) ApplyMutations(ctx context.Context, mut *pb.Mutations) (*api.TxnContext, error) {
48+
m.applyMutationsCalled = true
49+
return &api.TxnContext{}, nil
50+
}
51+
52+
func resetGlobalState() {
53+
globalConfig.Store(nil)
54+
defaultZeroHooks.Store(nil)
55+
}
56+
57+
func TestEnableDisable(t *testing.T) {
58+
resetGlobalState()
59+
defer resetGlobalState()
60+
61+
require.False(t, IsEnabled(), "should start disabled")
62+
require.Nil(t, GetConfig(), "config should be nil when disabled")
63+
64+
mock := &mockZeroHooks{}
65+
cfg := &Config{
66+
ZeroHooks: mock,
67+
}
68+
Enable(cfg)
69+
70+
require.True(t, IsEnabled(), "should be enabled after Enable()")
71+
require.NotNil(t, GetConfig(), "config should not be nil after Enable()")
72+
require.Equal(t, mock, GetConfig().ZeroHooks, "config should round-trip the hooks")
73+
74+
Disable()
75+
76+
require.False(t, IsEnabled(), "should be disabled after Disable()")
77+
require.Nil(t, GetConfig(), "config should be nil after Disable()")
78+
}
79+
80+
func TestGetHooksWithCustomConfig(t *testing.T) {
81+
resetGlobalState()
82+
defer resetGlobalState()
83+
84+
mock := &mockZeroHooks{}
85+
cfg := &Config{
86+
ZeroHooks: mock,
87+
}
88+
Enable(cfg)
89+
90+
hooks := GetHooks()
91+
require.NotNil(t, hooks)
92+
require.Equal(t, mock, hooks, "should return custom hooks from config")
93+
}
94+
95+
func TestGetHooksWithDefaultHooks(t *testing.T) {
96+
resetGlobalState()
97+
defer resetGlobalState()
98+
99+
mock := &mockZeroHooks{}
100+
SetDefaultZeroHooks(mock)
101+
102+
hooks := GetHooks()
103+
require.NotNil(t, hooks)
104+
require.Equal(t, mock, hooks, "should return default hooks when no config")
105+
}
106+
107+
func TestGetHooksPanicsWhenNoHooksConfigured(t *testing.T) {
108+
resetGlobalState()
109+
defer resetGlobalState()
110+
111+
require.Panics(t, func() {
112+
GetHooks()
113+
}, "should panic when no hooks configured")
114+
}
115+
116+
func TestGetHooksCustomOverridesDefault(t *testing.T) {
117+
resetGlobalState()
118+
defer resetGlobalState()
119+
120+
defaultMock := &mockZeroHooks{}
121+
SetDefaultZeroHooks(defaultMock)
122+
123+
customMock := &mockZeroHooks{}
124+
cfg := &Config{
125+
ZeroHooks: customMock,
126+
}
127+
Enable(cfg)
128+
129+
hooks := GetHooks()
130+
require.Equal(t, customMock, hooks, "custom hooks should override default")
131+
}
132+
133+
func TestZeroHooksFnsWrapper(t *testing.T) {
134+
var assignUIDsCalled, timestampsCalled, nsIDsCalled, commitCalled, mutationsCalled bool
135+
136+
wrapper := ZeroHooksFns{
137+
AssignUIDsFn: func(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
138+
assignUIDsCalled = true
139+
return &pb.AssignedIds{StartId: 1}, nil
140+
},
141+
AssignTimestampsFn: func(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
142+
timestampsCalled = true
143+
return &pb.AssignedIds{StartId: 100}, nil
144+
},
145+
AssignNsIDsFn: func(ctx context.Context, num *pb.Num) (*pb.AssignedIds, error) {
146+
nsIDsCalled = true
147+
return &pb.AssignedIds{StartId: 1}, nil
148+
},
149+
CommitOrAbortFn: func(ctx context.Context, tc *api.TxnContext) (*api.TxnContext, error) {
150+
commitCalled = true
151+
return &api.TxnContext{CommitTs: 200}, nil
152+
},
153+
ApplyMutationsFn: func(ctx context.Context, m *pb.Mutations) (*api.TxnContext, error) {
154+
mutationsCalled = true
155+
return &api.TxnContext{}, nil
156+
},
157+
}
158+
159+
ctx := context.Background()
160+
161+
_, _ = wrapper.AssignUIDs(ctx, &pb.Num{})
162+
require.True(t, assignUIDsCalled, "AssignUIDs should delegate to function")
163+
164+
_, _ = wrapper.AssignTimestamps(ctx, &pb.Num{})
165+
require.True(t, timestampsCalled, "AssignTimestamps should delegate to function")
166+
167+
_, _ = wrapper.AssignNsIDs(ctx, &pb.Num{})
168+
require.True(t, nsIDsCalled, "AssignNsIDs should delegate to function")
169+
170+
_, _ = wrapper.CommitOrAbort(ctx, &api.TxnContext{})
171+
require.True(t, commitCalled, "CommitOrAbort should delegate to function")
172+
173+
_, _ = wrapper.ApplyMutations(ctx, &pb.Mutations{})
174+
require.True(t, mutationsCalled, "ApplyMutations should delegate to function")
175+
}
176+
177+
func TestConcurrentEnableDisable(t *testing.T) {
178+
resetGlobalState()
179+
defer resetGlobalState()
180+
181+
var wg sync.WaitGroup
182+
iterations := 100
183+
184+
// Concurrent enables
185+
for i := 0; i < iterations; i++ {
186+
wg.Add(1)
187+
go func() {
188+
defer wg.Done()
189+
Enable(&Config{ZeroHooks: &mockZeroHooks{}})
190+
}()
191+
}
192+
193+
// Concurrent disables
194+
for i := 0; i < iterations; i++ {
195+
wg.Add(1)
196+
go func() {
197+
defer wg.Done()
198+
Disable()
199+
}()
200+
}
201+
202+
// Concurrent reads
203+
for i := 0; i < iterations; i++ {
204+
wg.Add(1)
205+
go func() {
206+
defer wg.Done()
207+
_ = IsEnabled()
208+
_ = GetConfig()
209+
}()
210+
}
211+
212+
wg.Wait()
213+
// Test passes if no race conditions detected
214+
}
215+
216+
func TestSetDefaultZeroHooks(t *testing.T) {
217+
resetGlobalState()
218+
defer resetGlobalState()
219+
220+
mock1 := &mockZeroHooks{}
221+
mock2 := &mockZeroHooks{}
222+
223+
SetDefaultZeroHooks(mock1)
224+
require.Equal(t, mock1, GetHooks())
225+
226+
SetDefaultZeroHooks(mock2)
227+
require.Equal(t, mock2, GetHooks())
228+
}

hooks/doc.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
// Package hooks provides configuration and hooks for running Dgraph in embedded mode.
7+
//
8+
// The initialization functions that would create import cycles are intentionally left
9+
// to the host application, which wires up the individual packages (edgraph, worker,
10+
// posting, schema, x) directly.
11+
//
12+
// Usage:
13+
// 1. Call hooks.Enable() with your ZeroHooks configuration.
14+
// 2. Initialize packages directly: edgraph.Init(), worker.State.InitStorage(), etc.
15+
// 3. The hooks in this package are then called automatically by worker functions.
16+
// 4. Call hooks.Disable() when shutting down.
17+
package hooks

0 commit comments

Comments
 (0)