Skip to content

Commit c0c20bf

Browse files
improvement(uploads): persist multipart sessions in postgres
1 parent 3577b08 commit c0c20bf

70 files changed

Lines changed: 20278 additions & 1773 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ cat > /tmp/cors.json <<'EOF'
7878
"AllowedOrigins": ["https://sim.yourdomain.com"],
7979
"AllowedMethods": ["GET", "PUT"],
8080
"AllowedHeaders": ["*"],
81-
"ExposeHeaders": ["ETag"],
8281
"MaxAgeSeconds": 3600
8382
}
8483
]
@@ -91,10 +90,6 @@ for name in workspace-files knowledge-base execution-files chat-files \
9190
done
9291
```
9392

94-
<Callout type="warn">
95-
`ExposeHeaders` **must** include `ETag`. Files larger than 50 MB use multipart uploads, and the browser reads each part's `ETag` to complete the upload — CORS hides the header otherwise and large uploads fail at the final step.
96-
</Callout>
97-
9893
Set `AllowedOrigins` to your exact Sim origin (scheme + host, no trailing slash). Add every origin users reach Sim from, including an apex/`www` pair if both are live.
9994

10095
</Step>
@@ -239,15 +234,15 @@ AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos
239234

240235
Direct browser uploads require a Blob service CORS rule on the storage account. Allow your exact
241236
Sim origin, `GET` and `PUT`, the `Content-Type` header, and the `x-ms-*` prefix used by signed blob
242-
and metadata headers:
237+
and metadata headers. Small-file uploads also send `If-None-Match` so a signed URL cannot overwrite
238+
an existing final object:
243239

244240
```bash
245241
az storage cors add \
246242
--services b \
247243
--methods GET PUT \
248244
--origins https://sim.yourdomain.com \
249-
--allowed-headers content-type 'x-ms-*' \
250-
--exposed-headers ETag \
245+
--allowed-headers content-type if-none-match 'x-ms-*' \
251246
--max-age 3600 \
252247
--account-name mystorageaccount \
253248
--account-key '<account-key>'
@@ -295,7 +290,7 @@ cat > /tmp/cors.json <<'EOF'
295290
"method": ["GET", "PUT"],
296291
"responseHeader": [
297292
"Content-Type",
298-
"ETag",
293+
"x-goog-if-generation-match",
299294
"x-goog-meta-uploadid",
300295
"x-goog-meta-originalname",
301296
"x-goog-meta-uploadedat",
@@ -319,7 +314,7 @@ done
319314
```
320315

321316
<Callout type="info">
322-
Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `ETag` is required because large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise.
317+
Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `x-goog-if-generation-match` makes small-file uploads create-only; Sim obtains multipart ETags from GCS during completion rather than exposing them to the browser.
323318
</Callout>
324319

325320
</Step>
@@ -467,25 +462,25 @@ The same browser-reachability and CORS requirements apply.
467462
</Tab>
468463
</Tabs>
469464

470-
## Configure temporary upload cleanup
465+
## Configure incomplete multipart cleanup
471466

472-
Sim stages every direct upload under the `upload-sessions/` prefix before promoting it to its final,
473-
immutable object key. Apply the cleanup policy to **every** purpose-specific bucket or container
474-
configured above:
467+
Sim uploads directly to a create-only final object key and keeps upload-session state in PostgreSQL.
468+
The cleanup cron claims expired sessions before deleting an uploaded object or aborting its provider
469+
multipart state. Configure provider lifecycle cleanup as a second line of defense for multipart
470+
state that outlives its database row:
475471

476-
- On AWS S3 and Google Cloud Storage, expire objects under `upload-sessions/` after two days and
477-
abort incomplete multipart uploads after two days.
478-
- On Azure Blob, expire committed blobs under `upload-sessions/` after two days. Azure automatically
479-
removes uncommitted blocks after seven days.
480-
- For an S3-compatible provider, configure both rules when its lifecycle implementation supports
481-
them. Check the provider's documentation because lifecycle feature support varies.
472+
- On AWS S3 and Google Cloud Storage, abort incomplete multipart uploads after two days on every
473+
purpose-specific bucket.
474+
- Azure automatically removes uncommitted blocks after seven days.
475+
- For an S3-compatible provider, configure incomplete-multipart cleanup when its lifecycle
476+
implementation supports it. Check the provider's documentation because support varies.
482477

483-
The two-day window exceeds the 24-hour upload-token lifetime and leaves time to retry completion.
484-
Do not apply this prefix rule to final objects outside `upload-sessions/`.
478+
The provider window should exceed the 24-hour upload-session lifetime so an in-progress completion
479+
can still recover. Do not add an object-expiration rule for final upload keys.
485480

486481
<Callout type="warning">
487-
Configure both expiration and incomplete-multipart cleanup where available. Expiring staged
488-
objects alone does not necessarily remove abandoned multipart parts.
482+
Object expiration and incomplete-multipart cleanup are different lifecycle operations. Configure
483+
the incomplete-multipart operation; expiring objects does not remove abandoned multipart parts.
489484
</Callout>
490485

491486
## Verify it works

apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ Both pods must have `REDIS_URL`. On Helm they share one Secret, so setting it un
193193

194194
The bucket's CORS policy does not allow your Sim origin. Uploads go directly from the browser to object storage via presigned `PUT`, so server-side configuration being correct is not enough.
195195

196-
If small uploads succeed but files over 50 MB fail at the last step, `ETag` is missing from the CORS exposed headers — multipart uploads read it from the browser. See [Object Storage](/platform/self-hosting/object-storage).
196+
If small uploads succeed but files over 50 MB fail during completion, check the app logs for the provider's part-listing request. The server completes multipart uploads from provider-authoritative state; for S3, its identity needs `s3:ListMultipartUploadParts`. See [Object Storage](/platform/self-hosting/object-storage).
197197

198198
## Agent Output Arrives All at Once
199199

apps/docs/content/docs/en/platform/self-hosting/verify.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Run this after a first install, after an upgrade, and after a restore. Each step
1818
| 4 | Open the same workflow in a second browser window and edit | Cross-replica collaboration | With >1 replica this needs [Redis](/platform/self-hosting/redis) |
1919
| 5 | Paste a model API key in settings and run a two-block workflow | Execution engine, credential encryption, outbound network | App logs; check `ENCRYPTION_KEY` is set and outbound egress is allowed |
2020
| 6 | Upload a small file in Files | File storage end to end | With object storage configured: presigned URL + bucket CORS. On local disk: the upload proxies through the app |
21-
| 7 | Upload a file larger than 50 MB | Multipart upload path (object storage only) | Confirm `ETag` is in the bucket's CORS exposed headers |
21+
| 7 | Upload a file larger than 50 MB | Multipart upload path (object storage only) | Check app logs for provider part-listing or completion errors |
2222
| 8 | Create a knowledge base and upload a PDF | Document parsing, embeddings, pgvector | Needs a hosted embedding provider — see below |
2323
| 9 | Invite a teammate from workspace settings | Email delivery | App logs for the mailer; see [Email](/platform/self-hosting/email) |
2424
| 10 | Connect an integration account | OAuth configuration | Redirect URI mismatch → see [Integrations & OAuth](/platform/self-hosting/integrations-oauth) |
@@ -72,7 +72,7 @@ All six should be present on Compose: `simstudio`, `realtime`, `db`, `redis`, `c
7272

7373
**Step 5 fails — execution errors.** Check outbound connectivity to the model provider, then the app logs. If the error is about decrypting a credential, `ENCRYPTION_KEY` differs from the one that encrypted it.
7474

75-
**Step 6 or 7 fails.** With object storage configured, a CORS error in the browser console means the bucket policy does not allow your Sim origin; step 7 failing while step 6 passes specifically means `ETag` is missing from the exposed headers. On local-disk storage there is no CORS involved — uploads proxy through the app, so look at the app logs and the proxy body-size limit instead.
75+
**Step 6 or 7 fails.** With object storage configured, a CORS error in the browser console means the bucket policy does not allow your Sim origin or the signed upload headers. If step 7 fails only during completion, check the app logs and verify the server identity can list multipart parts (for S3, `s3:ListMultipartUploadParts`). On local-disk storage there is no CORS involved — uploads proxy through the app, so look at the app logs and the proxy body-size limit instead.
7676

7777
**Step 8 fails — knowledge base upload errors.** Knowledge bases need a hosted embedding provider — OpenAI, Azure OpenAI, or Gemini. There is no local embedding backend. If a key is set, check pgvector is installed on the database.
7878

apps/docs/openapi-v2-files-audit.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -435,14 +435,6 @@
435435
"$ref": "#/components/parameters/UploadTokenHeader"
436436
}
437437
],
438-
"requestBody": {
439-
"required": true,
440-
"content": {
441-
"application/json": {
442-
"schema": {}
443-
}
444-
}
445-
},
446438
"responses": {
447439
"200": {
448440
"description": "The completed upload and registered file.",

apps/docs/openapi-v2-knowledge.json

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,16 +1008,6 @@
10081008
"$ref": "#/components/parameters/WorkspaceIdQuery"
10091009
}
10101010
],
1011-
"requestBody": {
1012-
"required": true,
1013-
"content": {
1014-
"application/json": {
1015-
"schema": {
1016-
"$ref": "#/components/schemas/CompleteUploadBody"
1017-
}
1018-
}
1019-
}
1020-
},
10211011
"responses": {
10221012
"200": {
10231013
"description": "The completed upload and queued knowledge document.",
@@ -2197,41 +2187,6 @@
21972187
}
21982188
}
21992189
},
2200-
"CompleteUploadBody": {
2201-
"oneOf": [
2202-
{
2203-
"type": "object",
2204-
"additionalProperties": false,
2205-
"required": ["parts"],
2206-
"properties": {
2207-
"parts": {
2208-
"type": "array",
2209-
"minItems": 1,
2210-
"maxItems": 640,
2211-
"items": {
2212-
"type": "object",
2213-
"additionalProperties": false,
2214-
"required": ["partNumber"],
2215-
"properties": {
2216-
"partNumber": {
2217-
"type": "integer",
2218-
"minimum": 1
2219-
},
2220-
"etag": {
2221-
"type": "string",
2222-
"minLength": 1
2223-
}
2224-
}
2225-
}
2226-
}
2227-
}
2228-
},
2229-
{
2230-
"type": "object",
2231-
"additionalProperties": false
2232-
}
2233-
]
2234-
},
22352190
"DocumentSummary": {
22362191
"type": "object",
22372192
"description": "Summary representation of a document, returned in list operations and as the upload acknowledgement.",

apps/docs/openapi-v2-tables.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3468,14 +3468,6 @@
34683468
"$ref": "#/components/parameters/UploadTokenHeader"
34693469
}
34703470
],
3471-
"requestBody": {
3472-
"required": true,
3473-
"content": {
3474-
"application/json": {
3475-
"schema": {}
3476-
}
3477-
}
3478-
},
34793471
"responses": {
34803472
"200": {
34813473
"description": "The queued import resource.",

apps/sim/app/api/cron/cleanup-tasks/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { verifyCronAuth } from '@/lib/auth/internal'
44
import { dispatchCleanupJobs } from '@/lib/billing/cleanup-dispatcher'
55
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { cleanupExpiredUploadSessions } from '@/lib/uploads/upload-session/service'
67

78
export const dynamic = 'force-dynamic'
89

@@ -13,11 +14,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
1314
const authError = verifyCronAuth(request, 'task cleanup')
1415
if (authError) return authError
1516

17+
const uploadSessions = await cleanupExpiredUploadSessions()
1618
const result = await dispatchCleanupJobs('cleanup-tasks')
1719

18-
logger.info('Task cleanup jobs dispatched', result)
20+
logger.info('Task cleanup jobs dispatched', { ...result, uploadSessions })
1921

20-
return NextResponse.json({ triggered: true, ...result })
22+
return NextResponse.json({ triggered: true, ...result, uploadSessions })
2123
} catch (error) {
2224
logger.error('Failed to dispatch task cleanup jobs:', { error })
2325
return NextResponse.json({ error: 'Failed to dispatch task cleanup' }, { status: 500 })

apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,14 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa
2222
if (!parsed.success) return parsed.response
2323

2424
try {
25-
const session = getOwnedUploadSession({
25+
const session = await getOwnedUploadSession({
2626
uploadId: parsed.data.params.uploadId,
2727
uploadToken: parsed.data.headers['upload-token'],
2828
userId: actor.id,
2929
})
3030
await reauthorizeUploadPurpose(actor.id, session)
3131
const completed = await completeUploadSession({
3232
session,
33-
completion: parsed.data.body,
3433
finalize: (claimed) => finalizeUploadPurpose({ session: claimed, actor, request }),
3534
})
3635
return NextResponse.json({

apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa
1717
if (!parsed.success) return parsed.response
1818

1919
try {
20-
const session = getOwnedUploadSession({
20+
const session = await getOwnedUploadSession({
2121
uploadId: parsed.data.params.uploadId,
2222
uploadToken: parsed.data.headers['upload-token'],
2323
userId: actor.id,

apps/sim/app/api/files/uploads/[uploadId]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Upl
2121
if (!parsed.success) return parsed.response
2222

2323
try {
24-
const session = getOwnedUploadSession({
24+
const session = await getOwnedUploadSession({
2525
uploadId: parsed.data.params.uploadId,
2626
uploadToken: parsed.data.headers['upload-token'],
2727
userId: actor.id,

0 commit comments

Comments
 (0)