Add stale recovery to tracker mapping worker

The tracker-mapping worker clears mapping_requested_at at claim time, so
a crash or hard failure between Process phases left the pattern dequeued,
unmapped, and with nothing to re-trigger it. Only an incidental sibling
remap could rescue it, so a lone pattern could stay stranded forever.

Implement the worker.StaleRecoverer interface, mirroring the enrichment
worker. ResetStaleMappings re-arms rows that were claimed but never
assigned a catalog row (common_tracker_pattern_id IS NULL) once idle past
a configurable window; a successful Process always assigns one via the
unmatched fallback, so the predicate cleanly detects interrupted runs and
self-heals after a single pass. ClearMappingRequestedAt now bumps
updated_at so the stale clock starts at claim time and the sweep never
recycles an in-flight claim.

Plumb a StaleAfter knob (default 600s) through the config struct, builder
env var, probod wiring, and Helm templates.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-01 11:43:53 +02:00
parent 587a4f63cd
commit 952c427d2a
10 changed files with 167 additions and 5 deletions

View File

@@ -249,3 +249,78 @@ func TestTrackerPattern_Update_NotFoundForMissingRow(t *testing.T) {
assert.ErrorIs(t, err, coredata.ErrResourceNotFound)
}
// TestResetStaleMappings pins the mapping stale-recovery contract: a row
// dequeued (mapping_requested_at IS NULL) but never finished (no
// common_tracker_pattern_id) and idle past the window is re-armed, while
// a recently claimed row (clock not yet elapsed) and a completed row
// (catalog row assigned) are left untouched. Without this sweep a crash
// between Process phases would strand the pattern unmapped forever.
func TestResetStaleMappings(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedTrackerPatternFixture(t, ctx, client)
now := time.Now().UTC().Truncate(time.Microsecond)
old := now.Add(-time.Hour)
maxAge := 3600
source := coredata.CookieSourceScript
newPattern := func(pattern string, updatedAt time.Time, commonID *gid.GID) *coredata.TrackerPattern {
tp := &coredata.TrackerPattern{
ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType),
OrganizationID: fx.organizationID,
CookieBannerID: fx.cookieBannerID,
CookieCategoryID: fx.cookieCategoryID,
CommonTrackerPatternID: commonID,
TrackerType: coredata.TrackerTypeCookie,
Pattern: pattern,
MatchType: coredata.TrackerPatternMatchTypeExact,
DisplayName: pattern,
MaxAgeSeconds: &maxAge,
Source: &source,
CreatedAt: old,
UpdatedAt: updatedAt,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return tp.Insert(ctx, tx, fx.scope)
}))
return tp
}
commonPattern := coredata.CommonTrackerPattern{
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
TrackerType: coredata.TrackerTypeCookie,
Pattern: "mapped_catalog_" + gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType).String(),
MatchType: coredata.TrackerPatternMatchTypeExact,
Confidence: 0.5,
CreatedAt: old,
UpdatedAt: old,
}
insertCommonTrackerPattern(t, ctx, client, commonPattern)
stale := newPattern("stale_unfinished", old, nil)
fresh := newPattern("fresh_unfinished", now, nil)
completed := newPattern("completed_mapping", old, &commonPattern.ID)
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return coredata.ResetStaleMappings(ctx, conn, 10*time.Minute)
}))
load := func(id gid.GID) coredata.TrackerPattern {
var reloaded coredata.TrackerPattern
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return reloaded.LoadByID(ctx, conn, fx.scope, id)
}))
return reloaded
}
assert.NotNil(t, load(stale.ID).MappingRequestedAt, "claimed-but-unfinished idle row must be re-armed")
assert.Nil(t, load(fresh.ID).MappingRequestedAt, "recently claimed row must not be re-armed before the window elapses")
assert.Nil(t, load(completed.ID).MappingRequestedAt, "completed mapping (catalog row assigned) must never be re-armed")
}