Skip to content

feat(upload): send large assets in chunks so they clear a proxy body limit - #3310

Open
mickzijdel wants to merge 23 commits into
Screenly:masterfrom
mickzijdel:feat/chunked-uploads
Open

feat(upload): send large assets in chunks so they clear a proxy body limit#3310
mickzijdel wants to merge 23 commits into
Screenly:masterfrom
mickzijdel:feat/chunked-uploads

Conversation

@mickzijdel

@mickzijdel mickzijdel commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Issues Fixed

No existing issue. This is the fix for the problem behind #3302: I run Anthias behind Cloudflare, and uploading a large video fails. #3302 made the failure legible ("File too large - it exceeds the upload size limit of the server or a proxy in front of it"); this makes the upload work.

Description

The Add asset upload posts the whole file in one request, so a large video fails wherever a reverse proxy caps the request body. Cloudflare caps every non-Enterprise plan at 100 MB, a video of a few minutes clears that easily, and the operator cannot raise it. The bytes have to arrive in several requests instead.

Server. A ranged upload carries Content-Range. Each request stages its bytes into <assetdir>/.uploads/<id>.part and answers with JSON; only the request carrying the final byte falls through to create the asset, which is then a rename within one filesystem rather than a copy. Type detection, the display name and the extension all run before staging, so a file Anthias will refuse is refused on the first chunk rather than after the operator has waited out the whole upload. A request without Content-Range takes the original path untouched.

Browser. A file larger than the chunk size is sliced and sent sequentially, each slice re-wrapped as a Blob carrying the file's own type. That last part matters: a raw Blob from File.slice() reports application/octet-stream, and the server reads the browser's type to catch a file whose extension lies about it, such as a HEIC renamed to .jpg. Without the re-wrap the chunked path would skip normalisation and the asset would render blank on the player, which is exactly what test_assets_upload_misnamed_heic_uses_browser_content_type protects on the single-shot path.

Progress is measured against the whole file rather than each request, so a large upload no longer runs from nought to a hundred once per chunk. A dropped connection or a 5xx resends the same chunk twice before giving up; a 4xx is the server's considered answer and is not resent.

Chunk size reaches Django as an environment variable and the browser as a <meta> tag, via the Django setting and the template context. On the plain docker-compose install it is declared in docker-compose.yml.tmpl and read from /etc/anthias/anthias.env, which upgrade_containers.sh sources before envsubst the same way it already sources proxy.env — without both halves the variable either never reaches the container or is reverted by the next upgrade. On balena it is a dashboard variable. ANTHIAS_UPLOAD_CHUNK_SIZE_MB defaults to 16, and both the browser and the server clamp it to [1, 24]. That ceiling keeps a chunk under FILE_UPLOAD_MAX_MEMORY_SIZE, where MemoryFileUploadHandler holds it entirely in RAM — so 16 MB is what one in-flight upload costs resident on a board that may have 512 MB. Over the limit Django spools to FILE_UPLOAD_TEMP_DIR, which is unset, so /tmp; no container here mounts a tmpfs there, making it the writable overlay, i.e. the SD card. Trading RAM for card writes is the wrong way round on this hardware. The value is parsed defensively: 16m, or 16 with the trailing space a balena variable field keeps, used to raise ValueError while settings imported and stop the container from starting. It is read as a decimal and rounded down, so 8.5 gives 8 — an operator writing that is trying to fit under a 10 MB cap, and handing them the 16 MB default would leave them exactly as broken as before.

Guards that exist because the failure they prevent is silent. A chunk is refused if it would seek past what is actually held: the alternative is a hole that reads back as zeros and an asset that is corrupt at exactly the right size. The check runs against the open file descriptor rather than the path, so the cleanup sweep cannot delete the file in between. The final chunk truncates to the declared total, so a retry that shrank cannot leave the tail of a longer earlier attempt behind. Range digits are bounded, because int() raises above 4300 digits and a client-controlled header must not reach a 500.

Partials live under the same one-hour deadline as every other stray file in the asset dir, and are excluded from backups, which tar that directory recursively and would otherwise carry gigabytes of a file nobody finished sending.

Known limitations

Worth stating plainly rather than leaving to be discovered:

  1. Chunks must arrive strictly sequentially, one in flight. What is tracked per upload is a byte count, not a set of received ranges, so a chunk starting past the end cannot be told apart from a resumed upload whose partial has gone. Both are treated as the latter: the partial is dropped and the operator asked to start over. Real resumability needs a received-ranges record and is not this change.
  2. A commit whose response is lost leaves the operator unable to tell whether the asset was created. The server renames the partial into place and then answers, so a lost reply is indistinguishable from a lost commit. This used to be worse than ambiguous: the browser resent the commit, the resend found the partial already moved away, os.open with O_CREAT recreated it empty, the offset guard fired, and the operator was told "This upload expired. Please try uploading it again." for an upload that had worked. The duplicate came from doing what that message said. The commit is no longer resent — on a dropped socket or a 5xx, once its body has gone out, since behind a proxy the lost-commit case usually arrives as a gateway 502 rather than a socket error. A body that never finished sending cannot have committed, so that case is still retried and still reported as the ordinary transport failure it is. Both messages now point at the asset list before anything else, and the modal closes so the list is visible. Removing the ambiguity itself needs the commit marker I built, could not demonstrate, and removed rather than ship protection nobody can rely on. The single-shot path has the same ambiguity today.
  3. No fsync before the final rename, so power loss immediately after commit could leave the row pointing at unwritten data. Also true of the single-shot path today.

Testing

I ran a dev stack with the chunk size set to 1 MB behind Caddy capped at 2 MB, then uploaded a 5 MB file through the real Add asset modal.

