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

@@ -273,6 +273,10 @@ spec:
- name: TRACKER_MAPPING_MAX_CONCURRENCY - name: TRACKER_MAPPING_MAX_CONCURRENCY
value: {{ .Values.probo.trackerMappingWorker.maxConcurrency | quote }} value: {{ .Values.probo.trackerMappingWorker.maxConcurrency | quote }}
{{- end }} {{- end }}
{{- if .Values.probo.trackerMappingWorker.staleAfter }}
- name: TRACKER_MAPPING_STALE_AFTER
value: {{ .Values.probo.trackerMappingWorker.staleAfter | quote }}
{{- end }}
{{- if .Values.probo.trackerMappingWorker.agentTimeout }} {{- if .Values.probo.trackerMappingWorker.agentTimeout }}
- name: TRACKER_MAPPING_AGENT_TIMEOUT - name: TRACKER_MAPPING_AGENT_TIMEOUT
value: {{ .Values.probo.trackerMappingWorker.agentTimeout | quote }} value: {{ .Values.probo.trackerMappingWorker.agentTimeout | quote }}

View File

@@ -176,11 +176,13 @@ probo:
# temperature: "0.1" # temperature: "0.1"
# maxTokens: "4096" # maxTokens: "4096"
# Tracker mapping worker tuning (optional; seconds for interval/agentTimeout). # Tracker mapping worker tuning (optional; seconds for
# interval/staleAfter/agentTimeout).
# Keep concurrency modest to stay under OpenAI/Firecrawl limits and the DB pool. # Keep concurrency modest to stay under OpenAI/Firecrawl limits and the DB pool.
# trackerMappingWorker: # trackerMappingWorker:
# interval: 10 # interval: 10
# maxConcurrency: 3 # maxConcurrency: 3
# staleAfter: 600
# agentTimeout: 45 # agentTimeout: 45
# agentMaxTurns: 4 # agentMaxTurns: 4

View File

@@ -276,12 +276,14 @@ probo:
temperature: "" temperature: ""
maxTokens: "" maxTokens: ""
# Tracker mapping background worker tuning (optional). interval and # Tracker mapping background worker tuning (optional). interval,
# agentTimeout are in seconds. Keep concurrency modest to stay under # staleAfter, and agentTimeout are in seconds. Keep concurrency modest
# OpenAI/Firecrawl rate limits and the database connection pool. # to stay under OpenAI/Firecrawl rate limits and the database
# connection pool.
trackerMappingWorker: trackerMappingWorker:
interval: 10 interval: 10
maxConcurrency: 3 maxConcurrency: 3
staleAfter: 600
agentTimeout: 45 agentTimeout: 45
agentMaxTurns: 4 agentMaxTurns: 4

View File

@@ -249,6 +249,7 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
TrackerMappingWorker: probodconfig.TrackerMappingWorkerConfig{ TrackerMappingWorker: probodconfig.TrackerMappingWorkerConfig{
Interval: b.getEnvIntOrDefault("TRACKER_MAPPING_INTERVAL", 10), Interval: b.getEnvIntOrDefault("TRACKER_MAPPING_INTERVAL", 10),
MaxConcurrency: b.getEnvIntOrDefault("TRACKER_MAPPING_MAX_CONCURRENCY", 3), MaxConcurrency: b.getEnvIntOrDefault("TRACKER_MAPPING_MAX_CONCURRENCY", 3),
StaleAfter: b.getEnvIntOrDefault("TRACKER_MAPPING_STALE_AFTER", 600),
AgentTimeout: b.getEnvIntOrDefault("TRACKER_MAPPING_AGENT_TIMEOUT", 45), AgentTimeout: b.getEnvIntOrDefault("TRACKER_MAPPING_AGENT_TIMEOUT", 45),
AgentMaxTurns: b.getEnvIntOrDefault("TRACKER_MAPPING_AGENT_MAX_TURNS", 4), AgentMaxTurns: b.getEnvIntOrDefault("TRACKER_MAPPING_AGENT_MAX_TURNS", 4),
}, },

View File

@@ -223,6 +223,7 @@ func TestBuilder_Build_Defaults(t *testing.T) {
// Tracker worker tuning — defaults // Tracker worker tuning — defaults
assert.Equal(t, 10, cfg.Probod.TrackerMappingWorker.Interval) assert.Equal(t, 10, cfg.Probod.TrackerMappingWorker.Interval)
assert.Equal(t, 3, cfg.Probod.TrackerMappingWorker.MaxConcurrency) assert.Equal(t, 3, cfg.Probod.TrackerMappingWorker.MaxConcurrency)
assert.Equal(t, 600, cfg.Probod.TrackerMappingWorker.StaleAfter)
assert.Equal(t, 45, cfg.Probod.TrackerMappingWorker.AgentTimeout) assert.Equal(t, 45, cfg.Probod.TrackerMappingWorker.AgentTimeout)
assert.Equal(t, 4, cfg.Probod.TrackerMappingWorker.AgentMaxTurns) assert.Equal(t, 4, cfg.Probod.TrackerMappingWorker.AgentMaxTurns)
assert.Equal(t, 10, cfg.Probod.CommonPatternEnrichmentWorker.Interval) assert.Equal(t, 10, cfg.Probod.CommonPatternEnrichmentWorker.Interval)
@@ -327,6 +328,7 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
// Tracker worker tuning override // Tracker worker tuning override
env["TRACKER_MAPPING_INTERVAL"] = "20" env["TRACKER_MAPPING_INTERVAL"] = "20"
env["TRACKER_MAPPING_MAX_CONCURRENCY"] = "5" env["TRACKER_MAPPING_MAX_CONCURRENCY"] = "5"
env["TRACKER_MAPPING_STALE_AFTER"] = "1200"
env["TRACKER_MAPPING_AGENT_TIMEOUT"] = "30" env["TRACKER_MAPPING_AGENT_TIMEOUT"] = "30"
env["TRACKER_MAPPING_AGENT_MAX_TURNS"] = "6" env["TRACKER_MAPPING_AGENT_MAX_TURNS"] = "6"
env["COMMON_PATTERN_ENRICHMENT_INTERVAL"] = "15" env["COMMON_PATTERN_ENRICHMENT_INTERVAL"] = "15"
@@ -428,6 +430,7 @@ func TestBuilder_Build_CustomValues(t *testing.T) {
// Tracker worker tuning — overrides // Tracker worker tuning — overrides
assert.Equal(t, 20, cfg.Probod.TrackerMappingWorker.Interval) assert.Equal(t, 20, cfg.Probod.TrackerMappingWorker.Interval)
assert.Equal(t, 5, cfg.Probod.TrackerMappingWorker.MaxConcurrency) assert.Equal(t, 5, cfg.Probod.TrackerMappingWorker.MaxConcurrency)
assert.Equal(t, 1200, cfg.Probod.TrackerMappingWorker.StaleAfter)
assert.Equal(t, 30, cfg.Probod.TrackerMappingWorker.AgentTimeout) assert.Equal(t, 30, cfg.Probod.TrackerMappingWorker.AgentTimeout)
assert.Equal(t, 6, cfg.Probod.TrackerMappingWorker.AgentMaxTurns) assert.Equal(t, 6, cfg.Probod.TrackerMappingWorker.AgentMaxTurns)
assert.Equal(t, 15, cfg.Probod.CommonPatternEnrichmentWorker.Interval) assert.Equal(t, 15, cfg.Probod.CommonPatternEnrichmentWorker.Interval)

View File

@@ -31,6 +31,12 @@ import (
"go.probo.inc/probo/pkg/uri" "go.probo.inc/probo/pkg/uri"
) )
// defaultMappingStaleAfter is the fallback idle window after which a
// claimed-but-unfinished tracker pattern mapping is re-armed. It is
// generous relative to a single Process run (deterministic SQL plus up
// to two bounded agent runs) so an in-flight mapping is never recycled.
const defaultMappingStaleAfter = 10 * time.Minute
type trackerMappingHandler struct { type trackerMappingHandler struct {
pg *pg.Client pg *pg.Client
logger *log.Logger logger *log.Logger
@@ -38,6 +44,7 @@ type trackerMappingHandler struct {
disambiguationAgent *agent.Agent disambiguationAgent *agent.Agent
agentTimeout time.Duration agentTimeout time.Duration
disambiguationTimeout time.Duration disambiguationTimeout time.Duration
staleAfter time.Duration
} }
func NewTrackerMappingWorker( func NewTrackerMappingWorker(
@@ -45,6 +52,7 @@ func NewTrackerMappingWorker(
logger *log.Logger, logger *log.Logger,
mappingCfg TrackerAgentsConfig, mappingCfg TrackerAgentsConfig,
disambiguationCfg thirdparty.DisambiguationConfig, disambiguationCfg thirdparty.DisambiguationConfig,
staleAfter time.Duration,
opts ...worker.Option, opts ...worker.Option,
) *worker.Worker[coredata.TrackerPattern] { ) *worker.Worker[coredata.TrackerPattern] {
agentTimeout := mappingCfg.AgentTimeout agentTimeout := mappingCfg.AgentTimeout
@@ -52,11 +60,16 @@ func NewTrackerMappingWorker(
agentTimeout = defaultAgentTimeout agentTimeout = defaultAgentTimeout
} }
if staleAfter <= 0 {
staleAfter = defaultMappingStaleAfter
}
h := &trackerMappingHandler{ h := &trackerMappingHandler{
pg: pgClient, pg: pgClient,
logger: logger, logger: logger,
agentTimeout: agentTimeout, agentTimeout: agentTimeout,
disambiguationTimeout: disambiguationCfg.Timeout, disambiguationTimeout: disambiguationCfg.Timeout,
staleAfter: staleAfter,
} }
if mappingCfg.LLMClient != nil { if mappingCfg.LLMClient != nil {
@@ -98,6 +111,24 @@ func (h *trackerMappingHandler) Claim(ctx context.Context) (coredata.TrackerPatt
return tp, nil return tp, nil
} }
// RecoverStale re-arms tracker patterns whose mapping was claimed but
// never finished. Claim clears mapping_requested_at up front, so a crash
// or hard failure between phases would otherwise strand the pattern
// unmapped with nothing to re-trigger it. ResetStaleMappings re-queues
// those rows once they have been idle past staleAfter.
func (h *trackerMappingHandler) RecoverStale(ctx context.Context) error {
return h.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := coredata.ResetStaleMappings(ctx, conn, h.staleAfter); err != nil {
return fmt.Errorf("cannot reset stale tracker pattern mappings: %w", err)
}
return nil
},
)
}
// catalogMatch is the result of a single catalog signal. commonPatternID // catalogMatch is the result of a single catalog signal. commonPatternID
// is the catalog row the signal resolved (or backfilled); commonThirdPartyID // is the catalog row the signal resolved (or backfilled); commonThirdPartyID
// is the catalog third party the signal discovered, when any; thirdPartyID // is the catalog third party the signal discovered, when any; thirdPartyID

View File

@@ -1196,13 +1196,19 @@ LIMIT 1;
return nil return nil
} }
// ClearMappingRequestedAt removes the row from the mapping queue. It
// bumps updated_at so the stale-recovery clock starts at claim time,
// keeping ResetStaleMappings from re-arming a row that is still being
// processed.
func (tp *TrackerPattern) ClearMappingRequestedAt( func (tp *TrackerPattern) ClearMappingRequestedAt(
ctx context.Context, ctx context.Context,
tx pg.Tx, tx pg.Tx,
) error { ) error {
q := ` q := `
UPDATE tracker_patterns UPDATE tracker_patterns
SET mapping_requested_at = NULL SET
mapping_requested_at = NULL,
updated_at = NOW()
WHERE id = @id WHERE id = @id
` `
@@ -1218,6 +1224,42 @@ WHERE id = @id
return nil return nil
} }
// ResetStaleMappings re-arms mapping_requested_at on rows whose mapping
// was claimed but never completed (no common_tracker_pattern_id) and
// have been idle longer than staleAfter, so a crashed or timed-out
// mapping run is retried. A successful Process always assigns a catalog
// row (the unmatched fallback in createUnmatchedPattern), so a missing
// common_tracker_pattern_id on a dequeued row marks an interrupted run.
//
// Like the claim query, this sweep is intentionally cross-tenant: the
// mapping worker is a system worker that drains the queue regardless of
// tenant.
func ResetStaleMappings(
ctx context.Context,
conn pg.Querier,
staleAfter time.Duration,
) error {
q := `
UPDATE tracker_patterns
SET
mapping_requested_at = NOW(),
updated_at = NOW()
WHERE
mapping_requested_at IS NULL
AND common_tracker_pattern_id IS NULL
AND updated_at < @stale_before
`
args := pgx.StrictNamedArgs{"stale_before": time.Now().Add(-staleAfter)}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot reset stale tracker pattern mappings: %w", err)
}
return nil
}
func (tp *TrackerPattern) SetMappingRequested( func (tp *TrackerPattern) SetMappingRequested(
ctx context.Context, ctx context.Context,
tx pg.Tx, tx pg.Tx,

View File

@@ -249,3 +249,78 @@ func TestTrackerPattern_Update_NotFoundForMissingRow(t *testing.T) {
assert.ErrorIs(t, err, coredata.ErrResourceNotFound) 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")
}

View File

@@ -730,6 +730,7 @@ func (impl *Implm) Run(
l, l,
trackerAgentsCfg, trackerAgentsCfg,
thirdPartyDisambiguationCfg, thirdPartyDisambiguationCfg,
time.Duration(impl.cfg.TrackerMappingWorker.StaleAfter)*time.Second,
worker.WithInterval(time.Duration(impl.cfg.TrackerMappingWorker.Interval)*time.Second), worker.WithInterval(time.Duration(impl.cfg.TrackerMappingWorker.Interval)*time.Second),
worker.WithMaxConcurrency(impl.cfg.TrackerMappingWorker.MaxConcurrency), worker.WithMaxConcurrency(impl.cfg.TrackerMappingWorker.MaxConcurrency),
) )

View File

@@ -48,6 +48,7 @@ type (
TrackerMappingWorkerConfig struct { TrackerMappingWorkerConfig struct {
Interval int `json:"interval"` // seconds between polls Interval int `json:"interval"` // seconds between polls
MaxConcurrency int `json:"max-concurrency"` MaxConcurrency int `json:"max-concurrency"`
StaleAfter int `json:"stale-after"` // seconds before a claim is recycled
AgentTimeout int `json:"agent-timeout"` // seconds, single agent run AgentTimeout int `json:"agent-timeout"` // seconds, single agent run
AgentMaxTurns int `json:"agent-max-turns"` AgentMaxTurns int `json:"agent-max-turns"`
} }