Add chunked, resumable dashcam upload to work around Cloudflare's 100MB body limit
Confirmed against the real deployed server: Cloudflare Tunnel's free tier caps request bodies at ~100MB and returns a plain 413 - a direct LAN upload of the same 250MB test file succeeds, the identical request through yaris.ceaf.dev does not. Real dashcam clips average ~320MB, so no dashcam video could ever complete an upload through the tunnel as it stood, regardless of the earlier memory-streaming fix. Server: new /api/v1/dashcam/upload/chunk (accepts one chunk at a time, keyed by relative_path+sha256+chunk_index/total_chunks, reassembles and verifies the sha256 once every chunk has arrived) and /api/v1/dashcam/upload/status (reports which chunk indices a given upload already has, or "already_uploaded" if it's done) endpoints. Chunks are staged under STORAGE_DIR/.upload_tmp/<device>/<sha256>/ and cleaned up after reassembly; a stale partial set left by a differently -chunked earlier attempt is detected via a small marker file and wiped before restarting. The original single-shot /upload endpoint is left in place unchanged. Client (modules/app_upload.py): _upload_dashcam_files() now calls _upload_video_chunked(), which checks /status first (so a prior partial upload resumes instead of restarting from byte 0) and then POSTs any missing 40MB chunks (DASHCAM_CHUNK_SIZE, comfortably under Cloudflare's cap) via the same streaming MultipartEncoder approach as before. Co-authored-by: Copilot <[email protected]>
This commit is contained in:
+68
-23
@@ -20,6 +20,16 @@ they simply wait for connectivity to return, same as before.
|
||||
*** default (YARIS_CELLULAR_ENABLED unset) so this has zero effect on the
|
||||
*** already-verified WiFi upload path unless explicitly configured.
|
||||
|
||||
Dashcam files are sent in DASHCAM_CHUNK_SIZE pieces via
|
||||
/api/v1/dashcam/upload/chunk rather than one request per file - confirmed
|
||||
against the real deployed server that Cloudflare Tunnel's free-tier ~100MB
|
||||
request-body limit otherwise rejects real dashcam clips (300+MB) outright
|
||||
with a 413, and that memory-safe streaming alone (MultipartEncoder) doesn't
|
||||
help there since the *proxy*, not this app, is what enforces the cap.
|
||||
Chunking also makes uploads resumable: a /status check before sending finds
|
||||
out which chunks the server already has, so an interrupted transfer picks
|
||||
back up instead of restarting from byte 0.
|
||||
|
||||
Credentials/server URL are loaded from db/upload_creds.env (gitignored,
|
||||
never commit real values - mirrors the db/creds.env pattern already used by
|
||||
app_db.py for MongoDB).
|
||||
@@ -48,6 +58,13 @@ TRACKING_BATCH_SIZE = 500
|
||||
BACKOFF_INITIAL = 5 # seconds, doubles on each consecutive failure
|
||||
BACKOFF_MAX = 300
|
||||
|
||||
# Real dashcam clips run 300+MB - Cloudflare Tunnel's free-tier ~100MB max
|
||||
# request body size rejects those outright with a 413 (confirmed against
|
||||
# the real tunnel: a 250MB file uploads fine direct-LAN but 413s through
|
||||
# yaris.ceaf.dev). Files are split into chunks this size and sent one at a
|
||||
# time via /api/v1/dashcam/upload/chunk instead, comfortably under that cap.
|
||||
DASHCAM_CHUNK_SIZE = 40 * 1024 * 1024
|
||||
|
||||
DASHCAM_UPLOAD_SETTING_KEY = "dashcam_upload_enabled"
|
||||
|
||||
_upload_thread = None
|
||||
@@ -161,6 +178,56 @@ def _upload_tracking_batch():
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _upload_video_chunked(abs_path, rel_path, sha256):
|
||||
"""Upload one dashcam video file in DASHCAM_CHUNK_SIZE pieces, resuming
|
||||
from whatever the server already has for this exact (path, sha256)
|
||||
combo. Raises on any request-level failure - the caller's normal
|
||||
backlog/backoff handling takes it from there; a retry will simply
|
||||
re-query /status and resume from the first missing chunk instead of
|
||||
starting over.
|
||||
"""
|
||||
status_resp = requests.get(
|
||||
f"{SERVER_URL}/api/v1/dashcam/upload/status",
|
||||
params={"relative_path": rel_path, "sha256": sha256},
|
||||
headers=_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
status_resp.raise_for_status()
|
||||
status = status_resp.json()
|
||||
if status.get("status") == "already_uploaded":
|
||||
return
|
||||
|
||||
already_received = set(status.get("received_chunk_indices", []))
|
||||
|
||||
size = os.path.getsize(abs_path)
|
||||
total_chunks = max(1, -(-size // DASHCAM_CHUNK_SIZE)) # ceil division
|
||||
|
||||
with open(abs_path, "rb") as f:
|
||||
for idx in range(total_chunks):
|
||||
if idx in already_received:
|
||||
continue
|
||||
|
||||
f.seek(idx * DASHCAM_CHUNK_SIZE)
|
||||
chunk_bytes = f.read(DASHCAM_CHUNK_SIZE)
|
||||
|
||||
encoder = MultipartEncoder(
|
||||
fields={
|
||||
"relative_path": rel_path,
|
||||
"sha256": sha256,
|
||||
"chunk_index": str(idx),
|
||||
"total_chunks": str(total_chunks),
|
||||
"file": (f"chunk_{idx:06d}", chunk_bytes, "application/octet-stream"),
|
||||
}
|
||||
)
|
||||
resp = requests.post(
|
||||
f"{SERVER_URL}/api/v1/dashcam/upload/chunk",
|
||||
data=encoder,
|
||||
headers={**_headers(), "Content-Type": encoder.content_type},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _upload_dashcam_files():
|
||||
"""Upload any not-yet-uploaded dashcam files, one at a time. Returns count sent.
|
||||
|
||||
@@ -176,29 +243,7 @@ def _upload_dashcam_files():
|
||||
if app_sql.dashcam_file_marked_uploaded(rel_path, sha256):
|
||||
continue
|
||||
|
||||
# Stream the multipart body via MultipartEncoder instead of requests'
|
||||
# plain files=/data= kwargs. requests builds a non-streamed files=
|
||||
# upload by reading the *entire* file into memory up front to
|
||||
# assemble the multipart body - fine for small files, but real
|
||||
# dashcam clips run 300+MB each, and on a ~1GB-RAM Pi 3 that alone is
|
||||
# enough to trigger the OOM killer (confirmed against real hardware:
|
||||
# RSS spiked in lockstep with dashcam file size). MultipartEncoder
|
||||
# reads the file in small chunks as the request is sent instead.
|
||||
with open(abs_path, "rb") as f:
|
||||
encoder = MultipartEncoder(
|
||||
fields={
|
||||
"relative_path": rel_path,
|
||||
"sha256": sha256,
|
||||
"file": (os.path.basename(abs_path), f, "application/octet-stream"),
|
||||
}
|
||||
)
|
||||
resp = requests.post(
|
||||
f"{SERVER_URL}/api/v1/dashcam/upload",
|
||||
data=encoder,
|
||||
headers={**_headers(), "Content-Type": encoder.content_type},
|
||||
timeout=300,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_upload_video_chunked(abs_path, rel_path, sha256)
|
||||
app_sql.mark_dashcam_file_uploaded(rel_path, sha256)
|
||||
uploaded_count += 1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user