The control first, to show the test means something: the identical file as a single request is refused.

single-shot through the 2MB cap  ->  413

Through the modal:

Source 5,242,880 bytes, sha256 9ddc5758c0f55d7f...
Stored asset 5,242,880 bytes, sha256 9ddc5758c0f55d7f...
Requests the server saw 5
Staging directory afterwards empty

Byte-identical, through a proxy that rejects the same file whole.

Unit tests cover the byte-exact multi-chunk round trip, ENOSPC while staging, the single-shot path staying unchanged when a stray upload id is present, rejection matrices for malformed ids and ranges (including the over-4300-digit total that used to 500), a chunk that lies about its length, a gap past the end of the partial, the final truncate, the free-space refusal, and on the browser side the range arithmetic, the type re-wrap, the retry policy and the error mapping. I mutation-tested them: removing the truncate, the length check, the free-space check, the batch break, the type re-wrap or the retry each makes them fail.

2010 python tests and 118 frontend tests pass, repeatably across consecutive runs. ruff check, ruff format --check and mypy are clean.

Review round

Eight findings from the review, one commit each:

  • ANTHIAS_UPLOAD_CHUNK_SIZE_MB no longer stops the server from booting when it is mistyped, and the [1, 24] bounds are enforced server-side rather than only in the browser.
  • Two of the new tests dispatched video normalisation for real and so needed Redis, which broke the no-Docker host run CLAUDE.md documents. Mocked like their neighbours; reproduced against an unreachable broker first.
  • The chunk-size rationale was inverted. /tmp is not a tmpfs in any of these containers, so staying under FILE_UPLOAD_MAX_MEMORY_SIZE is what puts a chunk in RAM, not what keeps it out. Corrected in both places that repeated it.
  • The lost-commit path, above.
  • Staged partials were fetchable at /anthias_assets/.uploads/<id>.part. anthias_assets now refuses any dot-leading path component. Not by moving the staging dir to ~/.anthias, which looked obvious and does not work: docker-compose.yml.tmpl bind-mounts .anthias and anthias_assets separately, and rename(2) across two mounts is EXDEV even on one filesystem — verified in a container with those mounts. The commit would have become a full copy of a multi-GB file onto an SD card.
  • Two unreachable branches in uploadOne removed, along with the nullable res that forced them.
  • The truncate test no longer declares one total on the first chunk and a different one on the second, which made "the declared size may change between chunks" look supported.
  • Comments across the branch tightened, and the two JSON parsers in home.ts collapsed into one.

Verified with an unreachable Redis to match the documented host recipe: 2028 Python tests and 121 frontend tests pass, ruff check, ruff format --check and mypy clean.

Second review round

Nine more findings, one commit each. The two that were defects in the first round's own fixes:

  • The commit was still being resent. The isFinal exemption covered only status === 0, and behind a proxy a lost commit usually arrives as a 502/504. The fix was half a fix.
  • The 409's wording never reached anyone. Only the staging loop read the server's JSON error; the commit chunk reported its status code and dropped the body, so the message written to prevent a duplicate was discarded on the one chunk that can cause one.

And seven more:

  • ANTHIAS_UPLOAD_CHUNK_SIZE_MB was unreachable on the docker-compose install, as above.
  • A rejected chunk left its partial on disk: every early validation raised before the try that cleans up.
  • A fractional size fell back up to 16 rather than down.
  • The browser had the ceiling but not the floor, so a hand-edited meta tag could floor the chunk size to 0 bytes and make uploadOne throw before a single request went out. The frontend tests were also running at 0.001 MB, which the server can no longer emit.
  • .uploads was not, as claimed, the only unfetchable thing in the asset dir — the REST API's <upload_id>.tmp was still being served, and the importer's .import-* files were caught by accident.
  • The backup filter pruned .uploads only, so a backup taken mid-import still carried up to 5 GiB.
  • Four things were green with the code deleted: the meta-tag wiring, the client/server bounds (which can drift and silently undo the clamp), the 200-plus-error-toast rejection rule, and the backup filter itself.

Separately, cleanup_asset_dir in the v1 API tests emptied settings['assetdir'] on teardown — one directory shared by every xdist worker, which is where a one-off failure under -n auto came from, and which also deletes a developer's real ~/anthias_assets (verified with a sentinel file). Each test gets its own now.

Note on overlap

This touches the same upload code as #3306, so the two conflict. Whichever merges second I will rebase; happy to do that at any point.

Checklist

  • I have performed a self-review of my own code.
  • New and existing unit tests pass locally and on CI with my changes.
  • I have done an end-to-end test for Raspberry Pi devices.
  • I have tested my changes for x86 devices.
  • I added a documentation for the changes I have made (when necessary).

Tested end to end against a dev stack behind a real proxy, but not on hardware: my only Pi is in use at the moment. The reverse-proxy FAQ entry that #3302 extended already covers proxy body-size caps, so I do not think this needs further docs, but say the word if you would like the chunk-size variable documented there too.

🤖 Generated with Claude Code

mickzijdel and others added 3 commits August 20, 2026 15:57
The Add asset upload posts the whole file in one request, so a large
video fails wherever a reverse proxy caps the request body. Cloudflare
caps every non-Enterprise plan at 100 MB, which a 4K clip clears
easily, and the operator cannot raise it. The bytes have to arrive in
several requests instead.

A ranged upload carries Content-Range. Each request stages its bytes
into <assetdir>/.uploads/<stem>.part and answers with JSON; only the
request carrying the final byte falls through to create the asset,
which is then a rename within one filesystem rather than a copy.
Detection, the display name and the extension all run before staging,
so a file Anthias will refuse is refused on the first chunk rather
than after the operator waits out the whole upload. A request without
Content-Range takes the original path untouched.

