When an export stops being a download

An export becomes a job at the point where it can fail halfway. Where that line sits, what a caller gets on each side of it, and why nothing is retried.

A glass export path splits, ending in an orange capsule beyond the direct chute

An export is a download right up to the point where it can fail halfway. That is where the boundary sits, and file size does not draw it: a request that streams rows as it selects them dies with the connection, while a job that writes a file somewhere else acquires a lifecycle somebody has to read.

The line is drawn where a request can fail halfway

Two entry points answer the same question here, and they answer it differently because one of them can hold the answer inside the response and the other cannot.

The direct download counts the matching rows, then streams the CSV into the response as it selects it. Nothing is stored, no job row is written, and if the connection drops the work is simply gone. There is no half-finished artifact for anybody to find later, and backpressure comes for free, so a slow client cannot make the server hold a whole file in memory while it waits.

The background mode inserts a job, hands back an identifier, and generates the file straight into object storage without ever holding it whole. The caller polls. What comes back is a state, and once that state is done, a signed url.

The caller picks which of the two it asks for. What the row count decides is narrower: whether the direct download is allowed to proceed, and above 50,000 rows it is refused instead of served slowly. The background path carries no row ceiling at all; what bounds it is a cap on the size of the artifact.

What the caller asks forWhat comes backWhat outlives the request
A direct download under the row ceilingthe CSV itself, streamed as it is selectednothing: no job row, no stored file
A direct download over the ceiling413 carrying the row total and where to go insteadnothing, and not one row was generated
A background joban identifier and a pending statea job row, then an artifact with an expiry
A background job while three are in flight429 saying to retry once one finishesnothing

Count the rows before generating one of them

The interesting decision is not where the ceiling sits. It is when the ceiling is checked.

An export endpoint has an obvious lazy design: start generating, and give up when the thing gets too big or the request runs too long. That design pays for most of the work before it fails, and it hands back something nobody can act on. A dead connection and a truncated file both refuse to say how large the job actually was, so the caller’s only move is to try again with a narrower guess.

Both entry points here count first. The count is its own query, it runs before a single row is selected for output, and its result goes into the refusal: the caller is told how many rows the export has, what the ceiling for a direct download is, and to run it as a background job instead. A refusal carrying the measurement is one somebody can plan the next call from.

The half worth copying is about the door rather than the number. That count runs behind the same permission check the generator runs behind, and it has to, because the count is a reading of the project’s contents. A design that counted first and authorized second would answer a stranger with a precise row total, which is a yes-or-no on whether the project exists and a rough sizing of what is in it. Counting behind the role check makes a stranger’s over-cap request indistinguishable from a request for a project nobody ever created.

That generalizes past exports. Any endpoint whose error message carries a measurement has to take the measurement behind the same door as the work it is refusing to do, because a 413 quoting a number and a 429 quoting a remaining allowance are both readings of private state that happen to leave as error codes. The other half of the same discipline decides who may press export at all, and the second attack surface in a ticket export covers that from the reader’s end of the file.

Five states, and what each one tells the caller

A job has five states. They are worth reading from the caller’s side rather than the queue’s, because four of them are answers to a poll and the fifth is what a working link turns into.

StateWhat a poll returnsWhat the caller does with it
pendingthe job exists, nothing has claimed it yetpoll again
runninga generator is writing the artifactpoll again
donerow count, byte size, and a signed url with its expirydownload before the expiry
failedan error string, truncated to a bounded lengthread it, narrow the request, ask again
expiredno url; the artifact has been deletedrequest the export again

The fifth state is the one that would be easy to leave out, and leaving it out costs more than it saves. An expired export could have been a row that simply disappeared once its file was purged. Keeping the row, with the file gone and the path cleared, is what lets somebody tell an export that was made and has since been cleaned up from one that never happened. For a file of personal data that distinction is the whole audit trail, and it is the same reasoning the rest of the security model runs on.

Two smaller decisions live in that table. A failed job keeps its error rather than a bare status, because a caller reading failed with nothing attached has to guess between a bad filter and a broken service. And a job that finishes successfully clears any error written while it was running, since a done row carrying an error message is a state no client can interpret.

Why a failed export waits for a person

