Filelayer

What background jobs does a file feature need, and did they run?

A file feature is not finished when upload, download, share and delete work. It is finished when the things those four leave behind are collected — and the cost of a file feature in production is almost entirely in that second half.

There are four jobs. Writing them is a morning. The part that goes wrong is that every one of them produces an absence as its output, so a job that has silently stopped running looks exactly like a job with nothing to do.

This guide is about both halves. It is written to be useful whether or not you use Filelayer: the schema and the SQL are yours to copy, and the script beside this page reproduces every claim against a real PostgreSQL in about thirty seconds — including a data-loss race that only exists with two backends.


The four jobs

JobCollectsWhat it costs you if it never runs
Abandoned upload reservationspending rows whose upload never completedrows that are never readable and never collected, plus their bytes
Orphaned storage objectsbytes with no row pointing at thema bucket bill that only goes up
Audit retentionan append-only log that grows foreverthe table that eventually dominates your database
Chain sealing—nothing, until you need to prove the log was not truncated

The second one has its own guide — Deleting files and orphaned objects — because the sweeper is the most dangerous job you will write and deserves the space. The third cannot be done naively on a hash-chained log, and How do I prove who accessed a file? has the checkpoint that makes it attestable rather than indistinguishable from an attack.

This guide takes the first one in full, because it is the one that is usually written as five lines and a DELETE, and then the fourth, and then the thing all four share.


Abandoned upload reservations

A direct upload is two steps with a gap: you reserve a row and hand out a presigned URL, the client PUTs the bytes, the client tells you it finished. The client is a browser on a train. It will not always tell you.

So the reservation carries an expiry:

CREATE TABLE file (
    id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    state             text NOT NULL CHECK (state IN ('pending', 'ready', 'deleted')),
    storage_key       text NOT NULL,
    upload_expires_at timestamptz
);

And the collector looks for expired pending rows. Here is the version that loses data:

-- Step 1, in the job
SELECT id, storage_key FROM file
 WHERE state = 'pending'
   AND upload_expires_at IS NOT NULL
   AND upload_expires_at < $1
 ORDER BY upload_expires_at ASC LIMIT $2;

-- Step 2, per candidate
DELETE FROM file WHERE id = $1;

Between the two statements the client's completeUpload lands. The row is now ready, the bytes are in the bucket, the user can see their file in the interface — and step 2 deletes it, because step 2 is working from what step 1 believed.

This is not a rare window. The collector runs against rows that expired just now, which is exactly the traffic of a client that was slow rather than gone.

The fix is to repeat the predicates in the DELETE and let it tell you whether it won:

DELETE FROM file
 WHERE id = $1
   AND state = 'pending'
   AND upload_expires_at IS NOT NULL
   AND upload_expires_at < $2
RETURNING storage_key;

Zero rows means the client completed and you must not touch the bytes. One row means you claimed it and nothing else can. The claim is the write; there is no window, because there is no earlier read being trusted.

Then, and only for a row you removed:

delete the row  ->  commit  ->  delete the bytes

That order, not the other one. A failure after the commit leaves an orphan, which the second job collects. A failure the other way round leaves a row pointing at bytes that are gone, which nothing collects and which a user meets as a broken download. An orphan is a recoverable failure and a dangling row is not.

One more thing, easy to leave out and worth the line: audit the collection. A file that disappeared is a question somebody will ask, and "the collector took it, at this time, because the reservation expired at that time" is a much better answer than silence.


Sealing, which is the job you only want after you needed it

If your audit log is hash-chained, replay detects an edit, a deletion from the middle and a deletion of the first event — and not a deletion of the most recent ones, because what remains is a perfectly consistent chain.

A seal is a row recording where the chain had reached at a moment in time. Schedule it, and removing recent events stops being invisible: a head that is behind a recorded seal is a chain that lost what came after it.

It raises the cost of a silent truncation without making one impossible — the seals live in the same database as the events — so it is worth having and is not a substitute for pinning the head hash somewhere outside. Both, and say which one does what.


Dry run is the default, and the record must say which one happened

Every job here deletes things on the strength of an absence. So the first argument is dryRun, and it defaults to true: a caller who has not thought about it gets a report, not a sweep.

That default has a consequence that bites months later. Record the effective mode, not the argument. A caller who passed nothing performed a dry run, and six weeks of those must not read as six weeks of sweeping:

mode text NOT NULL CHECK (mode IN ('dry', 'live'))

Text with a check, not dry_run boolean. Two reasons: a boolean cannot grow a third value and this is exactly the kind of flag that eventually needs one; and dry_run = false at a call site reads as a double negative at the moment you least want to misread it.

Step 3 of the script beside this page measures what the omission looks like: six recorded runs, a job that reports healthy by every naive query, and zero bytes ever actually swept.


The part nobody writes: a table of runs

Here is the whole problem in one sentence. The output of all four jobs is an absence, so the difference between "ran, found nothing" and "has not run since March" is not visible anywhere in your data. Logs scroll. Metrics have retention. Nobody notices a cron that stopped firing until the bucket bill does.