Chunks for one upload must arrive strictly sequentially, one in
flight. What is tracked is a byte count, not a set of received
ranges, so a chunk starting past the end cannot be told apart from a
resumed upload whose partial has gone. Both are treated as the
latter: the partial is dropped and the operator asked to start over.
Real resumability needs a received-ranges record and is not this
change.

Several guards exist because the failure they prevent is silent. A
chunk is refused if it would seek past what is actually held, since
the alternative is a hole that reads back as zeros and an asset that
is corrupt at exactly the right size; the check runs against the open
file descriptor rather than the path, so the sweep cannot delete the
file in between. The staged name mixes in the session key, so knowing
another client's id is not enough to finish their bytes as your own
asset. An empty marker records a committed id, so retrying a request
that timed out after it succeeded cannot build a second asset. The
declared total is checked against free space with a margin before any
of it is written, because a player that fills its card stops being a
player.

ENOSPC while staging removes the partial and answers 507, matching
the single-shot path and the REST API. Range digits are bounded so a
client-controlled header cannot raise ValueError out of the view.
Chunks answer JSON with real status codes rather than the asset
table, which would otherwise re-render and fan out a websocket
refresh for an asset that does not exist yet.

Partials outlive the hourly sweep by a day, since an operator who
pauses a large upload should find their bytes still there. They are
excluded from backups, which tar the asset dir recursively and would
otherwise carry gigabytes of a file nobody finished sending. One test
fixture unlinked every entry in the asset dir and now handles a
subdirectory being there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQ7dFy1EnsBd5394K1UHTz
The server accepts a ranged upload; this is the half that produces
one. A file larger than the configured chunk size is sliced and sent
as sequential requests carrying Content-Range, each echoing the
upload id the previous response returned. Anything that fits in one
chunk takes the original single-request path untouched.

Each slice is re-wrapped as a Blob carrying the file's own type. A
raw Blob from File.slice() reports application/octet-stream, and the
server reads the browser's type to catch a file whose extension lies
about it, such as a HEIC renamed to .jpg. Without the re-wrap the
chunked path would skip normalisation and the asset would render
blank on the player, which is exactly the case
test_assets_upload_misnamed_heic_uses_browser_content_type exists to
prevent on the single-shot path.

Progress is measured against the whole file rather than each request,
so a large upload no longer runs from nought to a hundred once per
chunk. A dropped connection or a 5xx resends the same chunk twice
before giving up, since chunking turns one request into dozens and a
single blip should not lose an upload the operator has been waiting
on; a 4xx is the server's considered answer and is not resent.

Chunk size follows the path app_store_index_url already takes: an
environment variable, a Django setting, the template context and a
meta tag. It defaults to 16 MB and the client caps it at 24,
because above FILE_UPLOAD_MAX_MEMORY_SIZE Django spools the chunk to
/tmp, which is RAM on a stock Pi image. Sizing chunks to a proxy's
limit instead would cost that much memory per upload on a board that
may have 512 MB.

The range arithmetic lives in its own module because its failures are
silent: the server truncates to the declared total, so a wrong final
range or an off-by-one start yields an asset of exactly the right
size that will not play.

Verified end to end against a dev stack behind a proxy capped at 2 MB
with the chunk size set to 1 MB. The same 5 MB file sent as a single
request is refused with 413; sent through the Add asset modal it
arrives as five requests and the stored asset matches the source
sha256 byte for byte, with no partial left staged. Adds tests for the
round trip, disk-full while staging, the single-shot path being
unchanged when a stray upload id is present, and rejection matrices
for malformed ids and ranges. 2007 python tests and 114 frontend
tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQ7dFy1EnsBd5394K1UHTz
Review of the chunked-upload branch found the client discarding every
message the server had been careful to word.

A chunk answers a full disk with 507 and the shared DISK_FULL_ERROR
text as JSON, but the message table matched `status >= 500` first and
told the operator to go read the device logs. Its comment still
claimed no 507 could reach the browser, which this branch had made
untrue. The server's own wording now passes through, and a 507 with
no readable body names the disk rather than the logs.

Type detection runs before staging, so a file Anthias refuses is
refused on the first chunk with the asset table and its own toast,
exactly as a single-shot upload is. The client could not parse that
as an acknowledgement, called it a transport failure and abandoned
the rest of the batch, where the unchunked path would have shown
"Invalid file type" and carried on. A 200 that is not an
acknowledgement is now one file rejected, toast replayed, batch
intact.

The upload id is minted client-side. The server used to mint it on
the first chunk, so a retry of that chunk after a lost response left
the staged bytes orphaned under an id the client never learned while
the upload silently continued under a second one.

Session-scoped staged filenames are gone. Nothing in the codebase
writes to the session and auth is off by default, so the salt was
always empty and the scoping was a no-op; with auth on it separated
only one operator's own tabs, while Django cycling the session key on
login would strand an upload mid-flight. It bought nothing and could
break a working upload.

The free-space margin is gone too. Refusing anything within 512 MB of
free space meant a device with 400 MB free could not take a 30 MB
video, and it reported the disk as full when it was not. Only an
upload that genuinely cannot fit is refused now. Partials return to
the same one-hour deadline as every other stray file: there is no
resume path, so holding one for a day only occupies the card, and the
offset guard already makes a swept partial fail loudly rather than
corrupt.

The commit marker that was meant to stop a replayed commit creating a
second asset is removed. Its test passed in isolation and failed in
the suite; made hermetic, it failed consistently, and the marker was
landing outside the asset dir the request was using. The guard did
not work. Shipping one that cannot be demonstrated is worse than
none, because everything downstream assumes the protection is there.
A retried commit can still duplicate an asset, as it can on the
single-shot path today; that belongs in the pull request text where
it can be weighed.