Retrying is the right default for a job whose entire product is one idempotent call to somebody else’s API. The attempt costs a request, the failure is usually transient, and nobody has to be told.

An export is a different shape, and the divergence is on the record as a deliberate one rather than an oversight: a running export orphaned by a crash is marked failed after ten minutes rather than reclaimed and retried, because at its cost profile a silent auto-retry is worse than an honest failure the requester can see and act on.

The mechanical half of that is sharper than the economic half. The artifact is streamed to storage out of a generator, and a generator is single-shot: once its chunks have been consumed they are gone. The storage write is therefore configured with retries: 0, because replaying an exhausted generator does not reproduce the file, it publishes a shorter one. A truncated CSV is the most expensive failure in this whole design, because the file opens, the columns are right, and nothing in it records the rows that never made it.

So the failure is left where the person who asked for it can see it. The row keeps its error, the poll returns it, and the next attempt is a decision made with the message in hand. The cost of that choice is worth stating rather than hiding: a background export interrupted by a restart does not resume, and whoever requested it has to request it again.

The limits a caller plans around

Every constant in this design is only interesting as something the caller receives.

LimitWhat the caller seesWhat to do about it
50,000 rows in one direct download413 with the real row totalrun it as a background job, which has no row ceiling
3 pending or running jobs per project429 saying to retry once one finishesserialize the requests, or wait for a slot
64 MiB per artifactthe job ends failed, naming the storage capnarrow the filters, split the date range
24 hours of artifact lifethe url stops working, the job reads expireddownload inside the window, or ask again
10 minutes for an orphaned running jobthe job flips to failed on its ownask again; nothing resumes by itself

The 24 hours is the row worth arguing with, because it is shorter than an ordinary attachment gets here and shorter on purpose. An export is bulk personal data in one file behind a link that carries no session, which is exactly what makes the link usable by an agent holding no cookie and exactly what makes a long life expensive. A short window bounds what a leaked url is worth, and the trade lands on whoever downloads it a day late. The clock belongs to the artifact rather than to the link: a signed url gets its own 24 hours from the moment the poll mints it, so a late one outlives the file on paper and stops working because the collector has deleted the object under it.

None of this is two sets of rules. The panel route and the agent tool go through one implementation of the boundary, which is a shipping rule here rather than a good intention: every capability the panel has is also a tool on the MCP server. An agent cannot receive a stream, so synchronous for an agent means generate now and return a url. It still passes the same count, meets the same ceiling, consumes the same quota and leaves a row that expires on the same clock. A boundary drawn twice is two boundaries, and they drift.

Common questions

When should an export endpoint return a file instead of a background job?

Return a file while the work still fits inside the response, and enqueue once it does not. Row count is the honest test: below the ceiling the bytes stream to the client and nothing is stored, above it the work outlives the connection and needs a state somebody can poll.

Why would a large CSV export return 413 rather than running slowly?

A count runs before the first output row is generated, so an export over the synchronous ceiling is refused immediately, with its real row total in the message and an instruction to run it as a background job. Nothing is produced, and the caller learns the size of what it asked for.

Should a failed export job retry automatically?

Not when the job produces an artifact. A retry repeats the whole query and the whole upload, and a chunk stream that has already been consumed cannot be replayed without publishing a truncated file. Failing once, visibly, costs less than a silent retry that succeeds wrongly.

How long does an export download link stay valid?

Twenty four hours from completion, which is the clock the artifact itself is deleted on. Each signed url carries its own 24 hour life counted from the moment it is minted, so a link handed out late is nominally valid for longer than the file exists, and what actually ends it is the collector removing the object underneath. After that the job reads as expired and the link answers gone rather than serving something stale. The window is short on purpose, because the payload is bulk personal data.

How many exports can run at the same time for one project?

Three jobs per project may be pending or running at once. A fourth request is refused with 429 and a message saying to retry once one finishes, which bounds the disk and generation work a single project can hold however many people or agents are asking.

Can a refused export tell an outsider that a project exists?

Not when the count runs behind the same role check as the generator. A caller without access gets the answer it would get for a project nobody ever created, so a size refusal is only ever read by somebody already allowed to read the rows being counted.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">