mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
d07c668495
A run started with `experimental_retention: 0` carries the reserved `$retention: '0'` attribute, and a World that implements retention is expected to delete that run's user payloads once it reaches a terminal state. world-postgres was ignoring the attribute entirely, so the option was silently a no-op here: the SDK documents the Postgres World as implementing retention, and it did not. The purge is one transaction issued after the terminal event row commits and before the terminal NOTIFY. - After the event row, because the terminal event is itself payload-bearing — `run_completed` carries the run's output — so anything earlier leaves that one payload behind. - Before the NOTIFY, so a waiter woken by it re-reads an already-expired run rather than catching the output on its way out. One transaction is also what makes the ordering rule the Vercel World has to hand-sequence (data must become unreadable no later than it becomes unrecoverable) a non-issue here: `expired_at` and the last cleared byte commit together, so no reader can observe a half-purged run. It cannot, however, be folded into the terminal run UPDATE itself, because that UPDATE commits before the event insert — so a crash in between leaves a terminal run holding data with no `expired_at`, exactly the window the Vercel World has, and nothing retries it. Both halves of every payload column are cleared. Each CBOR column has a legacy JSONB twin beside it and the read paths fall back to the twin (`value.output ||= value.outputJson`), so clearing only `*_cbor` would leave the payload in place and resurrect it on the next read. Payload columns are set through raw SQL `NULL` rather than a JS `null`: the CBOR codec's `toDriver` would otherwise encode `null` into a one-byte CBOR value and store that. `expired_at` is stamped because it is what the CLI and web UI gate their `<data expired>` rendering on. Without it the deletion is silent, and a purged run reads back as one that never had any input. Rows are kept — a purged run stays listable and traceable, matching the Vercel World's contract — and so is plaintext metadata (status, name, attributes, errorCode, executionContext, hook tokens). `executionContext` in particular is excluded deliberately: the reference implementation purges input/output/error and nothing else, and widening that here would delete something the contract does not ask us to. Stream chunk rows are blanked rather than deleted. The reader closes on the `eof` row and `streams.list()` enumerates from these rows, so deleting them would turn a finished stream into one that never terminates and a run's stream list into an empty one. An empty `bytea` carries no user data and keeps both behaviors. Every value other than the literal `'0'` keeps the data, including a well-formed non-zero duration. That is the load-bearing half: the unit `$retention` is measured in is deliberately undecided, so an SDK that starts sending a unit-bearing value to a World that predates the decision must get the safe answer. The failure mode worth engineering against is not a purge that does not fire, it is a purge that fires on a value nobody meant as "delete my data". Purge failures are caught and logged rather than thrown: a run must still be able to finish. The cost of a failure is a run that keeps data it asked to have deleted, which is loud in the log and safe on disk. No migration: `expired_at` has existed since 0002_add_expired_at.sql and nothing wrote it until now.