Adds tests for the length check, the free-space refusal and the final
truncate, all of which survived deletion before, and updates the
three tests that encoded the old contracts. 2010 python and 118
frontend tests pass, repeatably across consecutive runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQ7dFy1EnsBd5394K1UHTz
@mickzijdel
mickzijdel requested a review from a team as a code owner August 20, 2026 15:07
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (master@ba28647). Learn more about missing BASE report.

Additional details and impacted files
@@            Coverage Diff            @@
##             master    #3310   +/-   ##
=========================================
  Coverage          ?   90.43%           
=========================================
  Files             ?       85           
  Lines             ?    10052           
  Branches          ?     1120           
=========================================
  Hits              ?     9091           
  Misses            ?      708           
  Partials          ?      253           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Codecov put the staged-upload sweep at a third covered, which is the
same gap review had flagged: nothing exercised the cleanup that
deletes abandoned partials. Its blast radius is what makes it worth
pinning. Sweeping too eagerly takes a partial out from under an
upload still in progress, and the operator loses a large upload to an
expiry error; not sweeping at all leaves the card filling with
partials nobody can reach from the UI. The test runs the real find
against one partial aged two hours and one just written, and asserts
the directory itself survives, since the next upload's first chunk
has nowhere to land otherwise.

Two error paths in the view were uncovered as well. If the filesystem
will not report free space the upload proceeds rather than being
refused, because a check that cannot run should not block a working
upload. And only ENOSPC becomes the disk-full answer: anything else
is a real fault and must surface, rather than telling the operator to
free up space that was never the problem.

The remaining line codecov lists is the rmtree branch of a test
fixture, which only runs once a chunked upload has left a directory
behind during teardown. Writing a test for that would be writing a
test about a test.

2013 python and 118 frontend tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XQ7dFy1EnsBd5394K1UHTz

@vpetersson-bot vpetersson-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed as untrusted input, with attention to the things a hostile upload path would hide: traversal through the client-supplied id, symlink swaps on the staged file, holes in the reassembled file, and unbounded disk use. I found nothing malicious, and the guards you built are the right ones. I ran the branch locally: ruff check, ruff format --check and bun test (118 pass) are clean, and 644 Python tests pass — with two exceptions, below.

The integrity argument holds

This is the part I most wanted to break, so it is worth saying that I could not. I worked through the overwrite cases by hand: a retried chunk whose start < st_size, a deliberately tiny first chunk followed by a large final one, and a final chunk landing exactly at st_size. In every case seek(start) + write leaves the file at end + 1, the per-chunk file_upload.size check pins that to the declared range, and the start_bytes > fstat(fd).st_size guard makes a gap unrepresentable. The committed file is always exactly total_bytes with every byte accounted for. truncate(total_bytes) on the final chunk closes the shrinking-retry case. Measuring the open descriptor rather than the path, for the reason you give, is the right call.

The rest of the perimeter checks out too:

  • _UPLOAD_ID_RE.fullmatch on 32 lowercase hex closes traversal; O_NOFOLLOW closes the symlink swap; the 19-digit bound really does keep int() off its 4300-digit ValueError.
  • The orphan sweep in cleanup() uses os.scandir + is_file(), so .uploads is skipped rather than swept, and the existing *.tmp find cannot match *.part. The new .part sweep is the only thing that touches partials, and each chunk refreshes the mtime, so a slow upload is safe.
  • tarfile.add returns before recursing when the filter yields None, so _skip_staged_uploads genuinely prunes the subtree rather than just omitting the directory entry.

Findings

Two I would fix before merge and two smaller ones, all inline. Summarised:

  1. Two of the new tests fail without Redis — reproduced locally, and it breaks the no-Redis host recipe CLAUDE.md documents.
  2. The rationale for the chunk size is inverted. /tmp is not RAM in these containers, so staying under FILE_UPLOAD_MAX_MEMORY_SIZE forces each chunk into memory rather than keeping it out. The 16 MB number is still defensible; the reason given for it is not.
  3. int(getenv(...)) on a device variable can stop the server from starting.
  4. Known limitation 2 is described as the wrong failure. What the operator actually sees is an error, not a silent success — which changes how it should be documented.

On the overlap with #3306: no view from me on merge order, but the conflict is in assets_upload and home.ts, both of which this PR restructures substantially, so whichever lands second is a real rebase rather than a mechanical one.