So record the runs:

CREATE TABLE job_run (
    id          bigserial PRIMARY KEY,
    job         text NOT NULL CHECK (job IN ('storage_orphans', 'upload_reservations',
                                             'audit_trim', 'audit_seal')),
    started_at  timestamptz NOT NULL,
    finished_at timestamptz NOT NULL DEFAULT now(),
    outcome     text NOT NULL CHECK (outcome IN ('ok', 'failed')),
    mode        text NOT NULL CHECK (mode IN ('dry', 'live')),

    -- NULL, not 0, when a run threw before deciding anything. Zero is a claim.
    scanned     bigint,
    acted       bigint,

    detail      jsonb NOT NULL DEFAULT '{}'::jsonb,
    error       text,

    -- A failure with no error, or a success carrying one, are both states
    -- somebody would have to interpret. Neither is representable.
    CONSTRAINT job_run_error_iff_failed CHECK ((outcome = 'failed') = (error IS NOT NULL))
);

Four decisions in there, each of which was an inference somebody had to make:

scanned and acted are nullable. A run that threw before deciding anything did not scan zero things; it is not known what it got through. 0 would be a claim, and an operator comparing runs would read it as a clean pass.

The = between two boolean expressions is the whole invariant: a failed run must carry an error and a successful one must not. Written as an implication it would allow outcome = 'ok', error = 'connection reset', which is a row nobody can act on.

detail carries whether the sweep finished. A pass that hit its LIMIT did not complete, and an operator reading scanned 1000, acted 3 will take it for a complete pass unless something says truncated: true.

It is not hash-chained, and that is the honest line. This is an operational record, not evidence. Chaining it would claim a property it does not have, and the audit chain is the thing that carries that claim. Make it append-only the same way — DO INSTEAD NOTHING on UPDATE and DELETE, plus a BEFORE TRUNCATE trigger, because rules do not cover TRUNCATE — but do not dress it up as proof.

Both failures have to be reported

When the job fails and recording the failure fails, say both. "The job failed" and "we could not record that the job failed" are different problems for whoever is on call, and reporting one of them loses the other. An unrecorded failure is precisely the state this table exists to make impossible, so it must not arrive as a bare internal.


Asking "is it running", and the three answers

Now the question the table exists for. There are three states and most dashboards collapse two of them:

StateWhat it means
ok, 6 weeks oldit is running, late, and probably not scheduled as you think
failed, 5 minutes oldit is running and broken. A different problem, and a worse one
never runthe cron does not exist, or has never fired, or the deploy dropped it

A job that has never run is the worst case and the easiest to hide, because the natural query hides it for free:

SELECT j.job, now() - max(r.finished_at) AS age
  FROM (VALUES ('storage_orphans'), ('upload_reservations')) AS j(job)
  LEFT JOIN job_run r ON r.job = j.job
 GROUP BY j.job
HAVING now() - max(r.finished_at) > interval '1 day';   -- the bug

A job with no runs has max(finished_at) IS NULL, NULL > interval is NULL, HAVING NULL is not true, and the row that should have paged you is the one row that does not appear. Step 4 of the script measures it: the same query over the same three jobs returns the stale one and silently drops the one that has never run at all.

Say it explicitly:

HAVING max(r.finished_at) IS NULL
    OR now() - max(r.finished_at) > interval '1 day'

Do not invent the cadence

The interval is a policy decision about your bucket, your bill and your tolerance, and a default here is a number somebody made up appearing in somebody else's alerting. Take the threshold per job from the caller, and when no threshold was given, answer null for "is it stale" rather than guessing.

But when a threshold is given, a job that has never run is stale — immediately. Returning null there would hide the worst case behind the absence of data, which is the defect this whole section is about.


What this does not give you


If you would rather not build it

Filelayer is this shape, packaged: the three collectors and the sealer each record a job_run row whether they succeed or fail, with the effective mode rather than the argument, and jobStatus() answers the three-way question above — taking the threshold from you and inventing no cadence.

// Nothing has run yet. `lastRun: null` is NOT "nothing to report".
const fresh = await fl.jobStatus({ staleAfter: { upload_reservations: 3600 } });
const before = fresh.find((j) => j.job === 'upload_reservations')!;
assert.equal(before.lastRun, null);
assert.equal(before.ageSeconds, null);
assert.equal(before.stale, true);   // never run, and you said it should have

await fl.collectUploadReservations({ dryRun: true });

const after = (await fl.jobStatus({ staleAfter: { upload_reservations: 3600 } }))
  .find((j) => j.job === 'upload_reservations')!;
assert.equal(after.lastRun!.outcome, 'ok');
// The EFFECTIVE mode, so six weeks of dry runs cannot read as six weeks
// of sweeping.
assert.equal(after.lastRun!.mode, 'dry');
assert.equal(after.stale, false);

That block is executed by npm run check:docs on every commit.

It is Apache-2.0, it runs on your own Postgres and your own bucket, and it is alpha — the trust page has the numbers, including the ones sitting at zero.



Sources