Skip to content

Commit bcf4a2b

Browse files
authored
refactor: rename daily maintenance workflow (#50)
## Summary - Rename the misleading retention job subsystem to daily maintenance. - Keep durable activation-metric and orphan-upload cleanup coordination. - Preserve existing rows with a guarded in-place database migration. - Correct runtime contracts, documentation, and dead-code configuration. ## Architecture The webhooks Worker continues to run a durable two-phase daily workflow. The public code vocabulary and physical table now describe the actual work: activation metrics followed by orphan-upload cleanup. Committed artifacts remain outside this maintenance workflow and persist until project or account deletion. ## Decisions | Decision | Choice | Reason | |---|---|---| | Lifecycle coordination | Keep durable database jobs | Leases, retries, cursors, and crash recovery require persisted state | | Schema cutover | Rename the table in place | Preserves queued jobs and operational history | | Active work | Refuse migration while a lease is active | Prevents changing workflow state beneath a running owner | | Artifact retention | Exclude committed output expiry | Outputs persist until explicit project or account deletion | ## Validation - [x] Skills build - [x] Typecheck - [x] Lint - [x] Production build - [x] Architecture check - [x] Dead-code check - [x] Production migration dry-run
1 parent e92ca23 commit bcf4a2b

27 files changed

Lines changed: 3688 additions & 709 deletions

apps/agent-worker/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ admission events carry the planned logical model, while stream-attempt/completio
153153
the resolved logical model. A failure before any stream attempt keeps planned attribution instead;
154154
provider-local transport IDs remain structured-log context. R2-backed artifact
155155
persistence also atomically claims the user's durable first-artifact timestamp before emitting the
156-
`first_generated_artifact` activation signal, so output retention cannot make it fire twice.
156+
`first_generated_artifact` activation signal, so later project or account cleanup cannot make it
157+
fire twice.
157158
Terminal database status updates are persisted or durably queued in AgentRun's
158159
SQLite storage; alarms retry transient database failures with bounded exponential delay
159160
until the database accepts the update. A terminal Postgres status is deliberately held

apps/webhooks-worker/README.md

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ fields, and verifies only exact `v1,<base64>` signature tokens (including provid
2222
sets). Legacy payload versions and field aliases are rejected at ingress.
2323
Signed `/internal/webhooks/replay` commands also claim a durable envelope identity before changing
2424
Workflow state, so the same authenticated maintenance request cannot restart or resume twice.
25-
`OpsMaintenanceWorkflow` runs analytics watchdogs, daily
26-
retention metrics, generated-output retention, BYOK maintenance inventory,
25+
`OpsMaintenanceWorkflow` runs analytics watchdogs, daily activation metrics,
26+
abandoned-upload cleanup, BYOK maintenance inventory,
2727
and Clerk-driven GDPR deletion lifecycle jobs from Worker cron/webhook triggers. Account deletion
2828
jobs call the agent Worker through a Service Binding and clear quota state through a direct
2929
cross-Worker Durable Object binding before removing R2 and Postgres rows. These destructive
@@ -73,8 +73,8 @@ Analytics Engine SQL reads are timeout- and byte-bounded before schema narrowing
7373
Polar cleanup calls have a 30-second request deadline, a 1 MiB response-stream
7474
ceiling, and one 100-order page per durable account-deletion action.
7575

76-
Daily activation and generated-output cleanup are one restart-safe state machine in
77-
`v2_retention_jobs`. The daily trigger registers one UTC-day row; the five-minute reconciler leases
76+
Daily activation and orphan-upload cleanup are one restart-safe state machine in
77+
`v2_daily_maintenance_jobs`. The daily trigger registers one UTC-day row; the five-minute reconciler leases
7878
queued or expired rows and recreates every live reserved continuation for the active Worker release.
7979
An errored or terminated deterministic instance is restarted, a failed initial creation returns to
8080
database backoff, and a continuation reserved before its parent exits remains discoverable even if
@@ -88,8 +88,8 @@ and compare-and-swaps the exact lease, phase, and both persisted cursors. A gene
8888
four pages. Initial and continuation payloads are distinct and bind the UTC day, continuation,
8989
release UUID, and lease UUID into a deterministic identity below Cloudflare's 100-character limit.
9090
The chain therefore has no total activation-row cap, while conservative step retries keep each
91-
generation comfortably inside its two-hour database lease. Cleanup cannot begin until the terminal
92-
activation page atomically changes the durable phase. Since Analytics Engine is append-only and a
91+
generation comfortably inside its two-hour database lease. Orphan-upload cleanup cannot begin until
92+
the terminal activation page atomically changes the durable phase. Since Analytics Engine is append-only and a
9393
Workflow step may replay, cohort SQL must use `count(DISTINCT blob6)` for those event identities.
9494
BYOK maintenance dispatches
9595
every five minutes and claims only ten fingerprints per database step under
@@ -109,28 +109,15 @@ project run tombstones use 25-run pages, and thread jobs complete each run's Dur
109109
prefix before advancing. The Worker keeps Cloudflare's explicit 10,000-subrequest ceiling;
110110
continuation boundaries, rather than a total-row cap, bound each execution.
111111

112-
Expired generated outputs are scanned through the expiry/id index in 500-row keyset pages.
113-
Before the first output cursor advances, cleanup drains only upload intents whose terminal run,
114-
explicit awaited-artifact quiescence timestamp, and `cleanup_not_before` remote-side-effect grace
115-
deadline are all at or before the day's fixed cutoff. Each intent page revalidates the exact
116-
retention lease, deletes its deterministic R2 keys, and removes the matching intent identities
117-
transactionally while retaining the existing `cleanup` phase. Unquiesced or grace-fenced intents
118-
remain indexed for recovery. Generated outputs use the same terminal-run requirement in both page
119-
selection and exact row deletion, so an active run can renew an idempotently replayed output before
120-
issuing a download capability without racing retention. A database trigger makes terminal run state
121-
immutable, preserving that maintenance proof across the object-first deletion gap.
122-
Each page deletes its objects from the `R2_OUTPUTS` binding before conditionally deleting
123-
the exact matching Postgres rows and advancing the cursor in one database transaction. The R2
124-
attempt first compare-and-swaps the exact current phase and cursor; the transactional row deletion
125-
repeats that exact guard, so an earlier cached step cannot regress or destroy work after progress
126-
moves. R2 deletion is idempotent, so a failed database step safely retries the same page. A
127-
generation handles at most two pages and hands the fenced lease to another continuation. There is
128-
no total cleanup-row cap, and the day is terminal only after an empty cleanup page is observed.
129-
The `cheatcode-outputs` bucket also has a 60-day `expired-output-failsafe` lifecycle rule. It is
130-
deliberately longer than the application-owned 30-day expiry: the Workflow remains responsible for
131-
coordinated R2/Postgres deletion, while the native rule bounds storage leakage during a prolonged
132-
maintenance outage. Every protected backend release verifies the complete lifecycle rule set against
133-
`infra/cloudflare/production-r2-contract.json` before writers close and again before the gateway opens.
112+
Orphan-upload cleanup scans only upload intents whose terminal run, explicit awaited-artifact
113+
quiescence timestamp, and `cleanup_not_before` remote-side-effect grace deadline are all at or before
114+
the day's fixed cutoff. Each intent page revalidates the exact daily-maintenance lease, deletes its
115+
deterministic R2 keys, and removes the matching intent identities transactionally while retaining
116+
the `orphan-upload-cleanup` phase. Unquiesced or grace-fenced intents remain indexed for recovery.
117+
The external object deletion is idempotent, and the exact row identities are deleted in the same
118+
transaction that completes the maintenance job only after an empty page is observed. Committed
119+
generated outputs are outside this state machine and persist until project or account deletion;
120+
only their signed download capabilities are short lived.
134121

135122
Clerk deletion is a durable Postgres soft-delete, not a sleeping Workflow. After the
136123
30-day grace deadline, the five-minute reconciler discovers at most 25 new generations

apps/webhooks-worker/src/retention-admission.ts renamed to apps/webhooks-worker/src/daily-maintenance-admission.ts

Lines changed: 56 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,40 @@
11
import {
2-
claimReadyRetentionJobs,
2+
claimReadyDailyMaintenanceJobs,
33
createDb,
4+
type DailyMaintenanceJobLease,
45
type Database,
5-
deferRetentionJob,
6-
listLiveRetentionJobLeases,
7-
purgeCompletedRetentionJobs,
8-
type RetentionJobLease,
9-
registerDailyRetentionJob,
6+
deferDailyMaintenanceJob,
7+
listLiveDailyMaintenanceJobLeases,
8+
purgeCompletedDailyMaintenanceJobs,
9+
registerDailyMaintenanceJob,
1010
} from "@cheatcode/db";
1111
import { createLogger, emitErrorEvent, safeErrorTelemetry } from "@cheatcode/observability";
1212
import { z } from "zod";
13-
import { assertReleaseOpen } from "./release-gate";
1413
import {
15-
activeRetentionReleaseVersion,
16-
createRetentionInstance,
14+
activeDailyMaintenanceReleaseVersion,
15+
createDailyMaintenanceInstance,
16+
type DailyMaintenanceEnv,
1717
previousUtcDay,
18-
type RetentionMaintenanceEnv,
19-
} from "./retention-maintenance";
18+
} from "./daily-maintenance-workflow";
19+
import { assertReleaseOpen } from "./release-gate";
2020
import type { DeterministicWorkflowResult } from "./workflow-instance";
2121

