Skip to content

Commit 4afa968

Browse files
hallelx2claude
andcommitted
feat(queue): implement River driver end-to-end
Replaces the Phase-0 stub whose Enqueue/Start returned "not yet implemented" and silently blocked on ctx.Done. Design: - NewRiver opens its own pgxpool (separate from the engine's main pool so queue traffic and data-plane traffic don't starve each other) and runs River's own schema migrations idempotently at boot. - River expects typed JobArgs per kind, but the engine's Queue interface deals in a single generic Job. We bridge with an "envelope" job type: one River worker, one kind ("vle_envelope"). The engine's JobKind travels as a payload field; the envelope worker looks up the registered handler at work time, so Register() can still be called after the client is built. - Enqueue lazily constructs the *river.Client on first use. This lets the engine keep its current startup shape — buildQueue → Register → go Start — without reshuffling initialization order. - Start blocks until ctx is cancelled, then runs client.Stop with a fresh (non-cancelled) context so in-flight jobs get a chance to drain rather than being cancelled mid-transaction. Tests: - Unit test exercises envelopeWorker.Work dispatch against an in-memory handler map — no DB needed, runs in default `go test`. - Integration test gated on TEST_DATABASE_URL: full enqueue → worker → handler round-trip. Skipped locally; picks up a real Postgres when CI adds one (next up). Deps added: github.com/riverqueue/river@v0.35.0 and riverpgxv5. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8dd2a47 commit 4afa968

4 files changed

Lines changed: 291 additions & 11 deletions

File tree

