Short answer: Treat a browser download link as the last state in an export workflow, not as proof that the export worker finished. Persist one canonical object key, verify that exact key after the upload, and issue the expiring link only after the job reaches a separately named downloadable state. That sequence catches the two failures that look alike in a property-management system: a race between job completion and storage visibility, and a wrong key prefix assembled by another service.
This is a small state machine with three different clocks: the export job, the object store, and the browser's link expiry. The 404 is useful evidence, but it does not identify which clock was read incorrectly.
The incident lesson: “exported” did not mean “downloadable”
The bounded failure I care about is a batch of private inspection reports generated for property managers. A worker renders a large CSV or archive, starts the upload, and updates the job row to complete in the same broad operation that records rendering success. The web process sees that state and signs a browser download link. A user clicks it while the upload has not been confirmed, or the web process reconstructs exports/portfolio-17/job-884/report.csv as export/portfolio-17/884/report.csv. The browser reports 404 not found. Nothing about the symptom proves eventual consistency, and nothing about the export row proves the object key was right.
The invariant is narrower: downloadable means the database contains the exact key that was uploaded and a readiness check for that key succeeded. Store the complete key once. Do not let the worker, API, and browser each derive a filename from a display name, tenant ID, date, and their own prefix rules. A key prefix is part of the identifier, not decoration.
Short logs help.
For each transition, record the job ID, tenant, exact key, upload attempt, byte count, and readiness result. The SLO should measure the proportion of jobs labelled downloadable whose recorded key cannot be read, separately from link-expiry failures. I'm not sure one alert threshold fits every portfolio; export volume, retry policy, and large-file throughput change the baseline. The signal itself is stable because it tests the user-facing promise.
How can a browser download link prove an object storage 404 is real?
Start troubleshooting from the job record, not from a browser refresh. Take the exact URL parameters or signed-request path, decode only what your logging policy permits, and compare its bucket and key with the persisted key. Then ask four questions in order: did the worker finish the upload, did the readiness read target the same key, did the link issuer read the same job version, and was the object still inside its retention window? This order avoids spending an afternoon changing access policy when the application signed the wrong name.
For a race condition, the repair is a state transition: upload, confirm, persist the confirmed key, then expose downloadable. For a wrong object key prefix, the repair is ownership: generate the key in one place and pass it as data. A prefix listing is an operator aid, not a user-facing lookup algorithm; “newest object under this prefix” can select a neighboring export when two jobs overlap.
The same check belongs immediately before signing. It is a time-of-check, time-of-use gap, so it cannot replace retention controls or remove every possible deletion race, but it prevents the application from promising a link for an object it has never confirmed. The browser should get a short-lived capability, never a storage credential.
What should the export state machine guarantee before signing?
The useful contract is explicit and boring:
| State | Required evidence | Browser action |
|---|---|---|
rendering | Job exists and has a bounded worker lease | Show progress; issue no link |
uploading | Worker owns the attempt and the target key | Keep waiting; issue no link |
verifying | The exact key is being checked after upload | Retry within a bounded budget |
downloadable | Key is persisted and the readiness read succeeded | Create the expiring link |
retryable | The attempt did not establish the contract | Offer a new export, with an operator trace |
Here is the shape of the preventative path in Go. The storage adapter is intentionally generic: the important decision is that Put and Exists receive the same immutable key, and that the job store changes state only after both calls succeed.
package export import ( "context" "fmt" ) type ObjectStore interface { Put(ctx context.Context, bucket, key string, body []byte) error Exists(ctx context.Context, bucket, key string) (bool, error) } type JobStore interface { MarkDownloadable(ctx context.Context, jobID, bucket, key string) error MarkRetryable(ctx context.Context, jobID string, reason string) error } func Publish(ctx context.Context, objects ObjectStore, jobs JobStore, jobID, bucket, key string, data []byte) error { if key == "" { return fmt.Errorf("job %s has no object key", jobID) } if err := objects.Put(ctx, bucket, key, data); err != nil { return jobs.MarkRetryable(ctx, jobID, "upload did not complete") } present, err := objects.Exists(ctx, bucket, key) if err != nil { return jobs.MarkRetryable(ctx, jobID, "readiness check was inconclusive") } if !present { return jobs.MarkRetryable(ctx, jobID, "confirmed key is not readable") } return jobs.MarkDownloadable(ctx, jobID, bucket, key) } In production, the body would be streamed or uploaded in parts rather than held in memory; the contract remains the same. Large-file throughput is a capacity-planning input, so measure upload concurrency, part size, retry amplification, and time from upload start to downloadable. A successful renderer with a saturated upload queue is not a successful download workflow.
Which storage approach fits a private export service?
The buy-versus-build decision is really about the contract the platform team can operate. A direct object-storage integration gives the team provider-specific controls and can be the right choice when regional placement, versioning, legal hold, or replication semantics are mandatory. A self-hosted compatible service can fit a team that accepts owning disks, durability design, upgrades, and on-call response. A narrow internal storage adapter can reduce application coupling, but it does not make provider behavior identical.
| Approach | Good fit | Trade-off |
|---|---|---|
| Direct provider API | Existing cloud ownership and required native controls | More provider-specific code and operational knowledge |
| Self-hosted object storage | An established storage operations team and a clear durability budget | The team owns capacity, repair, upgrades, and incident response |
| Internal adapter over one or more backends | A platform that values one application contract | The adapter team owns semantic gaps, tests, and migration work |
The catch is that a compatibility layer is not suitable when the workload depends on a backend feature the adapter does not expose, or when the team cannot staff the resulting operational boundary. Stick with a direct integration when native replication or governance controls are the decision axis. For private, expiring property exports, an adapter is reasonable only if it preserves the exact-key, verification, retention, and observability contract above.
Reconcile the promise, then tune capacity
Run a bounded reconciliation job over known export records. Compare each saved key with the storage read, classify the result as pending, expired, deleted, or inconsistent, and move the job through its normal retry path. Do not silently replace a missing key with a prefix match; that turns an explainable 404 into a data-isolation risk.
The operational dashboard needs more than request latency: upload bytes, active multipart work, queue wait, verification latency, signed-link age, and the rate of downloadable jobs failing verification. Test the whole transition with two concurrent exports for the same property portfolio, a retry after a partial upload, a key containing spaces or Unicode, and a link requested just before retention cleanup. Those tests are cheap compared with explaining to a property manager why one tenant's report opened in another tenant's browser.
The browser is only the final observer. Make the state machine tell the truth first.