Skip to content

Commit 070bfc1

Browse files
committed
feat(processor): fork event processing for configured isolated destinations
1 parent 8f2ebcd commit 070bfc1

12 files changed

Lines changed: 832 additions & 56 deletions

app/apphandlers/setup_partitionmigration.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ func setupProcessorPartitionMigrator(ctx context.Context,
208208
if procRWDB != nil {
209209
procBuffRWHandle := jobsdb.NewForReadWrite(
210210
"proc_buf",
211+
// proc_buf doesn't need to be multi-consumer because it is used only as a temporary storage.
212+
// During flush, the jobs will be moved to the proc jobsdb which is multi-consumer.
211213
jobsdb.WithClearDB(false),
212214
jobsdb.WithDSLimit(config.GetReloadableIntVar(0, 1, "JobsDB.proc_buf.dsLimit", "JobsDB.dsLimit")),
213215
jobsdb.WithSkipMaintenanceErr(config.GetBoolVar(true, "JobsDB.proc_buf.skipMaintenanceError", "JobsDB.buff.skipMaintenanceError", "JobsDB.skipMaintenanceError")),

integration_test/partitionmigration/partitionmigration_embedded_test.go

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,25 @@ func TestPartitionMigrationEmbeddedMode(t *testing.T) {
5151
for _, tc := range []struct {
5252
name string
5353
jobsDBFanoutEnabled bool // whether source nodes declare per-jobsdb fan-out on migration acknowledgement
54+
forkDestinations bool // fork the destination to the (multi-consumer) proc jobsdb, exercising proc-jobsdb migration
5455
}{
55-
{name: "normal", jobsDBFanoutEnabled: true},
56-
{name: "legacy_no_jobsdb_fanout", jobsDBFanoutEnabled: false},
56+
{name: "normal", jobsDBFanoutEnabled: true, forkDestinations: true},
57+
{name: "legacy_no_jobsdb_fanout", jobsDBFanoutEnabled: false, forkDestinations: false},
5758
} {
5859
t.Run(tc.name, func(t *testing.T) {
59-
testPartitionMigrationEmbeddedMode(t, tc.jobsDBFanoutEnabled)
60+
testPartitionMigrationEmbeddedMode(t, tc.jobsDBFanoutEnabled, tc.forkDestinations)
6061
})
6162
}
6263
}
6364

64-
func testPartitionMigrationEmbeddedMode(t *testing.T, jobsDBFanoutEnabled bool) {
65+
func testPartitionMigrationEmbeddedMode(t *testing.T, jobsDBFanoutEnabled, forkDestinations bool) {
6566
const (
66-
namespace = "namespace123"
67-
workspaceID = "workspace123"
68-
sourceID = "source123"
69-
destinationID = "destination123"
70-
writeKey = "writekey123"
67+
namespace = "namespace123"
68+
workspaceID = "workspace123"
69+
sourceID = "source123"
70+
destinationID = "destination123"
71+
abortDestinationID = "destination-abort-123" // second destination, aborted at the router
72+
writeKey = "writekey123"
7173

7274
numPartitions = 4 // needs to be a power of 2 (e.g., 2, 4, 8, 16, ...)
7375
jobsPerPartitionPerSecond = 50
@@ -116,22 +118,31 @@ func testPartitionMigrationEmbeddedMode(t *testing.T, jobsDBFanoutEnabled bool)
116118
wh := newTestWebhook(t)
117119
t.Cleanup(wh.Close)
118120

119-
// start a test backendconfig with 1 source connected to the webhook
121+
// start a test backendconfig with 1 source connected to the webhook. When forking, add a
122+
// second destination on the same source that the router aborts (never delivered), so each
123+
// forked proc job carries two consumers and migration must move all pending consumers.
124+
sourceBuilder := backendconfigtest.NewSourceBuilder().
125+
WithWorkspaceID(workspaceID).
126+
WithID(sourceID).
127+
WithWriteKey(writeKey).
128+
WithConnection(
129+
backendconfigtest.NewDestinationBuilder("WEBHOOK").
130+
WithID(destinationID).
131+
WithConfigOption("webhookMethod", "POST").
132+
WithConfigOption("webhookUrl", wh.URL).
133+
Build())
134+
if forkDestinations {
135+
sourceBuilder = sourceBuilder.WithConnection(
136+
backendconfigtest.NewDestinationBuilder("WEBHOOK").
137+
WithID(abortDestinationID).
138+
WithConfigOption("webhookMethod", "POST").
139+
WithConfigOption("webhookUrl", "http://localhost:1234"). // aborted at the router, never delivered
140+
Build())
141+
}
120142
bc := backendconfigtest.NewBuilder().
121143
WithNamespace(namespace, backendconfigtest.NewConfigBuilder().
122144
WithWorkspaceID(workspaceID).
123-
WithSource(
124-
backendconfigtest.NewSourceBuilder().
125-
WithWorkspaceID(workspaceID).
126-
WithID(sourceID).
127-
WithWriteKey(writeKey).
128-
WithConnection(
129-
backendconfigtest.NewDestinationBuilder("WEBHOOK").
130-
WithID(destinationID).
131-
WithConfigOption("webhookMethod", "POST").
132-
WithConfigOption("webhookUrl", wh.URL).
133-
Build()).
134-
Build()).
145+
WithSource(sourceBuilder.Build()).
135146
Build()).
136147
Build()
137148

@@ -218,6 +229,12 @@ func testPartitionMigrationEmbeddedMode(t *testing.T, jobsDBFanoutEnabled bool)
218229
"JobsDB.dsLimit": "2",
219230
"JobsDB.refreshDSListLoopSleepDuration": "5s",
220231
}
232+
if forkDestinations {
233+
// fork both destinations to the proc jobsdb (isolation already enabled above) so each
234+
// proc job carries two consumers; abort the second at the router so it is never delivered
235+
commonEnv["Processor.DestinationIsolation.enabledDestinations.all"] = "true"
236+
commonEnv["Router.toAbortDestinationIDs"] = abortDestinationID
237+
}
221238
rsBinaryPath := filepath.Join(t.TempDir(), "rudder-server-binary")
222239
rudderserver.BuildRudderServerBinary(t, "../../main.go", rsBinaryPath)
223240
node0Name := "proc-node-0"
@@ -282,6 +299,14 @@ func testPartitionMigrationEmbeddedMode(t *testing.T, jobsDBFanoutEnabled bool)
282299
// wait for some time to let events flow
283300
time.Sleep(10 * time.Second)
284301

302+
if forkDestinations {
303+
// the fan-out siphon must be active: forked jobs land in the proc jobsdb of some node,
304+
// giving partition migration multi-consumer proc jobs to move
305+
require.Eventually(t, func() bool {
306+
return procJobsCount(t, pg0.DB)+procJobsCount(t, pg1.DB) > 0
307+
}, 30*time.Second, 500*time.Millisecond, "forked jobs should appear in the proc jobsdb")
308+
}
309+
285310
var srcRouterAcks []string
286311
err = rudoacker.NewSrcrouterAcker(ctx, g, etcdResource.Client, namespace, []string{"srcrouter"}).
287312
WithEventListener(func(key string, value etcdtypes.ReloadSrcRouterCommand) {

integration_test/partitionmigration/partitionmigration_gwproc_test.go

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package partitionmigration_test
22

33
import (
44
"context"
5+
"database/sql"
56
"fmt"
67
"path/filepath"
78
"strconv"
@@ -55,26 +56,26 @@ func TestPartitionMigrationGatewayProcessorMode(t *testing.T) {
5556
extraStressWorkspaces int // number of extra workspace migrations to include (0 = normal mode)
5657
restartProcessorEvery time.Duration // how often to restart processor nodes while migration is ongoing
5758
jobsDBFanoutEnabled bool // whether source nodes declare per-jobsdb fan-out on migration acknowledgement
59+
forkDestinations bool // fork the destination to the (multi-consumer) proc jobsdb, exercising proc-jobsdb migration
5860
}{
59-
{name: "normal", extraStressWorkspaces: 0, restartProcessorEvery: 25 * time.Second, jobsDBFanoutEnabled: true},
60-
{name: "stress_100_workspaces", extraStressWorkspaces: 100, restartProcessorEvery: 30 * time.Second, jobsDBFanoutEnabled: true},
61-
{name: "stress_1000_workspaces", extraStressWorkspaces: 1000, restartProcessorEvery: 35 * time.Second, jobsDBFanoutEnabled: true},
62-
{name: "stress_5000_workspaces", extraStressWorkspaces: 5000, restartProcessorEvery: 50 * time.Second, jobsDBFanoutEnabled: true},
63-
{name: "legacy_no_jobsdb_fanout", extraStressWorkspaces: 0, restartProcessorEvery: 25 * time.Second, jobsDBFanoutEnabled: false},
61+
{name: "normal", extraStressWorkspaces: 0, restartProcessorEvery: 25 * time.Second, jobsDBFanoutEnabled: true, forkDestinations: true},
62+
{name: "legacy_no_jobsdb_fanout", extraStressWorkspaces: 0, restartProcessorEvery: 25 * time.Second, jobsDBFanoutEnabled: false, forkDestinations: false},
63+
{name: "stress_5000_workspaces", extraStressWorkspaces: 5000, restartProcessorEvery: 50 * time.Second, jobsDBFanoutEnabled: true, forkDestinations: false},
6464
} {
6565
t.Run(tc.name, func(t *testing.T) {
66-
testPartitionMigrationGatewayProcessorMode(t, tc.extraStressWorkspaces, tc.restartProcessorEvery, tc.jobsDBFanoutEnabled)
66+
testPartitionMigrationGatewayProcessorMode(t, tc.extraStressWorkspaces, tc.restartProcessorEvery, tc.jobsDBFanoutEnabled, tc.forkDestinations)
6767
})
6868
}
6969
}
7070

71-
func testPartitionMigrationGatewayProcessorMode(t *testing.T, extraStressWorkspaces int, restartProcessorEvery time.Duration, jobsDBFanoutEnabled bool) {
71+
func testPartitionMigrationGatewayProcessorMode(t *testing.T, extraStressWorkspaces int, restartProcessorEvery time.Duration, jobsDBFanoutEnabled, forkDestinations bool) {
7272
const (
73-
namespace = "namespace123"
74-
workspaceID = "workspace123"
75-
sourceID = "source123"
76-
destinationID = "destination123"
77-
writeKey = "writekey123"
73+
namespace = "namespace123"
74+
workspaceID = "workspace123"
75+
sourceID = "source123"
76+
destinationID = "destination123"
77+
abortDestinationID = "destination-abort-123" // second destination, aborted at the router
78+
writeKey = "writekey123"
7879

7980
numPartitions = 4 // needs to be a power of 2 (e.g., 2, 4, 8, 16, ...)
8081
jobsPerPartitionPerSecond = 50 // number of jobs to send per partition per second from the gateway client
@@ -137,22 +138,31 @@ func testPartitionMigrationGatewayProcessorMode(t *testing.T, extraStressWorkspa
137138
wh := newTestWebhook(t)
138139
t.Cleanup(wh.Close)
139140

140-
// start a test backendconfig with 1 source connected to the webhook
141+
// start a test backendconfig with 1 source connected to the webhook. When forking, add a
142+
// second destination on the same source that the router aborts (never delivered), so each
143+
// forked proc job carries two consumers and migration must move all pending consumers.
144+
sourceBuilder := backendconfigtest.NewSourceBuilder().
145+
WithWorkspaceID(workspaceID).
146+
WithID(sourceID).
147+
WithWriteKey(writeKey).
148+
WithConnection(
149+
backendconfigtest.NewDestinationBuilder("WEBHOOK").
150+
WithID(destinationID).
151+
WithConfigOption("webhookMethod", "POST").
152+
WithConfigOption("webhookUrl", wh.URL).
153+
Build())
154+
if forkDestinations {
155+
sourceBuilder = sourceBuilder.WithConnection(
156+
backendconfigtest.NewDestinationBuilder("WEBHOOK").
157+
WithID(abortDestinationID).
158+
WithConfigOption("webhookMethod", "POST").
159+
WithConfigOption("webhookUrl", "http://localhost:1234"). // aborted at the router, never delivered
160+
Build())
161+
}
141162
bc := backendconfigtest.NewBuilder().
142163
WithNamespace(namespace, backendconfigtest.NewConfigBuilder().
143164
WithWorkspaceID(workspaceID).
144-
WithSource(
145-
backendconfigtest.NewSourceBuilder().
146-
WithWorkspaceID(workspaceID).
147-
WithID(sourceID).
148-
WithWriteKey(writeKey).
149-
WithConnection(
150-
backendconfigtest.NewDestinationBuilder("WEBHOOK").
151-
WithID(destinationID).
152-
WithConfigOption("webhookMethod", "POST").
153-
WithConfigOption("webhookUrl", wh.URL).
154-
Build()).
155-
Build()).
165+
WithSource(sourceBuilder.Build()).
156166
Build()).
157167
Build()
158168
bc.URL = strings.Replace(bc.URL, "127.0.0.1", localIp, 1) // replace localhost with local IP for docker containers to access
@@ -271,6 +281,12 @@ func testPartitionMigrationGatewayProcessorMode(t *testing.T, extraStressWorkspa
271281
"Router.Network.IncludeInstanceIdInHeader": "true", // for debugging in case of receiving out-of-order events
272282
"Router.jobIterator.maxQueries": "1",
273283
}
284+
if forkDestinations {
285+
// fork both destinations to the proc jobsdb (isolation already enabled above) so each
286+
// proc job carries two consumers; abort the second at the router so it is never delivered
287+
procCommonEnv["Processor.DestinationIsolation.enabledDestinations.all"] = "true"
288+
procCommonEnv["Router.toAbortDestinationIDs"] = abortDestinationID
289+
}
274290
rsBinaryPath := filepath.Join(t.TempDir(), "rudder-server-binary")
275291
rudderserver.BuildRudderServerBinary(t, "../../main.go", rsBinaryPath)
276292
gwNode0Name := "gw-node-0"
@@ -363,6 +379,14 @@ func testPartitionMigrationGatewayProcessorMode(t *testing.T, extraStressWorkspa
363379
// wait for some time to let events flow
364380
time.Sleep(10 * time.Second)
365381

382+
if forkDestinations {
383+
// the fan-out siphon must be active: forked jobs land in the proc jobsdb of some node,
384+
// giving partition migration multi-consumer proc jobs to move
385+
require.Eventually(t, func() bool {
386+
return procJobsCount(t, pg0.DB)+procJobsCount(t, pg1.DB) > 0
387+
}, 30*time.Second, 500*time.Millisecond, "forked jobs should appear in the proc jobsdb")
388+
}
389+
366390
var srcRouterAcks []string
367391
err = rudoacker.NewSrcrouterAcker(ctx, g, etcdResource.Client, namespace, []string{"srcrouter"}).
368392
WithEventListener(func(key string, value etcdtypes.ReloadSrcRouterCommand) {
@@ -499,3 +523,13 @@ func restartingProcessorServer(t *testing.T, ctx context.Context, g *errgroup.Gr
499523
}
500524
})
501525
}
526+
527+
// procJobsCount returns the number of jobs currently present in the intermediate (proc)
528+
// jobsdb of a node. Used to assert the destination-isolation fan-out siphon is active so
529+
// partition migration has proc jobs to move.
530+
func procJobsCount(t *testing.T, db *sql.DB) int {
531+
t.Helper()
532+
var count int
533+
require.NoError(t, db.QueryRow("SELECT count(DISTINCT job_id) FROM unionjobsdbmetadata('proc', 10)").Scan(&count))
534+
return count
535+
}

jobsdb/jobsdb_multiconsumer_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,42 @@ func TestMultiConsumerJobsDB_Cache(t *testing.T) {
417417
require.NoError(t, jd.UpdateJobStatus(ctx, []*JobStatusT{succeedFor(res.Jobs[0], "B")}))
418418
require.True(t, cacheHit("A"), "B's status update must not invalidate A's cache")
419419
}
420+
421+
// TestStoreConsumersOnSingleConsumerHandle verifies that a single-consumer jobsdb preserves a
422+
// job's explicitly-set Consumers on store and round-trips them through GetUnprocessed. This is
423+
// relied upon by the partition-migration buffer (proc_buf): it is a single-consumer handle that
424+
// relays multi-consumer proc jobs between nodes, so it must carry their consumers through.
425+
// Without it, migrated jobs would resurface under the legacy ” consumer and be dropped at the
426+
// target (unknown destination ""). Jobs without explicit consumers keep the legacy ” consumer.
427+
func TestStoreConsumersOnSingleConsumerHandle(t *testing.T) {
428+
postgres := startPostgres(t)
429+
430+
prefix := strings.ToLower(rand.String(5))
431+
jd := NewForReadWrite(prefix, // NOT multi-consumer
432+
WithDBHandle(postgres.DB),
433+
WithConfig(config.New()),
434+
)
435+
require.NoError(t, jd.Start())
436+
defer jd.TearDown()
437+
438+
ctx := context.Background()
439+
const customVal = "SC"
440+
newJob := func(userID string, consumers []string) *JobT {
441+
return &JobT{
442+
UUID: uuid.New(), UserID: userID, CustomVal: customVal,
443+
Parameters: []byte(`{}`), EventPayload: []byte(`{}`), EventCount: 1,
444+
WorkspaceId: "w", Consumers: consumers,
445+
}
446+
}
447+
require.NoError(t, jd.Store(ctx, []*JobT{
448+
newJob("with", []string{"A", "B"}),
449+
newJob("without", nil),
450+
}))
451+
452+
res, err := jd.GetUnprocessed(ctx, GetQueryParams{CustomValFilters: []string{customVal}, JobsLimit: 10})
453+
require.NoError(t, err)
454+
require.Len(t, res.Jobs, 2)
455+
// GetUnprocessed returns jobs ordered by job_id, i.e. insertion order.
456+
require.Equal(t, []string{"A", "B"}, res.Jobs[0].Consumers, "explicit consumers must be preserved on a single-consumer handle")
457+
require.Equal(t, []string{""}, res.Jobs[1].Consumers, "a job without consumers keeps the legacy '' consumer")
458+
}

jobsdb/jobsdb_pending_events.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ func (pejdb *pendingEventsJobsDB) Store(ctx context.Context, jobList []*JobT) er
8484
}
8585

8686
func (pejdb *pendingEventsJobsDB) StoreInTx(ctx context.Context, tx StoreSafeTx, jobList []*JobT) error {
87+
// Register the pending-events increase only after the underlying store succeeds. The
88+
// store may be retried internally on a stale dataset list; when it runs inside a
89+
// caller-provided transaction (WithStoreSafeTxFromTx) that transaction is reused across
90+
// retries, so registering before the store would double-count on every retry.
91+
if err := pejdb.JobsDB.StoreInTx(ctx, tx, jobList); err != nil {
92+
return err
93+
}
8794
tx.Tx().AddSuccessListener(func() {
8895
counters := make(map[string]map[string]map[string]float64) // workspaceID -> destType -> destinationID -> count
8996
for _, job := range jobList {
@@ -107,7 +114,7 @@ func (pejdb *pendingEventsJobsDB) StoreInTx(ctx context.Context, tx StoreSafeTx,
107114
}
108115
}
109116
})
110-
return pejdb.JobsDB.StoreInTx(ctx, tx, jobList)
117+
return nil
111118
}
112119

113120
func (pejdb *pendingEventsJobsDB) UpdateJobStatus(ctx context.Context, statusList []*JobStatusT) error {
@@ -117,6 +124,12 @@ func (pejdb *pendingEventsJobsDB) UpdateJobStatus(ctx context.Context, statusLis
117124
}
118125

119126
func (pejdb *pendingEventsJobsDB) UpdateJobStatusInTx(ctx context.Context, tx UpdateSafeTx, statusList []*JobStatusT) error {
127+
// Register the pending-events decrease only after the underlying update succeeds: the
128+
// update retries internally on a stale dataset list against the (possibly reused)
129+
// transaction, so registering before it would double-count on every retry.
130+
if err := pejdb.JobsDB.UpdateJobStatusInTx(ctx, tx, statusList); err != nil {
131+
return err
132+
}
120133
tx.Tx().AddSuccessListener(func() {
121134
counters := make(map[string]map[string]map[string]float64) // workspaceID -> destType -> destinationID -> count
122135
for _, status := range statusList {
@@ -142,5 +155,5 @@ func (pejdb *pendingEventsJobsDB) UpdateJobStatusInTx(ctx context.Context, tx Up
142155
}
143156
}
144157
})
145-
return pejdb.JobsDB.UpdateJobStatusInTx(ctx, tx, statusList)
158+
return nil
146159
}

jobsdb/jobsdb_pending_events_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111

1212
"github.com/rudderlabs/rudder-go-kit/config"
1313
"github.com/rudderlabs/rudder-go-kit/testhelper/rand"
14+
15+
txpkg "github.com/rudderlabs/rudder-server/utils/tx"
1416
)
1517

1618
func TestPendingEventsJobsDB(t *testing.T) {
@@ -331,6 +333,48 @@ func TestPendingEventsJobsDB(t *testing.T) {
331333
require.Equal(t, float64(1), registry.decreasedByDest["A"])
332334
require.Equal(t, float64(0), registry.decreasedByDest["B"])
333335
})
336+
337+
t.Run("retry on a shared transaction increases pending events exactly once", func(t *testing.T) {
338+
// Regression: with WithStoreSafeTxFromTx the caller's transaction is reused across
339+
// the store's internal stale-dataset-list retry. The pending-events increase must be
340+
// registered only after the store succeeds, so a failed-then-successful attempt on the
341+
// same committed transaction counts once, not once per attempt.
342+
_ = startPostgres(t)
343+
jobDB := &Handle{config: config.New()}
344+
require.NoError(t, jobDB.Setup(ReadWrite, false, strings.ToLower(rand.String(5))))
345+
defer jobDB.TearDown()
346+
347+
registry := &mockPendingEventsRegistry{}
348+
// underlying store fails the first attempt (as a stale-DS retry would) and succeeds the
349+
// second, without touching the real DB — the real tx below is what actually commits.
350+
underlying := &failThenSucceedStoreJobsDB{JobsDB: jobDB, errs: []error{ErrStaleDsList, nil}}
351+
decoratedDB := NewPendingEventsJobsDB(underlying, registry).(*pendingEventsJobsDB)
352+
353+
jobs := genJobsWithDestination(3)
354+
require.NoError(t, jobDB.WithTx(context.Background(), func(tx *txpkg.Tx) error {
355+
stx := &storeSafeTx{tx: tx, identity: underlying.Identifier()}
356+
require.ErrorIs(t, decoratedDB.StoreInTx(context.Background(), stx, jobs), ErrStaleDsList)
357+
require.NoError(t, decoratedDB.StoreInTx(context.Background(), stx, jobs))
358+
return nil // commit → fire success listeners
359+
}))
360+
361+
require.Equal(t, 1, registry.increaseCallCount, "increase must fire once despite the retry")
362+
require.Equal(t, float64(3), registry.totalIncreased)
363+
})
364+
}
365+
366+
// failThenSucceedStoreJobsDB is a JobsDB whose StoreInTx returns the queued errors in order
367+
// (modelling a stale-dataset-list retry) without performing any real store. All other
368+
// behaviour is inherited from the embedded handle.
369+
type failThenSucceedStoreJobsDB struct {
370+
JobsDB
371+
errs []error
372+
}
373+
374+
func (f *failThenSucceedStoreJobsDB) StoreInTx(_ context.Context, _ StoreSafeTx, _ []*JobT) error {
375+
err := f.errs[0]
376+
f.errs = f.errs[1:]
377+
return err
334378
}
335379

336380
// mockPendingEventsRegistry tracks calls to the pending events registry

0 commit comments

Comments
 (0)