go.mod

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ require (
2929
cloud.google.com/go/iam v1.2.2 // indirect
3030
cloud.google.com/go/longrunning v0.6.2 // indirect
3131
cloud.google.com/go/vertexai v0.12.0 // indirect
32+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
3233
github.com/dlclark/regexp2 v1.10.0 // indirect
3334
github.com/felixge/httpsnoop v1.0.4 // indirect
3435
github.com/go-logr/logr v1.4.3 // indirect
@@ -41,14 +42,26 @@ require (
4142
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
4243
github.com/jackc/puddle/v2 v2.2.2 // indirect
4344
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
45+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
46+
github.com/riverqueue/river v0.35.0 // indirect
47+
github.com/riverqueue/river/riverdriver v0.35.0 // indirect
48+
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.0 // indirect
49+
github.com/riverqueue/river/rivershared v0.35.0 // indirect
50+
github.com/riverqueue/river/rivertype v0.35.0 // indirect
4451
github.com/rogpeppe/go-internal v1.14.1 // indirect
52+
github.com/stretchr/testify v1.11.1 // indirect
53+
github.com/tidwall/gjson v1.18.0 // indirect
54+
github.com/tidwall/match v1.2.0 // indirect
55+
github.com/tidwall/pretty v1.2.1 // indirect
56+
github.com/tidwall/sjson v1.2.5 // indirect
4557
github.com/tmc/langchaingo v0.1.14 // indirect
4658
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
4759
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
4860
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
4961
go.opentelemetry.io/otel v1.36.0 // indirect
5062
go.opentelemetry.io/otel/metric v1.36.0 // indirect
5163
go.opentelemetry.io/otel/trace v1.36.0 // indirect
64+
go.uber.org/goleak v1.3.0 // indirect
5265
golang.org/x/crypto v0.50.0 // indirect
5366
golang.org/x/oauth2 v0.30.0 // indirect
5467
golang.org/x/sys v0.43.0 // indirect

go.sum

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYde
6363
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6464
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
6565
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
66+
github.com/riverqueue/river v0.35.0 h1:ERjFYsrCTGIQb8zdGZDegXmCNtsZ2f38Mh/1pm8s+7s=
67+
github.com/riverqueue/river v0.35.0/go.mod h1:BhQ6qtWT5ngvHcZByD86GB6wFbFmWuovkqwp5Ybuw+g=
68+
github.com/riverqueue/river/riverdriver v0.35.0 h1:g23GXp2ukQBzFAjHoqEqU9/nvDBVNugvFSzDeh9QnDA=
69+
github.com/riverqueue/river/riverdriver v0.35.0/go.mod h1:cg2DXFxovACcMKMDO7VwvMJt7/FfQD5ZvD19o7q+27g=
70+
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.0 h1:ifGnwXjFujv87RV1P7Q3RQRmMhZPgoyB4Y7Q3cwQAv8=
71+
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.35.0/go.mod h1:zUy67OVHoXfeOktxmQqlgqL9q/otBaUrwKuRm0PwHl8=
72+
github.com/riverqueue/river/rivershared v0.35.0 h1:8l0nemUfKvG51GhsjPAyhPVNw/Nej3xIbOZ9np/G0aY=
73+
github.com/riverqueue/river/rivershared v0.35.0/go.mod h1:m6UB4lGgbwi8ikuKniISDJ/U+BvQYtNAtZwb90G85Wk=
74+
github.com/riverqueue/river/rivertype v0.35.0 h1:0SvJQB5GvSkGAyLLtUbtd9Fre7yuEpuzOtRNncvtj2Y=
75+
github.com/riverqueue/river/rivertype v0.35.0/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ=
6676
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
6777
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
6878
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -72,6 +82,17 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
7282
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
7383
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
7484
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
85+
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
86+
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
87+
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
88+
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
89+
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
90+
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
91+
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
92+
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
93+
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
94+
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
95+
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
7596
github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc=
7697
github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378=
7798
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
@@ -92,6 +113,8 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
92113
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
93114
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
94115
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
116+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
117+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
95118
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
96119
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
97120
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=

pkg/queue/river.go

Lines changed: 155 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@ package queue
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
7+
"fmt"
68
"sync"
9+
10+
"github.com/jackc/pgx/v5"
11+
"github.com/jackc/pgx/v5/pgxpool"
12+
"github.com/riverqueue/river"
13+
"github.com/riverqueue/river/riverdriver/riverpgxv5"
14+
"github.com/riverqueue/river/rivermigrate"
715
)
816

917
// RiverConfig configures the River (Postgres-backed) Queue backend.
@@ -12,7 +20,7 @@ type RiverConfig struct {
1220
// engine — no additional infrastructure required.
1321
DatabaseURL string
1422

15-
// NumWorkers is the size of the worker pool that drains jobs.
23+
// NumWorkers is the max concurrent jobs drained from the default queue.
1624
NumWorkers int
1725
}
1826

@@ -22,45 +30,181 @@ type RiverConfig struct {
2230
// reuses the Postgres database the engine already depends on. No Redis, no
2331
// new ops surface.
2432
//
25-
// The actual River client wiring will be added when we integrate the
26-
// `river` + `riverpgxv5` packages. For scaffolding we keep the shape of the
27-
// implementation so the rest of the engine can depend on it.
33+
// Implementation shape: River expects typed job args per kind, but the
34+
// engine's Queue interface speaks a single generic Job shape. We bridge by
35+
// registering a single River worker for one "envelope" kind. The envelope
36+
// carries the engine's JobKind + JSON payload; the worker dispatches to the
37+
// registered handler at work time.
2838
type River struct {
29-
cfg RiverConfig
39+
cfg RiverConfig
40+
pool *pgxpool.Pool
41+
3042
mu sync.RWMutex
3143
handlers map[JobKind]Handler
44+
45+
client *river.Client[pgx.Tx]
46+
once sync.Once
47+
initEr error
48+
}
49+
50+
// envelopeArgs is the single job type we register with River. The real kind
51+
// lives in DomainKind; River only sees "vle_envelope".
52+
type envelopeArgs struct {
53+
DomainKind JobKind `json:"domain_kind"`
54+
Payload json.RawMessage `json:"payload"`
55+
}
56+
57+
// Kind is River's own kind identifier. It must be stable and unique within
58+
// the database; it intentionally differs from the engine's JobKind space so
59+
// the two can't collide if River ever grows a second typed job.
60+
func (envelopeArgs) Kind() string { return "vle_envelope" }
61+
62+
// envelopeWorker dispatches an envelope to the Handler registered on the
63+
// owning *River for that DomainKind.
64+
type envelopeWorker struct {
65+
river.WorkerDefaults[envelopeArgs]
66+
q *River
67+
}
68+
69+
func (w *envelopeWorker) Work(ctx context.Context, job *river.Job[envelopeArgs]) error {
70+
w.q.mu.RLock()
71+
h, ok := w.q.handlers[job.Args.DomainKind]
72+
w.q.mu.RUnlock()
73+
if !ok {
74+
return fmt.Errorf("%w: %q", ErrUnknownKind, job.Args.DomainKind)
75+
}
76+
return h(ctx, Job{Kind: job.Args.DomainKind, Payload: job.Args.Payload})
3277
}
3378

34-
// NewRiver constructs a new River-backed Queue.
79+
// NewRiver constructs a new River-backed Queue. It opens its own pgxpool
80+
// against cfg.DatabaseURL (intentionally separate from the engine's main
81+
// pool so queue traffic and data-plane traffic can't starve each other) and
82+
// runs River's schema migrations idempotently at startup.
3583
func NewRiver(cfg RiverConfig) (*River, error) {
3684
if cfg.DatabaseURL == "" {
3785
return nil, errors.New("river: database_url is required")
3886
}
3987
if cfg.NumWorkers <= 0 {
4088
cfg.NumWorkers = 10
4189
}
90+
91+
// We open the pool eagerly so NewRiver surfaces DB misconfiguration at
92+
// boot rather than on the first enqueue. The pool is closed by Close().
93+
ctx, cancel := context.WithCancel(context.Background())
94+
defer cancel()
95+
96+
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
97+
if err != nil {
98+
return nil, fmt.Errorf("river: open pool: %w", err)
99+
}
100+
101+
// Apply River's own migrations (creates river_job + related tables).
102+
// This is idempotent and fast when up-to-date.
103+
migrator, err := rivermigrate.New(riverpgxv5.New(pool), nil)
104+
if err != nil {
105+
pool.Close()
106+
return nil, fmt.Errorf("river: migrator: %w", err)
107+
}
108+
if _, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil); err != nil {
109+
pool.Close()
110+
return nil, fmt.Errorf("river: migrate: %w", err)
111+
}
112+
42113
return &River{
43114
cfg: cfg,
115+
pool: pool,
44116
handlers: map[JobKind]Handler{},
45117
}, nil
46118
}
47119

120+
// Register binds a handler to a domain JobKind. It's safe to call before or
121+
// after the client starts — the envelope worker looks handlers up at work
122+
// time, not at registration time.
48123
func (r *River) Register(kind JobKind, h Handler) {
49124
r.mu.Lock()
50125
defer r.mu.Unlock()
51126
r.handlers[kind] = h
52127
}
53128

129+
// Enqueue inserts a job. If the client hasn't been built yet (no prior
130+
// Enqueue or Start call), this triggers lazy client construction.
54131
func (r *River) Enqueue(ctx context.Context, j Job) error {
55-
// TODO(phase-1): insert into river_job via riverpgxv5.Client.Insert.
56-
return errors.New("river: Enqueue not yet implemented")
132+
if err := r.ensureClient(); err != nil {
133+
return err
134+
}
135+
args := envelopeArgs{DomainKind: j.Kind, Payload: j.Payload}
136+
137+
opts := &river.InsertOpts{}
138+
if !j.RunAt.IsZero() {
139+
opts.ScheduledAt = j.RunAt
140+
}
141+
if j.MaxRetries > 0 {
142+
opts.MaxAttempts = j.MaxRetries
143+
}
144+
// DedupeKey intentionally left on the table; River's native uniqueness
145+
// story has moved through several shapes (UniqueOpts, ByArgs, etc.) and
146+
// wiring it in here should be a deliberate call, not a guess. When we
147+
// need it we'll plumb UniqueOpts with ByKey matching DedupeKey.
148+
149+
if _, err := r.client.Insert(ctx, args, opts); err != nil {
150+
return fmt.Errorf("river: insert: %w", err)
151+
}
152+
return nil
57153
}
58154

155+
// Start begins draining jobs. Blocks until ctx is cancelled, then performs a
156+
// graceful shutdown (finishes in-flight jobs, stops fetching new ones).
59157
func (r *River) Start(ctx context.Context) error {
60-
// TODO(phase-1): start river.Client workers. Each registered JobKind
61-
// maps to a river.Worker[T] that calls the handler.
158+
if err := r.ensureClient(); err != nil {
159+
return err
160+
}
161+
if err := r.client.Start(ctx); err != nil {
162+
return fmt.Errorf("river: start: %w", err)
163+
}
62164
<-ctx.Done()
165+
// Give in-flight jobs a bounded window to finish. We detach from ctx
166+
// (which is already done) so Stop doesn't immediately cancel what it's
167+
// trying to drain.
168+
stopCtx, cancel := context.WithCancel(context.Background())
169+
defer cancel()
170+
if err := r.client.Stop(stopCtx); err != nil && !errors.Is(err, context.Canceled) {
171+
return fmt.Errorf("river: stop: %w", err)
172+
}
173+
return nil
174+
}
175+
176+
// Close releases the pool. Safe to call after Start returns. It does not
177+
// stop in-flight work — Start handles graceful shutdown on ctx cancel.
178+
func (r *River) Close() error {
179+
if r.pool != nil {
180+
r.pool.Close()
181+
}
63182
return nil
64183
}
65184

66-
func (r *River) Close() error { return nil }
185+
// ensureClient lazily constructs the river.Client. We defer this until first
186+
// use so tests that just want to Register handlers (or engines that queue
187+
// without working) can construct a *River without spinning up workers.
188+
//
189+
// The client's Workers are registered once at build time — our single
190+
// envelopeWorker dispatches by looking up the handler map on each Work call,
191+
// so new Register() calls after client construction still take effect.
192+
func (r *River) ensureClient() error {
193+
r.once.Do(func() {
194+
workers := river.NewWorkers()
195+
river.AddWorker(workers, &envelopeWorker{q: r})
196+
197+
client, err := river.NewClient[pgx.Tx](riverpgxv5.New(r.pool), &river.Config{
198+
Workers: workers,
199+
Queues: map[string]river.QueueConfig{
200+
river.QueueDefault: {MaxWorkers: r.cfg.NumWorkers},
201+
},
202+
})
203+
if err != nil {
204+
r.initEr = fmt.Errorf("river: new client: %w", err)
205+
return
206+
}
207+
r.client = client
208+
})
209+
return r.initEr
210+
}

pkg/queue/river_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package queue
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"testing"
8+
"time"
9+
10+
"github.com/riverqueue/river"
11+
)
12+
13+
// TestRiverEnvelopeDispatch exercises the envelopeWorker.Work dispatch table
14+
// directly, without touching Postgres. It proves the indirection between
15+
// River's single envelope kind and the engine's JobKind space behaves — a
16+
// registered handler is invoked on the right kind, an unregistered kind
17+
// produces ErrUnknownKind.
18+
func TestRiverEnvelopeDispatch(t *testing.T) {
19+
t.Parallel()
20+
21+
q := &River{
22+
handlers: map[JobKind]Handler{},
23+
}
24+
w := &envelopeWorker{q: q}
25+
26+
var got Job
27+
q.Register(KindIngestDocument, func(_ context.Context, j Job) error {
28+
got = j
29+
return nil
30+
})
31+
32+
payload, _ := json.Marshal(map[string]string{"doc": "abc"})
33+
err := w.Work(context.Background(), &river.Job[envelopeArgs]{
34+
Args: envelopeArgs{DomainKind: KindIngestDocument, Payload: payload},
35+
})
36+
if err != nil {
37+
t.Fatalf("work: unexpected error: %v", err)
38+
}
39+
if got.Kind != KindIngestDocument {
40+
t.Errorf("got kind %q, want %q", got.Kind, KindIngestDocument)
41+
}
42+
if string(got.Payload) != string(payload) {
43+
t.Errorf("payload not passed through: got %q, want %q", got.Payload, payload)
44+
}
45+
46+
err = w.Work(context.Background(), &river.Job[envelopeArgs]{
47+
Args: envelopeArgs{DomainKind: "never_registered"},
48+
})
49+
if err == nil {
50+
t.Fatal("work: expected error for unregistered kind, got nil")
51+
}
52+
}
53+
54+
// TestRiverIntegration is a full round-trip against a real Postgres. Gated
55+
// on TEST_DATABASE_URL so it stays out of the default `go test ./...` path
56+
// and only runs when someone has a DB ready (docker-compose up postgres).
57+
func TestRiverIntegration(t *testing.T) {
58+
dsn := os.Getenv("TEST_DATABASE_URL")
59+
if dsn == "" {
60+
t.Skip("TEST_DATABASE_URL not set; skipping River integration test")
61+
}
62+
63+
q, err := NewRiver(RiverConfig{DatabaseURL: dsn, NumWorkers: 2})
64+
if err != nil {
65+
t.Fatalf("new river: %v", err)
66+
}
67+
defer q.Close()
68+
69+
done := make(chan Job, 1)
70+
q.Register(KindIngestDocument, func(_ context.Context, j Job) error {
71+
done <- j
72+
return nil
73+
})
74+
75+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
76+
defer cancel()
77+
78+
// Start workers in the background.
79+
workerDone := make(chan error, 1)
80+
go func() { workerDone <- q.Start(ctx) }()
81+
82+
payload, _ := json.Marshal(map[string]string{"doc": "integration"})
83+
if err := q.Enqueue(ctx, Job{Kind: KindIngestDocument, Payload: payload}); err != nil {
84+
t.Fatalf("enqueue: %v", err)
85+
}
86+
87+
select {
88+
case j := <-done:
89+
if j.Kind != KindIngestDocument {
90+
t.Errorf("handler saw kind %q, want %q", j.Kind, KindIngestDocument)
91+
}
92+
case <-time.After(20 * time.Second):
93+
t.Fatal("handler never fired within 20s")
94+
}
95+
96+
cancel() // triggers graceful shutdown in Start
97+
if err := <-workerDone; err != nil {
98+
t.Fatalf("start returned: %v", err)
99+
}
100+
}

0 commit comments

Comments
 (0)