@pytest.mark.django_db
def test_assets_upload_chunked_round_trip_is_byte_exact(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two tests need Redis, and the sibling tests in this file deliberately do not.

Reproduced on a host with no Redis running:

FAILED tests/test_template_views.py::test_assets_upload_chunked_round_trip_is_byte_exact
FAILED tests/test_template_views.py::test_assets_upload_final_chunk_truncates_a_longer_earlier_attempt
RuntimeError: Retry limit exceeded while trying to reconnect to the Celery result store backend.

The cause is the .mp4 payload: a video upload sets is_processing and the view dispatches normalize_video_asset.delay for real, which reaches for the Celery result backend. Every other video-upload test in this file wraps that in mock.patch('anthias_server.celery_tasks.normalize_video_asset.delay') — these two do not.

This matters beyond a local inconvenience: CLAUDE.md documents uv run pytest -m "not integration" on the host as a no-Docker, no-Redis run, and the root conftest.py force-mocks lib.utils.connect_to_redis but not Celery's broker. So the suite as documented now has two hard failures, while CI (which has Redis) stays green — the combination that leaves this kind of thing sitting for months.

Fix is either mock the delay like the neighbours do, or make the payload an image, since neither test is about the video pipeline. test_assets_upload_final_chunk_truncates_a_longer_earlier_attempt has the same problem at line 2853.

# production store index.
# Size of each request a large browser upload is split into. Kept
# under FILE_UPLOAD_MAX_MEMORY_SIZE above so a chunk is buffered in
# memory rather than spooled to FILE_UPLOAD_TEMP_DIR, which is /tmp

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/tmp is not RAM here, so this reasoning runs the wrong way.

The premise is true of Raspberry Pi OS on the host, but this code runs in a container, and I checked all four compose files — docker-compose.yml.tmpl, docker-compose.balena.yml.tmpl, docker-compose.dev.yml, docker-compose.test.yml. None mounts a tmpfs at /tmp for anthias-server (the only tmpfs-adjacent setting anywhere is shm_size on the viewer), and FILE_UPLOAD_TEMP_DIR is unset. So /tmp inside the container is the writable overlay layer, i.e. the SD card.

That inverts the trade-off: staying under FILE_UPLOAD_MAX_MEMORY_SIZE is what puts a chunk fully in RAM, via MemoryFileUploadHandler. Exceeding it is what would have spooled it to disk. So the chunk ceiling is not protecting the 512 MB board's memory — it is spending it, 16 MB per concurrent chunk request.

Worth being clear that this is a comment-and-rationale problem, not necessarily a code one. 16 MB resident per in-flight upload is a defensible number, and it is better than the status quo, where a 100 MB single-shot upload spooled the whole 100 MB somewhere. But the same wrong premise drives MAX_CHUNK_MB = 24 in chunking.ts, and a future maintainer reasoning from it will reach for the opposite of what they intend. Either correct both comments to say "buffered in RAM, so keep it small", or point FILE_UPLOAD_TEMP_DIR at the asset volume and size chunks to what proxies actually need.

# limit instead would cost that much memory per upload on a board
# that may have 512 MB. Lower it if a proxy in front of the device
# caps request bodies below this.
UPLOAD_CHUNK_SIZE_MB = int(getenv('ANTHIAS_UPLOAD_CHUNK_SIZE_MB', '16'))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A typo in this device variable stops the server from starting.

This is the only int(getenv(...)) in settings.py, and it is unguarded: ANTHIAS_UPLOAD_CHUNK_SIZE_MB=16m (or 16 with a trailing space that a Balena variable field will happily keep) raises ValueError during settings import, and the container never comes up. On a headless device set through the Balena dashboard, the operator has no shell to work out why, and the value they typed is the sort of thing people set once and never look at again.

Two smaller things while you are here:

  • Values above 24 are silently ignored, because the cap lives only in chunkSizeFromMeta. An operator who reads the docstring and sets 32 to match their proxy gets 24 with no indication.
  • There is no lower bound either. 0.5 yields ~512 KB chunks, so a 2 GB video becomes ~4000 sequential requests, each with its own round trip and multipart parse.

Parsing defensively with a fallback to 16 and a warning, plus clamping to something like [1, 24] server-side, makes the client cap a second line of defence instead of the only one.

Comment thread src/anthias_server/app/views.py Outdated
# the sequential-only contract above.
if start_bytes > os.fstat(f.fileno()).st_size:
raise _ChunkedUploadError(
'This upload expired. Please try uploading it again.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the path that produces known limitation 2, and it does not fail the way the PR body describes.

The body says a commit that times out after succeeding "can produce a duplicate asset if the client retries". The mechanism is a bit different, and the difference matters for how it should be documented.

sendUpload resolves status: 0 on a dropped connection, and uploadOne treats status === 0 as retryable — including for the final chunk. So when the commit succeeds but the response is lost, the retry arrives after os.replace has already moved the partial away. os.open with O_CREAT recreates it empty, start_bytes > st_size fires here, and the client gets a 409.

What the operator sees is therefore "This upload expired. Please try uploading it again." for an upload that completed and created the asset. They do what the message says, and that is where the duplicate comes from — a second full upload, not a retried commit.

I would not ask you to build the marker you already removed. But the limitation is worth restating in these terms, because "may produce a duplicate" reads as a silent, harmless outcome, whereas the actual outcome is an error message that actively instructs the operator into the duplicate. If the message is all that is on offer, it could at least say the upload may have gone through and to check the list before retrying.

# Where chunked browser uploads stage their partial file, under the
# asset dir. Shared with the cleanup sweep and the backup filter so
# the name cannot drift from the code that writes into it.
STAGED_UPLOAD_DIR = '.uploads'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low severity, but worth a thought while the location is still cheap to change: .uploads sits inside anthias_assets, which is served over HTTP. views_files.anthias_assets resolves /anthias_assets/.uploads/<id>.part to a real path under ANTHIAS_ASSETS_ROOT, so the startswith guard passes and the file is served. That view's own comment says the DOCKER_BRIDGE_CIDR gate "does not actually exclude LAN clients" in the default no-SSL install, since REMOTE_ADDR is the bridge gateway.

In practice the 32-hex id is unguessable and there is no directory listing (IsADirectoryError becomes a 404), so this is not something I would hold the PR for. But partial uploads are the one thing in that tree that was never meant to be fetchable, and staging under ~/.anthias/ instead would take them off the HTTP surface entirely. Both live under the same mount, so the same-filesystem rename that makes the commit cheap still holds — it would cost the backup filter and the sweep path, and nothing else.

if (!retryable || attempt === CHUNK_RETRIES) break
await new Promise((r) => setTimeout(r, CHUNK_RETRY_DELAY_MS))
}
if (res === null) break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this branch and the return at the end of the function are both unreachable. res is assigned on every iteration of the inner retry loop, which always runs at least once, so it is never null by the time you get here; and needsChunking guarantees at least two chunks, the last of which returns via interpretFinalResponse.

Harmless, but the trailing { kind: 'network' } reads like a real fallback for "ran out of chunks without committing", which cannot happen.



@pytest.mark.django_db
def test_assets_upload_final_chunk_truncates_a_longer_earlier_attempt(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Besides the Redis dependency noted above, this test pins down a contract worth a second look: chunk one declares /40 and chunk two declares /10 for the same upload id, and the server accepts the change. total_bytes is never compared against what earlier chunks declared.

The offset and size guards mean nothing corrupt comes out of it — the committed file is exactly the new total, with every byte written — so this is not a bug. But no real client shrinks the total mid-upload, and encoding it in a test makes "the declared size may change between chunks" a supported behaviour that someone later has to preserve. If the intent is just to prove truncate works, driving it with a consistent total and a genuinely longer earlier attempt under the same total would test the same line without fixing the looser contract in place.

mickzijdel and others added 16 commits August 26, 2026 21:37
ANTHIAS_UPLOAD_CHUNK_SIZE_MB went straight through int(), so a value
like `16m` — or `16` with the trailing space a balena variable field
keeps — raised ValueError while settings imported and the container
never came up. On a headless device the operator has no shell to work
out why, and this is the sort of variable set once and never looked at
again.

Parse it the way resolve_time_zone parses TZ: fall back to the default
and say so, rather than letting any value wedge Django at startup.
Clamp to [1, 24] while here. The ceiling matched the browser's
MAX_CHUNK_MB but lived only there, so an operator who set 32 to match
their proxy silently got 24; there was no floor at all, and 0.5 turns
a 2 GB video into ~4000 sequential requests.

Also moves the block below APP_STORE_INDEX_URL — it had landed between
that constant and the comment explaining it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both complete an upload with an .mp4 payload, so the view dispatches
normalize_video_asset for real and Celery reaches for its result
store. Every other video-upload test in this file mocks that; these
two did not, so they failed on any host without Redis:

    RuntimeError: Retry limit exceeded while trying to reconnect to
    the Celery result store backend.

CLAUDE.md documents `uv run pytest -m "not integration"` as a
no-Docker, no-Redis run, and conftest force-mocks connect_to_redis but
not Celery's broker — so the suite as documented had two hard
failures while CI, which has Redis, stayed green.

Reproduced against an unreachable broker (39.9s, both failing) and
confirmed fixed the same way (0.7s, both passing).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment claimed a chunk over FILE_UPLOAD_MAX_MEMORY_SIZE would be
spooled to /tmp, "and therefore RAM on a stock Pi image". That is true
of Raspberry Pi OS on the host, but this code runs in a container, and
none of docker-compose.yml.tmpl, .balena.yml.tmpl, .balena.dev.yml.tmpl,
.dev.yml or .test.yml mounts a tmpfs at /tmp for anthias-server;
FILE_UPLOAD_TEMP_DIR is unset. So /tmp there is the writable overlay,
i.e. the SD card.

Which inverts the argument: staying under the limit is what puts each
chunk fully in RAM via MemoryFileUploadHandler. 16 MB resident per
in-flight upload is still the number I want — trading RAM for card
writes is the wrong way round on this hardware — but a maintainer
reasoning from the old comment would reach for the opposite of what
they intended. Same wrong premise was copied into MAX_CHUNK_MB.

Comments only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The final chunk commits: the server renames the partial into place and
then answers. If that answer is lost, sendUpload reports status 0,
which uploadOne treated as retryable — so the commit was resent, found
the partial already moved away, recreated it empty via O_CREAT, and
tripped `start_bytes > st_size`. The operator was then told "This
upload expired. Please try uploading it again." for an upload that had
worked, did what the message said, and that second upload is where the
duplicate came from.

Two changes, one to each half:

  * a lost response is only retried for a staging chunk, which is
    idempotent — the commit is never resent. It reports a new
    `unconfirmed` failure instead, whose message points at the asset
    list rather than asking for another upload.
  * the 409 says the upload could not be resumed and to check the
    list first, since a lost commit is one of the ways to reach it.

Does not eliminate the duplicate — that needs the commit marker this
branch tried and dropped — but it stops the UI instructing the
operator into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.uploads/<id>.part` sits inside anthias_assets, and anthias_assets
resolves any path under ANTHIAS_ASSETS_ROOT and serves it — the
startswith guard passes fine for a partial. That view's own comment
notes the DOCKER_BRIDGE_CIDR gate does not exclude LAN clients in the
default no-SSL install, so a partial upload was fetchable by anything
on the network. The 32-hex id is unguessable and there is no listing,
so this was never urgent, but partials are the one thing in that tree
that was never meant to be fetchable.

Refuse any dot-leading path component instead of naming the staging
directory: uploaded assets are always <uuid>.<ext>, so nothing
legitimate is lost, and whatever lands there next is covered without
this list having to be kept in sync.

Not by relocating the staging dir to ~/.anthias, which was the
obvious fix and does not work: docker-compose.yml.tmpl bind-mounts
/data/.anthias and /data/anthias_assets separately, and rename(2)
across two mounts is EXDEV even when they share a filesystem.
Reproduced with those two mounts in a container:

    rename FAILED: [Errno 18] Cross-device link

The commit would have become a full copy of a multi-GB file onto an SD
card, needing twice the free space. Recorded next to STAGED_UPLOAD_DIR
so the next person does not try it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`res` was assigned on every iteration of a retry loop that always runs
at least once, so the `res === null` break was unreachable; and
needsChunking guarantees at least two chunks, the last of which
returns, so the trailing `{ kind: 'network' }` was too. It read like a
real fallback for "ran out of chunks without committing", which cannot
happen.

Both existed to satisfy the nullability of a `let res` declared
outside the retry loop. Lifting the loop into sendWithRetry, which
returns a response or keeps trying, removes the need for either. The
chunk loop then splits along the seam that was already there: the
staging chunks, which only add bytes, and the last one, which commits.
No isFinal threading through the body of the loop, and nothing left
after it for TypeScript to worry about.

Also names the request shape (UploadRequest) that the interface and
sendUpload each spelled out in full.

bun test 121 pass, tsc --noEmit clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test drove truncate by declaring `/40` on the first chunk and
`/10` on the second for the same upload id. The server accepts that —
total_bytes is never compared against what earlier chunks declared —
and the guards mean nothing corrupt comes of it, so it is not a bug.
But no real client shrinks a total mid-upload, and asserting on it
made "the declared size may change between chunks" a supported
behaviour someone later has to preserve.

Same line covered with a consistent total: a stale partial left under
the id, longer than the file now being sent, and one chunk declaring
its own file's real size. Still fails with the truncate removed —
b'1'*10 + b'0'*30 instead of b'1'*10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same facts, fewer lines — the prose had grown to where the reasoning
was harder to find, not easier. Nothing dropped: every constraint,
gotcha and cross-reference the comments carried is still there, said
shorter.

Two things beyond wording:

  * the sequential-only contract was written out in full twice, in the
    _stage_upload_chunk docstring and again above planChunks. The
    docstring keeps it; chunking.ts points at it.
  * two comments said an abandoned partial is "held for a day". It is
    held for STAGED_UPLOAD_MAX_AGE_MIN, which is an hour. Left over
    from an earlier version of the sweep.

Verified comment-only: the Python ASTs are identical with docstrings
excluded, and home.js builds byte-for-byte the same bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parseServerError and parseUploadId were the same eight lines with a
different key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ANTHIAS_UPLOAD_CHUNK_SIZE_MB existed only in settings.py and its test.
docker-compose.yml.tmpl lists anthias-server's environment explicitly
and Compose passes only what the file declares, so on the plain
docker-compose install — Raspberry Pi OS and x86, the install this PR
exists to fix — the container never saw the variable at all. Setting
it did nothing. Only balena worked, where the supervisor injects
device variables into every service regardless.

Declaring it in the template is half a fix: upgrade_containers.sh
regenerates docker-compose.yml from the template on every run and
passes -f explicitly, so an edit to the generated file or a
docker-compose.override.yml is reverted by the next upgrade. An
operator behind an nginx with client_max_body_size 8m would have fixed
their uploads, upgraded a month later, and had the 413s come back with
nothing connecting the two events.

So the script now also sources /etc/anthias/anthias.env before
envsubst, the same way it already sources /etc/anthias/proxy.env for
GH Screenly#3239. Unlike proxy.env it is not ansible-managed — it is where an
operator's own settings live, absent by default.

Documented in the reverse-proxy FAQ, next to the body-limit table it
belongs with, which is what the PR offered to do.

Both halves are tested, since neither is exercised by anything else:
removing the environment line or the sourcing fails the new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous round exempted the final chunk from being resent on a
dropped socket, and the PR body said flatly "the commit is no longer
resent". Only half true: `retryable` exempted `status === 0` and left
the 5xx arm alone, and behind a proxy the lost-commit case usually
arrives as a gateway 502 or 504, not a socket error. So the common
shape of it was still resent — twice — into the 409 that says the
upload cannot be resumed, for an upload that had worked.

Worse, that 409's careful wording never reached the operator anyway.
Only the staging loop read the server's JSON error; the commit went
straight to interpretFinalResponse, which reads the status and not the
body, so the operator saw "Upload failed — check the file and try
again". The one message written to stop a duplicate was thrown away on
the one chunk that can produce one, and the 507 story upload-error.ts
describes had the same hole.

The discriminator is whether the request body finished going out.
XMLHttpRequest already knows — `xhr.upload`'s load event — and it
separates the two cases that were being conflated: a connection cut
mid-body cannot have committed anything, so it is safe to resend and
is reported as the plain transport failure it is; a body that went out
with nothing intelligible coming back may have committed, so it is
never resent and says so.

The stub modelled none of this — it fired no upload events at all, so
every simulated failure looked like a mid-body cut. It now streams the
body first, which is also what let the mid-commit case be tested.

Also closes the Add modal on that outcome: "check the asset list"
is not actionable with the modal sitting on top of the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PR body claimed "both the browser and the server clamp it to
[1, 24]". The browser only ever had the ceiling. That matters twice:

  * a hand-edited meta tag below ~0.000001 MB floors to 0 bytes, and
    then needsChunking says to split the file while planChunks returns
    no chunks at all — `chunks[chunks.length - 1]` is undefined and
    uploadOne throws, silently, before a single request goes out.
  * the frontend tests drove the whole chunked path at 0.001 MB, a
    configuration resolve_upload_chunk_size_mb can no longer produce.
    The retry and commit policy — the part of this branch most worth
    getting right — was only ever exercised in a state no device can
    reach.

So the tests now run at 1 MB chunks over a 2.5 MB file, which is the
smallest a real device will hand the browser.

While there: the ranges test asserted three literal byte ranges that
follow from the chunk size, so it broke on that change without
anything being wrong. It now asserts what actually has to hold —
contiguous, starts at 0, ends at the last byte, one consistent total —
since a gap reads back as zeros and an off-by-one truncates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`int(raw)` rejected `8.5`, and the fallback handed the operator 16.
MB is a decimal quantity, so 8.5 is a plausible thing to write, and
the person writing it is doing so because their proxy caps bodies at
10 MB. Falling back to 16 gives them a value that cannot clear the cap
they were trying to fit under — every chunk 413s and the upload fails
exactly as it did before this branch, with a warning line they will
never see. Every other knob on the device fails toward "still works";
this one failed toward "still broken".

Parsed as a float and floored, so 8.5 gives 8. float() also absorbs
nan, inf and the thousand-digit integer that the digit bound in
_CONTENT_RANGE_RE exists to keep away from int(), so the unparseable
path is narrower than it was, not wider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The range, length and empty-file checks all raised before the `try`,
so the `except _ChunkedUploadError` that removes the partial only ever
fired for the 409 from the offset guard. A rejected chunk therefore
left everything staged so far — up to (n-1) × chunk_size of a file
nobody will finish — sitting on the card for an hour, invisible in the
UI, on a device where the whole reason for this feature is that the
card is small.

The client abandons an upload on any 4xx and starts over under a fresh
id, so those bytes are unreachable the moment they are refused. The id
is now resolved before anything that can fail, which is what lets the
partial be named and dropped; a malformed id is the one case with
nothing to clean up, since there is no name to derive.

Also corrects the 409's comment. It named a lost commit as the way to
reach it — the one path our own client now rules out. The reachable
causes are the sweep taking a partial after an idle hour, and a
third-party client that does resend a commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment claimed `.uploads/<id>.part` was "the one thing here never
meant to be fetchable". It is not. Two other kinds of transient file
already live in the asset dir:

  * `.import-<hex><ext>` and its `.part`, staged by the content
    importer at up to 5 GiB — dot-leading, so the new rule caught them
    by accident rather than design.
  * `<upload_id>.tmp`, staged by the REST API's own resumable uploads
    — not dot-leading, and still served to anything on the LAN.

Both predate this branch, but a comment asserting the directory is now
clean is worse than no comment: it tells the next reader not to look.
So the claim is gone, the list is written down, and `.tmp` is refused
alongside. Nothing durable is named that way — the celery sweep
already deletes stale `*.tmp` from this directory outright, which only
works because assets are always `<uuid>.<ext>`.

The staging test also hardcoded `.uploads` while the code read the
constant, so renaming STAGED_UPLOAD_DIR left it passing against a
directory nothing writes to while real partials went back to being
served. It builds the path from the constant now, and fails if the two
come apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_skip_staged_uploads pruned `.uploads/` and nothing else, so the two
staging files that already lived in the asset dir still went into
every backup and every streamed download: the REST API's
`<upload_id>.tmp`, and the content importer's `.import-<hex>` and its
`.part`, which allows 5 GiB. A backup taken mid-import carried the
whole thing — the exact failure the filter exists to prevent, one
directory over.

It also had no tests. Coverage counted the function as covered because
`tar.add` calls it on every member, which is the kind of green that
means nothing: removing `filter=` from both call sites left all ten
backup tests passing. Four cases now, one per staging file, each
asserting a real asset alongside still makes it in — and all four fail
without the filter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mickzijdel and others added 3 commits August 27, 2026 01:09
Each of these passed with the code deleted.

**The meta tag.** Removing the one line in helpers.py that puts the
chunk size into the template context left all 2028 Python tests green,
while every device fell back to the browser's own 16 MB default and
ignored whatever the operator configured. It is the only link carrying
the setting to the client, and now it is asserted end to end, the way
the app-store index next to it already was.

**The two sets of bounds.** chunking.ts cannot import a Python
constant, so [1, 24, 16] is written out twice with nothing tying the
copies together. Changing the server's ceiling to 20 left everything
green — and silently resurrects the bug the server-side clamp was
added to prevent, since a stale browser ceiling overrides the server's
answer. A test reads the three literals out of chunking.ts and
compares them.

**The rejection rule.** The server refuses some files with 200 plus an
error toast rather than a status code, and this branch went to some
trouble to keep the chunked path treating that as a refusal. No test
supplied an HX-Trigger header at all — the stub returned null for
every header — so `kind === 'error' ? 'rejected' : 'ok'` could be
replaced with `'ok'` and stay green, closing the modal and firing a
table refresh for an asset that does not exist.

Also stops setChunkSizeMb assigning over document.head. It wiped the
date-format metas home.ts reads and, since bun runs every file in one
process, leaked the chunk size into later tests. Three batch tests
passed only because their fixture file happened to be one byte long:
raising it to 2000 broke them before this change and does not now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"The server mints nothing now" — it does. views.py still mints an id
for any chunk that arrives without the header; what changed is that
our own client stopped relying on it.

"The client echoes the id" — it mints its own. The distinction matters
where that comment sits, on the regex that has to survive whatever a
third-party caller sends.

And screenly_migration's cross-reference to "the same guard as
views_files.anthias_assets" stopped being the same guard when that
view gained rules about transient staging files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cleanup_asset_dir` emptied `settings['assetdir']` on teardown. That is
one directory — ~/anthias_assets — shared by every xdist worker, so
under the `pytest -n auto` CI runs one worker's teardown deleted files
another worker was still mid-request on. It surfaced once here as
test_file_asset_upload_id_ignored_without_content_range failing in a
full run and passing in isolation, and three consecutive full runs
afterwards were clean, which is what a race looks like.

It also emptied the directory on a developer's own machine. Verified:
a sentinel file placed in ~/anthias_assets does not survive running
this one test file. That is the hazard tests/test_backup_helper.py's
fixture already goes out of its way to avoid, quoting the same reason.

Each test now gets its own asset dir under tmp_path, which pytest
cleans up, so there is nothing to empty and nothing shared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants