Skip to content

Commit 55c7b9f

Browse files
feat(worker): cohort skip-guards — no-op every team-iterating job for is_test_cohort teams (PR-1 follow-up) (#90)
* feat(worker): cohort skip-guards — no-op every team-iterating job for is_test_cohort teams (PR-1 follow-up) Worker-side follow-up to api #246 (W0): teams.is_test_cohort (migration 067, already on master) tags durable synthetic test-cohort teams. This makes every background job that charges / churns / emails / quota-nudges a team SKIP an is_test_cohort team, so continuous synthetic monitoring never pollutes the real funnel / billing / email surface. Plan: docs/sessions/2026-06-04/TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md §1.6. Mechanism — two skip surfaces, both UNCONDITIONAL + inert (no real team is is_test_cohort=true today, so behaviour is unchanged for all real teams; this is a safety guard, not a feature, and does NOT depend on FLOW_SYNTHETIC_ENABLED): - SQL scans add `AND NOT [t.]is_test_cohort` (teams-joined) or a NULL-safe NOT EXISTS subselect (testCohortNotExistsClause, for team_id-carrying scans). - Per-team Go loops can call isTestCohort/skipIfTestCohort (shared helper). Guarded (§1.6): quota.go (suspend/unsuspend/redis-eviction scans), quota_wall_nudge.go, churn_predictor.go, expire_imminent.go, expiry_reminder.go, billing_reconciler.go (primary + orphan sweep), checkout_reconcile.go, payment_grace_reminder.go, payment_grace_terminator.go, email.go (weekly_digest). Deliberately NOT guarded (documented in test_cohort_registry_test.go): expire.go + expire_stacks.go (TTL REAPERS — must reap synthetic resources/stacks so they never leak; no customer email), lifecycle_emails.go (pure render layer, gated by the guarded trigger jobs upstream). Tests: - test_cohort_test.go: sqlmock unit coverage of the helper (flagged / not-flagged / row-vanished / DB-error fail-safe-for-real-teams). 100% of test_cohort.go. - test_cohort_registry_test.go: rule-18 enumeration net — TestAllTeamIteratingJobs_FilterTestCohort fails if any enumerated job loses its guard, plus TestNotGuardedJobs_AreStillPresent pins the scope boundary. - test_cohort_integration_test.go: real-Postgres round-trips proving each SQL-driven job skips a cohort team while processing a normal team (quota_wall_nudge, churn_predictor, expiry_reminder, expire_imminent, weekly_digest, checkout_reconcile, payment_grace_reminder, billing_reconciler). Gated by SetupTestDB (skips no-DB lanes). testhelpers: add is_test_cohort + the users/pending_checkouts/ payment_grace_periods subset tables + cohort seed/read helpers. The two-gate note (ci.yml + deploy.yml) holds: the harness change is idempotent and DB-gated so both -short, no-DB workflows stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(testhelpers): in-package smoke test for cohort.go → 100% (unblock #90 coverage) cohort.go is exercised only cross-package by internal/jobs integration tests, so the per-package coverage job reported it 0% (94 missing lines) → 100%-patch gate red. Add an in-package smoke test (DB-gated via SetupTestDB, runs in coverage.yml) bringing all 8 seed/query helpers to 100%, mirroring billing_deletion_smoke_test.go. Recurring testhelpers gotcha (3rd: #87/#89/#90) — see memory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent baa1aba commit 55c7b9f

17 files changed

Lines changed: 1209 additions & 31 deletions

internal/jobs/billing_reconciler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,7 @@ func (w *BillingReconcilerWorker) Work(ctx context.Context, job *river.Job[Billi
822822
FROM teams
823823
WHERE stripe_customer_id IS NOT NULL
824824
AND stripe_customer_id != ''
825+
AND NOT is_test_cohort
825826
ORDER BY id
826827
LIMIT $1
827828
`, billingReconcilerBatchLimit)
@@ -1204,6 +1205,7 @@ func (w *BillingReconcilerWorker) runOrphanSweep(ctx context.Context) (scanned,
12041205
JOIN teams t ON t.id = pc.team_id
12051206
WHERE pc.subscription_id IS NOT NULL
12061207
AND pc.subscription_id != ''
1208+
AND NOT t.is_test_cohort
12071209
AND pc.created_at < $1
12081210
ORDER BY pc.created_at
12091211
LIMIT $2

internal/jobs/checkout_reconcile.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,10 @@ func (w *CheckoutReconcileWorker) Work(ctx context.Context, job *river.Job[Check
148148

149149
rows, err := w.db.QueryContext(ctx, `
150150
SELECT subscription_id, team_id, customer_email, plan_tier
151-
FROM pending_checkouts
151+
FROM pending_checkouts pc
152152
WHERE resolved_at IS NULL
153153
AND failure_notified_at IS NULL
154+
AND `+testCohortNotExistsClause("pc.team_id")+`
154155
AND created_at < $1
155156
ORDER BY created_at ASC
156157
LIMIT $2

internal/jobs/churn_predictor.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,14 @@ const churnInactivityWindow = 7 * 24 * time.Hour
8787
// churnDedupeWindow is the minimum gap between two churn.risk_flagged
8888
// rows for the same team. 30 days is the brief's window. Rationale:
8989
//
90-
// * Slower than 30d would let a team get re-flagged month after month
90+
// - Slower than 30d would let a team get re-flagged month after month
9191
// without intervention — operationally noisy and emotionally
9292
// repetitive ("you again missed us") for the customer.
93-
// * Faster than 30d (e.g. weekly) would risk sending the same
93+
// - Faster than 30d (e.g. weekly) would risk sending the same
9494
// "we miss you" copy four times in a month to a customer who has
9595
// decided not to return; the perception cost (spammy) outweighs
9696
// the recall benefit.
97-
// * 30d also aligns with monthly billing cycles — most B2B SaaS
97+
// - 30d also aligns with monthly billing cycles — most B2B SaaS
9898
// reactivation playbooks rate-limit on a monthly cadence for the
9999
// same reason.
100100
//
@@ -210,6 +210,7 @@ func (w *ChurnPredictorWorker) Work(ctx context.Context, job *river.Job[ChurnPre
210210
)
211211
LEFT JOIN resources r ON r.team_id = t.id AND r.status = 'active'
212212
WHERE t.plan_tier != 'team'
213+
AND NOT t.is_test_cohort
213214
AND NOT EXISTS (
214215
SELECT 1 FROM audit_log f
215216
WHERE f.team_id = t.id
@@ -300,10 +301,10 @@ func (w *ChurnPredictorWorker) Work(ctx context.Context, job *river.Job[ChurnPre
300301
// the brief enumerated lives here: tier, last_activity_days_ago,
301302
// active_resource_count, email.
302303
meta := map[string]any{
303-
"tier": r.planTier,
304-
"last_activity_days_ago": daysSince,
305-
"active_resource_count": r.activeResourceCount,
306-
"email": r.ownerEmail.String,
304+
"tier": r.planTier,
305+
"last_activity_days_ago": daysSince,
306+
"active_resource_count": r.activeResourceCount,
307+
"email": r.ownerEmail.String,
307308
}
308309
metaBytes, mErr := churnMetaMarshal(meta)
309310
if mErr != nil {

internal/jobs/email.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ func (w *WeeklyDigestWorker) Work(ctx context.Context, job *river.Job[WeeklyDige
120120
FROM users u
121121
JOIN teams t ON t.id = u.team_id
122122
WHERE t.plan_tier != 'anonymous'
123+
AND NOT t.is_test_cohort
123124
AND NOT EXISTS (
124125
SELECT 1 FROM audit_log a
125126
WHERE a.team_id = t.id
@@ -228,11 +229,12 @@ func (w *WeeklyDigestWorker) buildResourceDigestCounts(ctx context.Context, team
228229
// to if a per-row count is missing.
229230
//
230231
// Metadata shape — buildDigestWeekly (event_email_mapping.go) reads these:
231-
// email — recipient address (also the forwarder's resolver)
232-
// team_name — display name for the email greeting (may be "")
233-
// total_active_resources — sum across all resource_types; 0 means "empty week"
234-
// resource_breakdown — JSON-encoded array of {resource_type, count}
235-
// (templates that don't render a table can ignore)
232+
//
233+
// email — recipient address (also the forwarder's resolver)
234+
// team_name — display name for the email greeting (may be "")
235+
// total_active_resources — sum across all resource_types; 0 means "empty week"
236+
// resource_breakdown — JSON-encoded array of {resource_type, count}
237+
// (templates that don't render a table can ignore)
236238
func emitWeeklyDigestAudit(ctx context.Context, db *sql.DB, teamID uuid.UUID, email, teamName string, stats []DigestResourceCount) error {
237239
var total int64
238240
for _, s := range stats {
@@ -268,4 +270,3 @@ func emitWeeklyDigestAudit(ctx context.Context, db *sql.DB, teamID uuid.UUID, em
268270
}
269271
return nil
270272
}
271-

internal/jobs/expire_imminent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ func (w *ExpireImminentWorker) Work(ctx context.Context, job *river.Job[ExpireIm
167167
AND r.expires_at IS NOT NULL
168168
AND r.expires_at > $1
169169
AND r.expires_at < $2
170+
AND `+testCohortNotExistsClause("r.team_id")+`
170171
AND NOT EXISTS (
171172
SELECT 1
172173
FROM audit_log al
@@ -303,4 +304,3 @@ func (w *ExpireImminentWorker) Work(ctx context.Context, job *river.Job[ExpireIm
303304
)
304305
return nil
305306
}
306-

internal/jobs/expiry_reminder.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ func (w *ExpiryReminderWorker) Work(ctx context.Context, job *river.Job[ExpiryRe
181181
WHERE r.team_id IS NOT NULL
182182
AND r.tier = 'free'
183183
AND r.status = 'active'
184+
AND `+testCohortNotExistsClause("r.team_id")+`
184185
AND r.expires_at IS NOT NULL
185186
AND r.expires_at > $1
186187
AND r.expires_at <= $2

internal/jobs/payment_grace_reminder.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,11 @@ func (w *PaymentGraceReminderWorker) Work(ctx context.Context, job *river.Job[Pa
113113
// first so a backlog drains in FIFO order.
114114
rows, err := w.db.QueryContext(ctx, `
115115
SELECT id, team_id, expires_at
116-
FROM payment_grace_periods
116+
FROM payment_grace_periods pgp
117117
WHERE status = 'active'
118118
AND expires_at > $1
119119
AND (last_reminder_at IS NULL OR last_reminder_at < $2)
120+
AND `+testCohortNotExistsClause("pgp.team_id")+`
120121
ORDER BY last_reminder_at ASC NULLS FIRST, started_at ASC
121122
LIMIT $3
122123
`, now, cutoff, paymentGraceReminderBatchLimit)
@@ -186,9 +187,9 @@ func (w *PaymentGraceReminderWorker) Work(ctx context.Context, job *river.Job[Pa
186187

187188
hoursRemaining := math.Round(r.expiresAt.Sub(now).Hours()*10) / 10
188189
meta := map[string]any{
189-
"grace_id": r.id.String(),
190-
"hours_remaining": hoursRemaining,
191-
"grace_ends_at": r.expiresAt.UTC().Format(time.RFC3339),
190+
"grace_id": r.id.String(),
191+
"hours_remaining": hoursRemaining,
192+
"grace_ends_at": r.expiresAt.UTC().Format(time.RFC3339),
192193
}
193194
metaBytes, mErr := graceReminderMetaMarshal(meta)
194195
if mErr != nil {

internal/jobs/payment_grace_terminator.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,10 @@ func (w *PaymentGraceTerminatorWorker) Work(ctx context.Context, job *river.Job[
149149
// Candidate query: active grace rows whose clock has expired.
150150
rows, err := w.db.QueryContext(ctx, `
151151
SELECT id, team_id, expires_at
152-
FROM payment_grace_periods
152+
FROM payment_grace_periods pgp
153153
WHERE status = 'active'
154154
AND expires_at < $1
155+
AND `+testCohortNotExistsClause("pgp.team_id")+`
155156
ORDER BY expires_at ASC
156157
LIMIT $2
157158
`, now, paymentGraceTerminatorBatchLimit)
@@ -308,14 +309,15 @@ func (w *PaymentGraceTerminatorWorker) terminate(ctx context.Context, teamID uui
308309
// HS256 algorithm and the claim set below.
309310
//
310311
// Claims:
311-
// sub — the team id being terminated (allows the api to assert the
312-
// path :id matches the JWT subject; prevents a stolen token
313-
// from terminating a different team)
314-
// iss — "instanode-worker" so the api can route on issuer
315-
// iat — issued-at (UTC seconds)
316-
// exp — issued-at + 5 minutes (short window — single-shot use)
317-
// aud — "internal-teams-terminate" so the same secret can't be
318-
// re-used by an attacker against a different internal route
312+
//
313+
// sub — the team id being terminated (allows the api to assert the
314+
// path :id matches the JWT subject; prevents a stolen token
315+
// from terminating a different team)
316+
// iss — "instanode-worker" so the api can route on issuer
317+
// iat — issued-at (UTC seconds)
318+
// exp — issued-at + 5 minutes (short window — single-shot use)
319+
// aud — "internal-teams-terminate" so the same secret can't be
320+
// re-used by an attacker against a different internal route
319321
func signWorkerInternalJWT(secret, teamID string) (string, error) {
320322
if secret == "" {
321323
return "", errors.New("empty secret")

internal/jobs/quota.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,10 @@ func (w *EnforceStorageQuotaWorker) runRedisEvictionLoop(ctx context.Context) (i
241241
for {
242242
rows, err := w.db.QueryContext(ctx, `
243243
SELECT id, token, tier, storage_bytes
244-
FROM resources
244+
FROM resources r
245245
WHERE status = $1
246246
AND resource_type = 'redis'
247+
AND `+testCohortNotExistsClause("r.team_id")+`
247248
AND id::text > $2
248249
ORDER BY id::text ASC
249250
LIMIT $3
@@ -362,9 +363,10 @@ func (w *EnforceStorageQuotaWorker) runSuspendLoop(ctx context.Context) ([]strin
362363
SELECT id, token, resource_type, tier, storage_bytes,
363364
COALESCE(provider_resource_id, ''),
364365
team_id, COALESCE(name, '')
365-
FROM resources
366+
FROM resources r
366367
WHERE status = $1
367368
AND resource_type IN ('postgres', 'redis', 'mongodb')
369+
AND `+testCohortNotExistsClause("r.team_id")+`
368370
AND id::text > $2
369371
ORDER BY id::text ASC
370372
LIMIT $3
@@ -528,9 +530,10 @@ func (w *EnforceStorageQuotaWorker) runUnsuspendLoop(ctx context.Context, skipID
528530
SELECT id, token, resource_type, tier, storage_bytes,
529531
COALESCE(provider_resource_id, ''),
530532
team_id, COALESCE(name, '')
531-
FROM resources
533+
FROM resources r
532534
WHERE status = $1
533535
AND resource_type IN ('postgres', 'redis', 'mongodb')
536+
AND `+testCohortNotExistsClause("r.team_id")+`
534537
AND id::text > $2
535538
ORDER BY id::text ASC
536539
LIMIT $3

internal/jobs/quota_wall_nudge.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ func (w *QuotaWallNudgeWorker) Work(ctx context.Context, job *river.Job[QuotaWal
145145
SELECT id, plan_tier
146146
FROM teams
147147
WHERE plan_tier NOT IN ('team', 'anonymous', 'free')
148+
AND NOT is_test_cohort
148149
AND id::text > $1
149150
ORDER BY id::text ASC
150151
LIMIT $2

0 commit comments

Comments
 (0)