22-
const RETENTION_RECONCILIATION_LIMIT = 25;
23-
const RETENTION_CREATION_CONCURRENCY = 5;
24-
const RETENTION_TOMBSTONE_MS = 32 * 24 * 60 * 60 * 1000;
25-
const CREATION_ERROR_CODE = "retention_instance_creation_failed";
26-
const ORPHANED_COMPLETION_ERROR_CODE = "retention_instance_completed_without_job_completion";
22+
const MAINTENANCE_RECONCILIATION_LIMIT = 25;
23+
const MAINTENANCE_CREATION_CONCURRENCY = 5;
24+
const MAINTENANCE_TOMBSTONE_MS = 32 * 24 * 60 * 60 * 1000;
25+
const CREATION_ERROR_CODE = "daily_maintenance_instance_creation_failed";
26+
const ORPHANED_COMPLETION_ERROR_CODE =
27+
"daily_maintenance_instance_completed_without_job_completion";
2728
const ScheduledTimeSchema = z.number().int().nonnegative().max(8_640_000_000_000_000);
2829

2930
type ReconciliationSource = "claimed" | "live";
3031

3132
interface ReconciliationCandidate {
32-
lease: RetentionJobLease;
33+
lease: DailyMaintenanceJobLease;
3334
source: ReconciliationSource;
3435
}
3536

