feat: KEEP-1042 age out workflow execution data on a schedule - #2344
feat: KEEP-1042 age out workflow execution data on a schedule#2344OleksandrUA wants to merge 11 commits into
Conversation
joelorzet
left a comment
There was a problem hiding this comment.
Questions on what this changes for users.
Two tables, because everything below comes from them:
workflow_executionsis one row per run. This PR keeps it 400 days.workflow_execution_logsis one row per step inside a run. This PR erases it at the plan window: 7 days free, 30 pro, 90 business, 365 enterprise.
1. A free org will see two different numbers on the same screen.
The "Gas spent" number at the top of analytics reads workflow_executions. The "Gas by network" chart under it reads workflow_execution_logs.
The page still offers a 30d button and a custom range with no limit. A free org clicks 30d: the number on top covers 30 days, the chart under it covers 7, and nothing says why they disagree.
There are no UI changes in this PR, 15 files and none in components/. Is a UI change coming with it, or do we ship the cron and leave the page like that?
2. Runs will open to an empty step list.
A free org opens a run from 20 days ago. It is still in the list with its status and duration, because that row lives 400 days. Click into it and there are no steps, because they were erased at 7 days. It looks the same as a run that never had any.
3. The gas filter will put runs in the wrong bucket, not hide them.
The filter decides "free" by asking whether the run has a step that burned gas and whether it has a gas credit row. If neither, it is free.
Erase the steps and an old run that really did pay gas from the wallet answers no to both. It moves out of wallet and shows up under free. Not missing, wrong.
4. The monthly digest will report about a quarter of the sponsored transactions.
The digest counts sponsored transactions with output_raw->>'sponsored' = 'true' on the step logs. stripExpiredOutputRaw sets output_raw to NULL after 7 days, for every org on every plan, and NULL does not match.
The monthly digest covers the previous full calendar month. Only its last 7 days will still have the marker. The email will still label the number as the month's total.
5. output_raw ignores the plan the customer pays for.
Every other part of this job uses the org's plan window. This one is a flat 7 days for everybody. Business pays for 90 days and loses the field at 7. Enterprise pays for 365 and loses it at 7. It is returned by GET /api/workflows/executions/{id}/logs and by get_execution_logs over MCP, so a customer can watch it disappear.
Why is this one flat?
6. We need to check every current enterprise org before this runs.
We fill in plan and plan_overrides by hand when an enterprise org is created. Until now a wrong or missing value cost nothing, because no code read it. This PR makes it decide what gets erased.
If the subscription row is missing, resolveRetentionDays falls back to defaultLogRetentionDays, which is 7, and that org's logs go at 7 days like a free org.
So before dry-run comes off we need to go through every enterprise org and confirm its row carries the right retention value, and then confirm the cron resolves that org to the window we expect and not to 7.
The job only reports a row count today, so it cannot be used for that check. Can it report the window and the org count per group, so the manual rows can be verified against what it actually resolved?
7. I do not think this erases much on prod.
The two per-org steps only select a 7 day slice, from retentionDays + lookbackDays ago up to retentionDays ago. For a free org that is rows aged 7 to 14 days. Anything aged 15 to 400 days is not selected by anything.
The two steps that scan from the oldest row cut at 400 days, and the migration comment in this PR says the oldest row on prod is from 2026-01-06, about 244 days ago. So they match nothing.
If the goal is the 62 GB, this does not reach it. Is that intended, and is a catch-up run planned separately?
8. In about five months, invoice history starts changing.
Once prod rows cross 400 days, run rows start being erased. The invoices page does not read a stored figure, it recounts workflow_executions on every load, so a past invoice will show a smaller number than we billed.
overage_billing_records stores the real figures, but overage.ts only writes a row when there was overage, so every month an org stayed inside its plan has no record. For those months the rows are the only proof. Once they go we cannot defend a charge a customer disputes, and we cannot catch one we got wrong in our own favor.
That means new storage, not a tweak here. We need a table that records, per org per billing period, the plan and tier at the time, the execution limit, the billable workflow executions and the direct executions, and the charge. overage_billing_records already has most of those columns but only gets a row when there was overage, so it is a starting shape and not the answer. Then the invoices page reads that record for closed periods and only recounts the open one.
This needs to be a tracked follow-up that lands before dry-run comes off, not after. Once the first run rows are erased the numbers are gone and we cannot rebuild the record from anything.
9. EXECUTION_RETENTION_DAYS has no clamp.
Everything in point 8 is 400 days away only while that variable stays at 400. It is read with no floor. minLogRetentionDays gets a Math.max in getRetentionConfig and this one does not, and it sits in the same values.yaml block as the log windows. A low value there moves live quotas and every past invoice in one hourly run. Can it get a clamp?
And who turns dry-run off, and when?
Posting this as discussion rather than a blocking review.
Nothing deleted a workflow execution row on an age basis. workflow_execution_logs reached 62 GB / 23.1M rows on prod and caused two failures in two days: the RDS volume alarm on 2026-09-01, and 87 minutes of 99.7% database CPU on 2026-09-02 when the analytics queries de-TOASTed jsonb out of it row by row and starved the dispatchers. The product has sold retention since lib/billing/plans.ts was written - logRetentionDays is 7 free, 30 pro, 90 business, 365 enterprise, and the upsell modal advertises it - but the value had no server-side reader at all. This makes it real. A new hourly CronJob calls GET /api/internal/retention, which runs five passes: - step logs past a 400-day floor, for every org, with no join - step logs past the window their org's plan sells - output_raw nulled once a run can no longer resume, which halves the payload of an aged row without deleting it - step logs a user purged from the UI, hard-deleted after a grace period - run rows past a flat 400-day window The plan window drives the step logs only. workflow_executions gets one flat, long window instead, because every billing count reads that table by started_at with no floor and the invoices page pages back over every past invoice, so a 7-day plan window on run rows would rewrite what a customer was billed. The per-org passes work on a bounded slice rather than scanning from the oldest row: the windows differ by three orders of magnitude, so a scan from the start would walk millions of enterprise rows to reach a handful of free-tier ones. A long outage is covered by the floor pass, which has no join and no lookback. Every window is an env var, the job is off until EXECUTION_RETENTION_ENABLED is set, and EXECUTION_RETENTION_DRY_RUN reports what a pass would touch without deleting. Staging ships enabled-but-dry; prod ships off. Rows purged, run outcome and last-success are counters and a gauge on the API registry; the oldest-row age and both table sizes come from the db-metrics collector. The last-success gauge is the health signal, not the oldest-row age: enterprise keeps a year of logs and therefore pins min(started_at), which would not move if only the short-window passes broke. Migration 0151 adds the two index lookups the job needs. Both are behind @requires-db-prep: they must be built CONCURRENTLY out-of-band on staging and prod before merge.
… readers Addresses the review on #2344. The job barely deleted anything. Both per-organization passes bounded their scan on BOTH sides -- [now - (window + lookback), now - window) -- so a run only ever saw rows that had crossed their boundary in the last week. On prod that left 1.96M step-log rows and 19M output_raw payloads that nothing would ever reach: the passes deleted about 180 MB where the description claimed ~16 GB. The floor pass matched nothing at all, because it ran at a fixed 400 days and the oldest row is 244 days old. My own live check missed this because it ran with a 999-day lookback. The lookback is gone. The two passes that need a lower bound now get it from real progress instead: - the plan-window pass keeps a per-organization watermark (execution_retention_progress) and advances it only when that organization's range fully drains, so an interrupted run resumes rather than skipping; - the output_raw pass has no lower bound at all and leans on a partial index on (started_at) WHERE output_raw IS NOT NULL, which holds the backlog while it drains and then only the rows inside the window. The floor pass now runs at the longest window actually in use rather than a constant, so the organizations holding most of the table are served by a plain index range with no join, and the per-organization pass only ever walks the short windows. Run-row deletion moves behind EXECUTION_RETENTION_EXECUTIONS_ENABLED, default off. The invoices page recounts workflow_executions per period on every load, no durable usage record survives a period without overage, free plans never get one at all, and Stripe holds no recoverable figure -- so deleting a run row rewrites what a customer was billed. EXECUTION_RETENTION_DAYS also gets a hard 400-day floor: it can lengthen the window, never shorten it. Every other window is clamped up to EXECUTION_RETENTION_MIN_DAYS. Three readers would have gone wrong once rows started disappearing, all of them pre-existing bugs that retention turns into wrong numbers: - the analytics gas filter derived its buckets from a step-log rollup, so a wallet-paid run whose logs had aged out satisfied `free` -- refiled, not hidden. It now reads workflow_executions.gas_used_wei and gas_credit_usage, neither of which is purged. Measured over 30 days of prod: identical buckets for all 1,938,753 runs; - the digest counted sponsored transactions off output_raw, which is nulled at 7 days, so a monthly digest would have reported about a quarter of them under a full-month heading. `sponsored` is not a redacted key, so it now counts off `output`, which is never stripped; - the runs table rendered blank Gas and Network cells and "No step logs available" for a run whose steps retention removed, indistinguishable from a run that never had any. The runs response now carries the organization's step-log cutoff and the table says which it is. The analytics range gains a floor as well. customStart came straight off the query string with no lower bound, so ?customStart=2020-01-01 scanned the whole table and bypassed the cache -- the cheapest way to reproduce the 2026-09-02 saturation. It also fixes range=custom with no customStart, which produced an Invalid Date that every downstream comparison answered false to. Migration renumbered to 0152 (KEEP-1328 took 0151 on staging) and 0153 adds the progress table. The new partial index needs a CONCURRENTLY build out-of-band before merge, like the other two.
a586e4d to
35311c4
Compare
|
Joel, thank you for this. Point 7 is right and it is the one that matters, so I start there. 7. Correct, and worse than you wrote. My numbers were wrongYou found the actual defect. Both per-organization passes bounded the slice on both sides, I MEasured it on prod rather than arguing. 0.5% sample, scaled:
And the same bound applied to
So this PR freed about 1.5 GB of 62 GB, not the ~16 GB I claimed in description and in Linear. That claim was simply wrong and I corrected it in the ticket. My own live check did not catch it because I ran it with a 999-day lookback, which is exactly the crutch that hid the bug. The lookback is gone now. The two passes that need a lower bound take it from real progress :
I also moved the floor pass to run at the longest window actually in use, not at a constant. That way organizations holding most of the table are served by plain index range with no join, and the per-organization pass only ever walks the short windows- which on prod is 2.23M executions rather than millions. No separate catch-up job. The hourly run drains it on its own. I re-ran the live check on a real Postgres, this time with rows seeded at 2, 9, 20, 45, 120, 300 and 500 days and no lookback anywhere: all 20 assertions pass, including that second run does exactly nothing. 9. Correct. Clamped
On "who turns dry-run off, and when" :I do, and only after the dry run reports what point 6 asks for. It is a values.yaml change, so it is a PR, not a console click. 8. Correct on every assertion. I gated the pass offI checked all of it. The invoices page recounts on every load with no stored figure, ONe thing which makes it a bit worse than you wrote :the job never touches Rather than race the follow-up, I put run-row deletion behind its own switch, 6. Correct, and the exposure is larger than enterpriseI checked prod :975 of 1409 organizations have no In fairness that fallback is not arbitrary- All four enterprise orgs ( The job now reports 3. Correct, and KEEP-1334 did not fix itI checked, because I hoped it had. KEEP-1334 moved the filter from FIxed properly now. The buckets read 4. Correct, and the arithmetic is yoursMonthly cadence exists, window is previous full calendar month, and with the strip running hourly about 6.4 of 28-31 days keep their marker. So roughly a quarter, as you said, printed under a It needed one word. Two things I noticed there which are not mine to fix in this PR, but somebody should know :the digest is 1. Mechanism correct, the example is dead codeThe two queries really do read different tables, you are right about that. But The problem is real on the screen which IS rendered, though, and I would say this makes your point stronger rather than weaker :the runs table's per-run Gas and Network cells come from the step-log subquery, and so do the network and gas filter dropdown counts. A purged run keeps its KPI contribution and loses its row-level cell. 2. CorrectFixed together with 1. The runs response now carries the organization's step-log cutoff, so a run older than it renders an em dash with reason on hover instead of blank cell, and the expanded view says the steps are past the retention window instead of I also gave the analytics range a floor while I was in there. 5. Fair question. I would like to hold the flat window, here is why
Both columns are written in the same UPDATE from the same payload, and So what goes at 7 days is an unredacted copy of secrets which I would say we should perhaps not keep for a year anyway. And if I align it to the plan window instead, enterprise holds 83% of the payload at 365 days, so it gives back ~14 GB and we are back to freeing almost nothing. Where I think you are plainly right is that a stripped row is byte-identical to one which never had a payload. If you want, I would suggest to add a marker so the API can say "stripped by retention" rather than returning bare Also, unrelated to your review :KEEP-1328 took migration Full pipeline is green again- |
The PR environment renders its own values file against the plain `common`
chart and inherits nothing from deploy/keeperhub-stack/staging/values.yaml,
so it shipped neither the retention CronJob nor any EXECUTION_RETENTION_*
variable. The route was in the image and answered {"enabled": false}.
Nothing else exercises the SQL either. The unit test mocks the whole drizzle
builder and says so in its own header; the route test mocks the purge. So
every pass, every index lookup and the child-before-parent delete ordering
reach staging without ever having run against Postgres.
A PR environment is the right place to close that: it is disposable, it gets
a fresh CloudNativePG database with the migrations applied, and its seed data
is hours old, well inside every window. So it runs enabled and not dry -- if
a pass deletes the wrong row here, it says so before staging sees it. Run
rows stay behind their own switch, as in both real environments.
Every 10 minutes rather than hourly, so the job actually fires inside the
life of a PR environment.
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
…n-retention-plan-windows-and-output-raw-strip # Conflicts: # .env.example
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
|
👋 Hi @OleksandrUA, reviewed this again and most of what I raised is addressed. The watermark replacing the lookback, the hard clamp on Two things left. 1. Plan downgrade deletes data within the hour, and it is not recoverable. The deletion is a plain
So is the intent a straight wipe at the plan window, or should there be an internal retention policy behind it? A grace period on downgrade, an archive, or a soft delete before the hard delete would each change what this pass does. If it is a straight wipe, the plan copy should say so, because "30-day log retention" reads as a ceiling today rather than as "these are destroyed the hour your plan ends." 2. The two run row columns everything now leans on have unmeasured gaps. Moving the gas buckets and the network dimension off the step logs is the right fix, and it makes
Both become permanent the moment the step logs go, because the backfill derives from the rows being deleted. One count before dry run comes off would settle it: SELECT count(*) FROM workflow_executions
WHERE gas_used_wei IS NULL
AND started_at < now() - interval '30 days'
AND id IN (SELECT execution_id FROM workflow_execution_logs WHERE gas_used_wei > 0); |
…ashes Two things from the second review. The purge moves from hourly to daily. A plan downgrade drops the org to the free window, so the run after it deletes everything between the old window and the new one -- for a lapsed Pro subscription that is 23 days of step logs, within the hour. The deletion is intended and the pricing page states the window, so this is not a behaviour change; a daily schedule simply gives someone who cancels or lets a trial lapse a full day to resubscribe before it happens. The stalled alert moves from 3 hours to 30 with it, otherwise a daily job pages every single day. The backfill closes the second half. Moving the network dimension onto transaction_hashes made that column load-bearing, and migration 0071 added it with a [] default and no backfill, on the assumption history was reconstructable from output_raw. Measured on prod: 4,710 runs carry gas with an empty array, and of the 11,186 gas-bearing step logs behind them, output holds the hash on all of them and output_raw on about a fifth -- so the column this job nulls at seven days is the wrong source and always was. transactionHash is not a redacted key, so the surviving twin carries the same value. Two findings worth recording. The gap is not historical: the newest affected run is from today, because the array is only written at a successful finalize, so a run that spends gas and then fails never gets one. And the gas column Joel raised alongside it is clean - zero gas-bearing runs older than 30 days have a null gas_used_wei. The script mirrors loadHashesFromLogs so a backfilled array and a written one cannot drift, is anchored on idx_exec_logs_gas_started_at so the candidate scan never de-TOASTs the whole log table, and is idempotent - it only touches rows whose array is still empty.
PR Environment DeployedYour PR environment has been deployed! Environment Details:
Components:
The environment will be automatically cleaned up when this PR is closed or merged. |
What and why
Nothing in the database or the app deleted a workflow execution row on an age basis.
workflow_execution_logsreached 62 GB / 23.1M rows on prod and caused two failures in twodays: the RDS volume alarm on 2026-09-01, and 87 minutes of 99.7% database CPU on
2026-09-02, when the analytics queries de-TOASTed jsonb out of it row by row, starved the
dispatchers, and stopped workflows from starting.
The product has sold retention since
lib/billing/plans.tswas written -logRetentionDaysis 7 free, 30 pro, 90 business, 365 enterprise, and the trial upsell modal advertises
"30-day log retention" - but that value had no server-side reader anywhere. This PR makes it
real.
What it does
An hourly CronJob calls
GET /api/internal/retention, authorized by the same internal-serviceHMAC every other scheduled route uses. Five passes, child rows before parent rows, because
every foreign key into
workflow_executionsisON DELETE NO ACTIONand nothing cascades:logs_floorlogs_plan_windowoutput_rawoutput_rawnulled once a run can no longer resumelogs_soft_deletedexecutions_flat_windowoutput_rawis where the space comes from. It is the unredacted twin ofoutputand costsabout the same on disk, so nulling it halves the payload of every aged row without deleting
anything a user reads. The redacted
outputthe UI shows stays for the full plan window.How each pass finds its lower bound
None of them uses a fixed lookback. An earlier revision did, and it meant a run only ever saw
rows that had crossed their boundary in the last few days: on prod that left 1.96M step-log
rows and 19M
output_rawpayloads that nothing would ever reach. The two passes that need alower bound take it from real progress instead - a per-organization watermark for the
plan-window pass, a self-pruning partial index for the
output_rawpass - so the backlogdrains on its own and a drained table costs nothing to re-check.
The watermark stops at the oldest run the pass had to skip, not at the cutoff. The drain
query excludes runs that can still resume, so an empty page does not mean an empty range, and
advancing past those rows put them below an inclusive lower bound for good. The backstop pass
then records its own cutoff for every organization, which bounds a permanently stuck run and
gives the organizations it serves alone a watermark they would otherwise never have.
The one place the plan window does not apply
logRetentionDaysis a log promise, so it drives the step logs only.workflow_executionsgets one flat, long window instead, behind
EXECUTION_RETENTION_EXECUTIONS_ENABLED, whichships off everywhere.
Every billing count reads that table by
started_at, filtersbillable = true, joinsworkflowsfor the org, and never filtersdeleted_at.countMonthlyExecutionscounts fromthe start of the UTC month, and the invoices page recounts every past period on every load
with no floor. No durable record survives a period without overage, free plans never get one,
and the provider holds no recoverable figure. So deleting a run row rewrites what a customer
was billed. That pass stays off until a per-period usage record exists (KEEP-1338). The first
prod row crosses 400 days on 2027-02-10.
Readers that would have gone wrong
Three pre-existing bugs that retention turns into wrong numbers, plus one the cutoff itself
introduced:
freeonce its steps were gone. Itreads
workflow_executions.gas_used_weiandgas_credit_usagenow, both of which the jobnever touches.
transaction_hasheson the run row as well, which is written at finalize and never purged.output_rawand would have reportedabout a quarter of them. It counts off
output, which carries the same value - verified as0 rows of 1,717,501 on staging where
output_rawis set andoutputis not.the job was doing. The cutoff comes from the purge watermark now, is null while the job is
disabled or dry, and only ever explains a cell that is already empty.
The analytics range also gets a floor and both ends get validated:
customStartwasunbounded and
customEndreturned "Invalid time value" for anything unparseable.Configuration
Eleven env vars, all documented in
.env.example. The job is off untilEXECUTION_RETENTION_ENABLEDistrue, andEXECUTION_RETENTION_DRY_RUNreports what eachpass would touch while deleting nothing. An empty, unparseable or non-positive value falls
back to the built-in default rather than to 0, because a 0 window would mean "delete
everything".
EXECUTION_RETENTION_DAYShas a hard 400-day floor and can only lengthen.Staging ships enabled-but-dry. Prod ships off.
Metrics
keeperhub_execution_retention_rows_purged_total{pass},keeperhub_execution_retention_runs_total{result},keeperhub_execution_retention_last_success_timestamp_secondsand the per-windowkeeperhub_execution_retention_window_organizations{retention_days}on the API registry;keeperhub_execution_log_oldest_age_secondsandkeeperhub_execution_table_bytes{table}from the db-metrics collector.
The last-success gauge is the health signal, not the oldest-row age: enterprise organizations
keep a year of logs and therefore pin
min(started_at), so that gauge would not move at allif only the short-window passes broke. Alert rules are in techops-services/infrastructure.
Migrations 0153 and 0154 - 0153 needs db-prep
Three index lookups the job depends on, behind
-- @requires-db-prep, plus the watermarktable:
idx_workflow_executions_started_at- no existing index leads with an unfilteredstarted_at, so the flat pass would sequentially scan on every run.idx_exec_logs_deleted_at, partial ondeleted_at IS NOT NULL. Holds only the rows waitingout their grace period and empties itself as they go.
idx_exec_logs_output_raw_pending, partial onoutput_raw IS NOT NULL. This is what makesthe unbounded
output_rawscan viable; it self-prunes as the backlog drains.All three are built
CONCURRENTLYon staging and verified valid; the migration body isIF NOT EXISTSso the deploy no-ops. The exact statements are in the migration header.Testing
tests/db/execution-retention-purge.db.test.tsruns the real purge against a real Postgresin a new ungated
test-dbCI job. Six organizations across every window, the config and theclock injected, assertions against rows rather than against the report. It covers the cases
that only a two-run test can see: a run that is non-terminal when the pass sweeps past it
and finalizes afterwards, a dry run over more rows than one batch, a batch smaller than the
candidate set, a budget cut mid-organization, and the
NOT INNULL trap that would turn therun-row pass into a silent no-op. About two seconds.
tests/unit/execution-retention-windows.test.ts- config parsing, per-org windowresolution, and the schedule grouping.
tests/unit/execution-retention-purge.test.ts- pass order, dry run writing nothing, theruntime budget, and the watermark clamp.
tests/unit/analytics-time-range-bounds.test.ts- both ends of a custom range.tests/integration/retention-route.test.ts- route contract and the metric counters.pr-2344, which now runs the CronJob for real: thejob has run unattended every 10 minutes for over 16 hours with no failures.
pnpm check,pnpm type-check,pnpm build,pnpm test:unit,pnpm test:integration,pnpm test:db, andpnpm db:migrateplus the drizzle drift checkon a fresh database.