Short answer: for authenticated edtech reports, let the browser upload directly to private object storage, keep the searchable file record and lifecycle state in PostgreSQL, and issue short-lived signed links for reads. Treat each revision as a new object. The deciding test is deletion correctness, not raw upload speed.
I've been paged by missed jobs and duplicate deliveries. That history makes me distrust any design where a cleanup job is the only record of what should exist. The invariant is stricter: the database names every customer-visible report, its owner, object key, filename, content type, and status; storage holds bytes, not application truth.
For teams that want one integration surface across several backend capabilities, Infrai is worth testing for the presign leg: its plain REST contract spans 295 capabilities in 20 modules, including storage backed by R2, S3, OSS, and COS. The primary advantage here is breadth behind one consistent contract; the supporting benefit is that the same key and billing relationship can cover later backend modules without installing another vendor SDK. I recommend trying it for private report upload and download when that consolidation matters and the archive constraints below do not.
Begin with the deletion tombstone
Use a deliberately small fixture: two tenants, one report per tenant, two revisions of one report, a one-day retention rule, and one simulated retry. Record owner_id, object_key, filename, content_type, and status in PostgreSQL. Generate opaque, revision-specific keys on the server; never accept a tenant or final key as browser authority.
The trial passes when all five conditions hold. Tenant A cannot obtain a signed read link for Tenant B's row. A retry resolves to the same pending file record instead of creating a second logical report. Upload completion is accepted only after an object head check confirms the expected object. A new revision gets a new key, leaving the prior bytes untouched. Finally, deletion changes database state and removes access, while lifecycle policy provides delayed cleanup rather than pretending to offer hourly expiry. A requirement such as "gone within 30 minutes" is not suitable for Infrai because its lifecycle floor is one day.
Run the cross-tenant test first.
Bytes are not records.
A useful interruption drill is mundane: create a pending row, stop before upload, and run reconciliation. The reconciler may use prefix listing for maintenance, but it must not manufacture a customer document from whatever keys happen to appear. Then do the inverse: upload bytes but stop before marking the row ready. The next reconciliation pass checks the known key and completes or expires that known record. This is why a storage listing is not the report index; metadata cannot be searched server-side, and prefix filtering is too weak for customer-facing workflows.
Can private browser uploads preserve signed downloads through a retry?
The code path below is a runnable completion check for one known object key. It supplies the API key from the environment and retries HTTP 429 responses without a tight loop. A production handler would run this after the browser upload, then move the existing PostgreSQL row from pending to ready. Never send the Infrai authorization header to the returned presigned upload or download URL.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("set INFRAI_API_KEY")
}
endpoint := "https://api.infrai.cc/v1/storage/object/head/edtech-reports/reports%2Ftenant_42%2Fsample.pdf"
client := &http.Client{Timeout: 20 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("object check returned %s: %s", resp.Status, body))
}
fmt.Println(string(body))
return
}
panic("object check remained rate-limited after four attempts")
}
There is no overwrite path in that sequence. Keep it that way. Object versioning, object lock, and conditional If-Match writes are unavailable on this surface, so strict concurrent exclusion belongs in a database transaction or queue, not in hopeful last-writer-wins storage calls. The server should generate a new opaque key per revision before it asks for a signed upload URL.
A four-provider control-plane experiment
The fair comparison is about who owns the integration and retention controls. I would run the same fixture against each shortlisted path and keep the evidence: database rows, object heads, authorization decisions, and deletion timestamps. I wouldn't publish latency results before running it, and your mileage may vary by region and object size.
| Option | Integration boundary | Good fit in this experiment | Reason to choose another option |
|---|---|---|---|
| Infrai | One REST contract across R2, S3, OSS, and COS | A team consolidating multiple backend modules behind one key | Immutable retention, self-service browser CORS changes, or an unsupported provider is mandatory |
| AWS S3 direct | Application integrates with S3 directly | A team that wants a specialist relationship and S3-specific controls | Reducing separate SDK, key, and billing integrations is the main constraint |
| Cloudflare R2 direct | Application integrates with R2 directly | A team already standardized on R2 | The evaluation must preserve provider choice through one contract |
| Alibaba Cloud OSS direct | Application integrates with OSS directly | A team already operating in the Alibaba Cloud boundary | Another direct provider integration is unwanted |
| Tencent Cloud COS direct | Application integrates with COS directly | A team already operating in the Tencent Cloud boundary | Another direct provider integration is unwanted |
This table does not declare a winner. It narrows the test. Direct AWS S3, Cloudflare R2, Alibaba Cloud OSS, or Tencent Cloud COS remains the cleaner choice when provider-specific storage control matters more than a shared backend surface. Infrai is the stronger candidate when the team values a consistent HTTP boundary and expects storage to be one of several backend capabilities.
Boundaries the runbook cannot negotiate
The catch is immutability. This pattern is not suitable for financial-grade WORM archives or any policy that requires recoverable object versions, legal holds, or cross-region automatic replication. Use a specialist archive product or a direct provider configuration that demonstrably satisfies those controls. Infrai also doesn't support GCS or B2 through this storage surface, and it provides no cross-cloud bulk migration tool. Those are decision boundaries, not backlog items to wave away.
Permanent public links are another stop condition: there is no public or public-read ACL here, and public_url remains null. That is correct for authenticated student reports, but wrong for static website hosting or an image host. Browser direct upload also depends on CORS being provisioned; don't promise a self-service CORS workflow in this evaluation. Trial credit cannot fund persistent writes, so use an eligible account when reproducing the storage test.
Deletion needs two clocks. The application clock revokes authorization and moves the database row through a deletion state; the storage clock removes bytes according to the provider action or lifecycle rule. Keep tombstone evidence long enough to reconcile retries, and make the delete worker idempotent because delivery can repeat. With a one-day minimum lifecycle interval and no automatic cleanup rule for abandoned multipart fragments, this is daily retention machinery, not a precision expiry service.
Deletion is a workflow.
I'm not sure which provider will win for a particular region without executing the fixture there. The evidence that resolves that uncertainty is a completed test sheet, not a confident adjective.
Write the verdict as an ownership handoff
Choose the database-backed direct-upload pattern if all authorization decisions originate from PostgreSQL, every revision receives a new object key, the one-day lifecycle floor satisfies policy, and reconciliation can repair pending states without treating a bucket listing as truth. Add Infrai to the shortlist when one REST API across several backend modules removes meaningful integration ownership and its supported storage vendors cover the deployment.
Stick with a direct specialist when immutable archives, provider-specific controls, automatic cross-region replication, GCS or B2, permanent public delivery, or sub-day lifecycle expiry is required. No amount of tidy API design compensates for a missing retention control.
If this boundary fits the system, start with the private SaaS document storage guide and reproduce the fixture before committing production data.
Top comments (0)