36-
export interface RetentionReconciliationSummary {
37+
export interface DailyMaintenanceReconciliationSummary {
3738
claimed: number;
3839
created: number;
3940
deferred: number;
@@ -44,24 +45,24 @@ export interface RetentionReconciliationSummary {
4445
}
4546

4647
/** Register a daily job, then use the same recovery path as the five-minute reconciler. */
47-
export async function enqueueDailyRetentionMetrics(
48-
env: RetentionMaintenanceEnv,
48+
export async function enqueueDailyMaintenance(
49+
env: DailyMaintenanceEnv,
4950
scheduledTimeInput: number,
50-
): Promise<RetentionReconciliationSummary> {
51+
): Promise<DailyMaintenanceReconciliationSummary> {
5152
assertReleaseOpen(env);
5253
const scheduledAt = new Date(ScheduledTimeSchema.parse(scheduledTimeInput));
5354
await withDatabase(env, (db) =>
54-
registerDailyRetentionJob(db, { day: previousUtcDay(scheduledAt), scheduledAt }),
55+
registerDailyMaintenanceJob(db, { day: previousUtcDay(scheduledAt), scheduledAt }),
5556
);
56-
return reconcileDailyRetentionWorkflows(env);
57+
return reconcileDailyMaintenanceWorkflows(env);
5758
}
5859

5960
/** Recreate live reserved generations and lease every due/expired day in bounded batches. */
60-
export async function reconcileDailyRetentionWorkflows(
61-
env: RetentionMaintenanceEnv,
62-
): Promise<RetentionReconciliationSummary> {
61+
export async function reconcileDailyMaintenanceWorkflows(
62+
env: DailyMaintenanceEnv,
63+
): Promise<DailyMaintenanceReconciliationSummary> {
6364
assertReleaseOpen(env);
64-
const releaseVersionId = activeRetentionReleaseVersion(env);
65+
const releaseVersionId = activeDailyMaintenanceReleaseVersion(env);
6566
const now = new Date();
6667
const state = await loadReconciliationState(env, now, releaseVersionId);
6768
const eligibleLive = state.live.filter((lease) => lease.releaseVersionId === releaseVersionId);
@@ -79,22 +80,26 @@ export async function reconcileDailyRetentionWorkflows(
7980
}
8081

8182
async function loadReconciliationState(
82-
env: RetentionMaintenanceEnv,
83+
env: DailyMaintenanceEnv,
8384
now: Date,
8485
releaseVersionId: string,
85-
): Promise<{ claimed: RetentionJobLease[]; live: RetentionJobLease[]; purged: number }> {
86+
): Promise<{
87+
claimed: DailyMaintenanceJobLease[];
88+
live: DailyMaintenanceJobLease[];
89+
purged: number;
90+
}> {
8691
return withDatabase(env, async (db) => {
87-
const purged = await purgeCompletedRetentionJobs(
92+
const purged = await purgeCompletedDailyMaintenanceJobs(
8893
db,
89-
new Date(now.getTime() - RETENTION_TOMBSTONE_MS),
94+
new Date(now.getTime() - MAINTENANCE_TOMBSTONE_MS),
9095
);
91-
const live = await listLiveRetentionJobLeases(db, {
92-
limit: RETENTION_RECONCILIATION_LIMIT,
96+
const live = await listLiveDailyMaintenanceJobLeases(db, {
97+
limit: MAINTENANCE_RECONCILIATION_LIMIT,
9398
now,
9499
});
95-
const claimed = await claimReadyRetentionJobs(db, {
100+
const claimed = await claimReadyDailyMaintenanceJobs(db, {
96101
leaseToken: crypto.randomUUID(),
97-
limit: RETENTION_RECONCILIATION_LIMIT,
102+
limit: MAINTENANCE_RECONCILIATION_LIMIT,
98103
now,
99104
releaseVersionId,
100105
});
@@ -103,30 +108,30 @@ async function loadReconciliationState(
103108
}
104109

105110
async function createReconciledInstances(
106-
env: RetentionMaintenanceEnv,
111+
env: DailyMaintenanceEnv,
107112
candidates: ReconciliationCandidate[],
108-
): Promise<Omit<RetentionReconciliationSummary, "claimed" | "purged" | "staleRelease">> {
113+
): Promise<Omit<DailyMaintenanceReconciliationSummary, "claimed" | "purged" | "staleRelease">> {
109114
const summary = { created: 0, deferred: 0, restarted: 0, reused: 0 };
110-
for (let offset = 0; offset < candidates.length; offset += RETENTION_CREATION_CONCURRENCY) {
111-
const batch = candidates.slice(offset, offset + RETENTION_CREATION_CONCURRENCY);
115+
for (let offset = 0; offset < candidates.length; offset += MAINTENANCE_CREATION_CONCURRENCY) {
116+
const batch = candidates.slice(offset, offset + MAINTENANCE_CREATION_CONCURRENCY);
112117
const settled = await Promise.allSettled(
113-
batch.map(({ lease }) => createRetentionInstance(env, lease)),
118+
batch.map(({ lease }) => createDailyMaintenanceInstance(env, lease)),
114119
);
115120
await accountForReconciliationBatch(env, batch, settled, summary);
116121
}
117122
return summary;
118123
}
119124

120125
async function accountForReconciliationBatch(
121-
env: RetentionMaintenanceEnv,
126+
env: DailyMaintenanceEnv,
122127
candidates: ReconciliationCandidate[],
123128
settled: PromiseSettledResult<DeterministicWorkflowResult>[],
124129
summary: { created: number; deferred: number; restarted: number; reused: number },
125130
): Promise<void> {
126131
for (const [index, result] of settled.entries()) {
127132
const candidate = candidates[index];
128133
if (!candidate) {
129-
throw new Error("Retention reconciliation lost a lease identity");
134+
throw new Error("Daily maintenance reconciliation lost a lease identity");
130135
}
131136
if (result.status === "rejected") {
132137
await accountForCreationFailure(env, candidate, result.reason, summary);
@@ -137,7 +142,7 @@ async function accountForReconciliationBatch(
137142
}
138143

139144
async function accountForCreationFailure(
140-
env: RetentionMaintenanceEnv,
145+
env: DailyMaintenanceEnv,
141146
candidate: ReconciliationCandidate,
142147
error: unknown,
143148
summary: { deferred: number },
@@ -146,7 +151,7 @@ async function accountForCreationFailure(
146151
const deferred = await tryDeferLease(env, candidate.lease, CREATION_ERROR_CODE);
147152
summary.deferred += deferred ? 1 : 0;
148153
}
149-
createLogger().error("retention_instance_reconciliation_failed", {
154+
createLogger().error("daily_maintenance_instance_reconciliation_failed", {
150155
continuation: candidate.lease.continuation,
151156
day: candidate.lease.day,
152157
source: candidate.source,
@@ -155,13 +160,13 @@ async function accountForCreationFailure(
155160
emitErrorEvent(env, {
156161
errorCategory: "workflow",
157162
errorCode: CREATION_ERROR_CODE,
158-
route: "retention-admission",
163+
route: "daily-maintenance-admission",
159164
workerName: "webhooks",
160165
});
161166
}
162167

163168
async function accountForCreationResult(
164-
env: RetentionMaintenanceEnv,
169+
env: DailyMaintenanceEnv,
165170
candidate: ReconciliationCandidate,
166171
result: DeterministicWorkflowResult,
167172
summary: { created: number; deferred: number; restarted: number; reused: number },
@@ -176,17 +181,17 @@ async function accountForCreationResult(
176181
}
177182

178183
async function tryDeferLease(
179-
env: RetentionMaintenanceEnv,
180-
lease: RetentionJobLease,
184+
env: DailyMaintenanceEnv,
185+
lease: DailyMaintenanceJobLease,
181186
errorCode: string,
182187
): Promise<boolean> {
183188
try {
184189
const deferred = await withDatabase(env, (db) =>
185-
deferRetentionJob(db, { ...lease, errorCode }),
190+
deferDailyMaintenanceJob(db, { ...lease, errorCode }),
186191
);
187192
return deferred !== null;
188193
} catch (error) {
189-
createLogger().error("retention_reconciliation_defer_failed", {
194+
createLogger().error("daily_maintenance_reconciliation_defer_failed", {
190195
continuation: lease.continuation,
191196
day: lease.day,
192197
errorCode,
@@ -197,7 +202,7 @@ async function tryDeferLease(
197202
}
198203

199204
async function withDatabase<T>(
200-
env: RetentionMaintenanceEnv,
205+
env: DailyMaintenanceEnv,
201206
operation: (db: Database) => Promise<T>,
202207
): Promise<T> {
203208
const { db, close } = createDb(env.HYPERDRIVE, {

0 commit comments

Comments
 (0)