Skip to content
Crixaa
← All posts

Derived work must not fail the real work

The Crixaa team4 min read

Committing a new version of a template used to do two things: write the version, and produce the preview image you see on the template list. Rendering a PDF, rasterising the first page and uploading it all happened inside the same transaction, on the request thread, while the author waited.

What the commit path was doing

The shape was roughly this, and it reads fine until you say it out loud:

await db.transaction(async (tx) => {
  const version = await tx.insertVersion(templateId, layout)
  const pdf = await renderPdf(layout)        // CPU, seconds
  const png = await rasterise(pdf)           // CPU, megabytes
  await uploadPreview(templateId, png)       // network, someone else's uptime
  return version
})

Three things are wrong here, and only one is about speed.

The transaction is held open across two CPU-heavy steps and a call to object storage, so a connection and a set of row locks stay pinned for as long as the slowest of them takes. The author waits for all of it before the save button comes back. And — the one that matters — a throw anywhere in those three lines rolls back the insert. Object storage being briefly unreachable becomes a failed save.

The reverse is possible too: the upload succeeds, something after it fails, the transaction rolls back, and there is now a preview image for a version that does not exist.

Derived work and real work

The preview is derived. It is a pure function of the layout, reproducible at any time, and if it is missing the cost is a blank tile until something regenerates it.

The version is the author's actual work. Somebody moved a GSTIN block, fixed the place value in an invoice total, adjusted a clause. That is not reconstructible from anything we hold. If it is lost, it is lost.

So the original code traded away the thing it could not rebuild to avoid a moment without the thing it could rebuild in a second. Nobody would choose that written down; it happened because the two operations were adjacent in the code, and adjacency is easy to mistake for a real dependency.

The rule that falls out is short. An artefact you can rebuild must never be in a position to destroy an artefact you cannot.

After the commit, not inside it

The fix is to run preview generation after the commit returns. The transaction then contains only its writes, and the request no longer waits on a rasteriser.

Ordering is the whole point. Not finally, not in parallel, not "kick it off and then commit". After. If the commit rolls back, the preview code never runs at all, so the second failure mode above disappears by construction: no path produces an image of a version that was never committed.

const version = await db.transaction((tx) => tx.insertVersion(templateId, layout))
void generatePreview(version.id)   // after, and only after
return version

The handler must re-read

There is an obvious optimisation here that is also a bug. You already have the layout in memory, so why make the preview job fetch it again?

Because the in-memory object is not evidence of anything. If the transaction rolled back, it describes a version that is not in the database, and a handler holding it will cheerfully render and upload a preview for a version nobody can open. Passing an id makes that state unreachable: the handler reads the committed row, and either finds one or finds nothing and stops.

Pass ids across an asynchronous boundary, not snapshots.

Never throws, and never unbounded

void generatePreview(id) is only safe if that function genuinely cannot throw. A floating promise that rejects is an unhandled rejection, and in current Node that terminates the process — so a preview failure would take the server down with it.

"Never throws" therefore has to be a contract you can point at in the code, not a belief about what the body does:

export async function generatePreview(versionId: string): Promise<void> {
  try {
    // read the committed version, render, rasterise, upload
  } catch (err) {
    logger.warn({ err, versionId }, 'preview generation failed')
  }
}

The try wraps everything, including the read. Nothing escapes.

Exception safety is not the only kind. Each task holds a full-page raster in memory, and images are large in a way database rows are not. One at a time is nothing; a bulk import committing a few hundred versions in a minute, each spawning an unbounded task, is a heap graph pointing at the ceiling. An out-of-memory kill is not something your try/catch sees — the process dies, and every in-flight request with it.

So the tasks go through a pool with a fixed number of slots and a queue. Previews are allowed to be late. They are not allowed to be expensive enough to matter.

The habit worth building

When a write produces something as a side effect, ask two questions. Can I rebuild this from what I stored? And is it in a position to destroy what I stored?

If the answer to both is yes, it is in the wrong place. Move it after the commit, hand it an id rather than an object, make the never-throws real rather than assumed, and bound how many can run at once. None of that is clever. It is refusing to let the disposable thing hold the durable thing hostage.

Try it on your own document

Design a template in the browser and generate a real PDF — free, no card.