Skip to content

fix(recording): upload each recording once, including failed and orphaned sessions - #359

Open
wagenet wants to merge 16 commits into
Eyevinn:mainfrom
wagenet:fix-recording-reupload
Open

wagenet wants to merge 16 commits into
Eyevinn:mainfrom
wagenet:fix-recording-reupload

Conversation

@wagenet

@wagenet wagenet commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Opened by Claude (Claude Code) on behalf of @wagenet.

Based on main; merges independently of #358. Merging #358 afterwards conflicts only in one hunk of src/__tests__/recording-wiring.test.ts (the pad name and filename_prefix assertions next to this PR's output_dir assertions); a scratch merge resolved it and passed typecheck and 1097 tests.

Deactivate uploads a production's recordings, then deletes them from Strom (#378). A file left behind, because its delete failed or was skipped when the production changed during the upload, was uploaded and registered again at the next deactivate, so the recordings list got duplicates. With this change each recording is uploaded and listed once. A session whose upload failed, or that ended without an explicit deactivate (idle timeout, lost flow), is uploaded at the production's next deactivate and dated by its own activation.

Live check against Strom upstream/main b28be77b with #358 underneath (it supplies the recorder's AAC audio), run before the rebase onto #378 and before this branch moved onto main, not repeated since. Two activations of one production, each deactivated through the real routes, gave 3 files, 3 uploads and 3 RecordingDocs with no duplicates, all H.264 High + AAC-LC 48 kHz stereo.

How to review

Five commits, in order:

  • 2de79b6 fix(recording): upload only the ending activation's recordings
  • eae26c9 fix(recording): sweep every activation's recordings at deactivate
  • 38cc1d9 fix(recording): stop the production sweep at the first auth rejection
  • d8ffaf2 fix(recording): upload a registered file again if its size changed
  • 7d5f32a fix(recording): keep the file's end time when refreshing a registered recording
Cause

The recorder's output_dir was recordings/<production id> for every activation. Deactivate uploaded everything in it, nothing deleted the files, and each upload inserted a RecordingDoc with a fresh random id.

Fix
  • One directory per activation: recordings/<production id>/<activation start>-<uuid>, saved on the production as recorderOutputDir.

  • Deactivate sweeps every activation directory and skips files whose RecordingDoc exists. The id is now derived from bucket and key, so the check is one get, and concurrent deactivates conflict instead of duplicating. startedAt comes from the directory name, endedAt from the file's last write. No directory on Strom (404) means nothing to upload.

  • Legacy productions (recorder but no recorderOutputDir) also upload files directly in recordings/<production id>. Other productions skip them, because older sessions there are registered under random ids and would be registered again.

  • Stale recorder fields: the idle watchdog, startup reconcile and activation-failure reset now clear recorderBlockId and recorderOutputDir, and activation always writes both. Before, a later activation without a recorder kept the old block id, and its deactivate split it and registered the old session with the wrong start time.

  • MediaEntry matches Strom (is_directory, modified in epoch seconds). The uploader was checking is_dir, so it tried to download directories as files.

  • A registered file is skipped only if its size matches Strom's. A file registered while the recorder was still writing it left a truncated object; if its delete then failed, the next sweep would have skipped the complete file and deleted it. A size mismatch now uploads it again and updates the RecordingDoc's size and end time.

  • Deleting from Strom (feat(recording): delete recordings from Strom after successful upload #378) covers the whole sweep. When the isStillRecording guard passes, deactivate deletes every swept file that is in object storage, including files skipped as already registered, so a file whose delete failed is removed at the next deactivate. Each activation directory that ends up empty is removed.

  • An object-store auth rejection stops the whole sweep. fix(recording): fail fast on object-store auth errors in deactivate upload sweep #473 stops uploadFiles at the first S3AuthError. The production sweep now also skips the remaining activation directories and the legacy directory, so a rejected store costs one download, not one per directory. Files already in object storage are still deleted from Strom, as on main, and deactivate still logs the abort.

S3 keys are unchanged, so listing and playback need no changes.

Live check

Strom upstream/main b28be77b (0.6.10, has builtin.audioenc). A scratch vitest (not committed) drove the real POST /activate and POST /deactivate routes, the real flow generator and StromClient, with a __test1__ source and a recording output. CouchDB was in memory. MinIO was stubbed: signed PUTs (SigV4 Authorization present) were captured and their bodies written to disk.

Deactivate Uploaded RecordingDocs inserted
1 activation 1 …_100511_00000.mp4 1
2 activation 1 …_100511_00001.mp4, activation 2 …_100522_00000.mp4 2, the first dated by activation 1

Activation 1's _00001 is the segment the split opens. The split is asynchronous and lands after deactivate 1 lists the directory, so deactivate 1 misses it. The sweep picks it up at deactivate 2. It holds the frames between the split and the flow stop. Nothing was uploaded twice.

Reviewer notes
  • With recording storage configured, every deactivate lists the production's recordings directory on Strom, even without a recorder this activation, plus one CouchDB get per file.
  • The watchdog and reconcile still don't upload; their sessions go up at the next explicit deactivate. The same applies to the tail segment after each deactivate's split (see Live check).
  • Deactivate uploads and deletes before it stops the flow, as on main. If the split's new tail segment were already on disk when the sweep lists the directory, the sweep would upload it part-written and then delete it while the recorder is still writing it. In the live run the split landed after the listing.
  • Files are deleted after upload and before their RecordingDoc is written, as in feat(recording): delete recordings from Strom after successful upload #378. If that write fails, the object is in the bucket but not listed, and the file is gone from Strom.
  • Known limits, none of them present on main:
    • Object keys and RecordingDoc ids ignore the activation directory. Two activations holding a same-named file (Strom names files by wall-clock second, so this needs two recorder starts in one second, such as a DST fall-back) collide, and the later file's bytes can be lost.
    • A RecordingDoc is trusted without checking the object is still in the bucket. If the object was wiped, the Strom copy is deleted without re-upload.
    • On a Strom whose media folder has no recordings folder yet, it answers 400 rather than 404 to the production listing, so every deactivate logs an upload error. Deactivate still succeeds.
    • If the RecordingDoc update after a re-upload fails, the listing keeps the old size although the object is complete; it is corrected at the next sweep only if the Strom file is still there.
  • Each uploaded MP4 carries about 95 MB of free boxes that the recorder's muxer reserves, whatever the recording's length. The uploader reads each file fully into memory.

Testing

  • recording-reupload.test.ts (new) drives the real deactivate and activate routes against a stateful fake CouchDB and Strom media store. production-lifecycle.test.ts checks that the watchdog and reconcile clear the fields. recording-wiring.test.ts asserts the per-activation output_dir and that a second activation gets a different one.
  • Each commit: pnpm typecheck passes; pnpm test passes 1077, 1085, 1086, 1088 and 1088 tests.
  • Reverts on this branch, each failing at least one test:
Reverted Failing
flow generator's per-activation output_dir recording-wiring
sweep skips already-registered files 3 in recording-reupload (skip registered, legacy directory, delete registered)
auth abort: break between activation directories 1 in recording-reupload
auth abort: skip of the legacy directory the same test
watchdog and reconcile clearing the recorder fields 2 in production-lifecycle
size comparison, or the RecordingDoc update, for a file that grew 1 in recording-reupload each

The 404 handling is the exception: reverting it only adds an error log.

🤖 Generated with Claude Code

@wagenet wagenet changed the title fix(recording): upload only the ending activation's recordings fix(recording): upload each recording once, including failed and orphaned sessions Sep 28, 2026
@birme

birme commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

This PR looks like the top of a two-PR dependency stack (depends on #358):

Holding off on automated review/merge here — this needs a human (or the relevant dev agent) to assess the whole stack together, not a per-PR pass, and both PRs are drafts. Nothing further is needed from the author; this comment exists so the dependency is recorded on the PR rather than in a run log.

@birme

birme commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Heads up: main has moved (several recording/ws fixes merged), and this branch now reports a real
merge conflict with main (mergeable: CONFLICTING) — not mere staleness, so an automated
branch refresh won't resolve it. Please merge current main into this branch (or rebase) and
resolve the conflicting files, then push. This PR is otherwise still held for holistic review as
part of its dependency stack (see the earlier stack note); this comment only records the new
conflict so it isn't lost.

@wagenet
wagenet force-pushed the fix-recording-reupload branch 2 times, most recently from 53f367f to 5a7d90f Compare September 29, 2026 13:56
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 1, 2026
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 1, 2026
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 2, 2026
Resolutions:
- productions.ts: keep Eyevinn#359's recording-doc helpers; main moved the
  guest invite sweep to services/guest-sweep.ts. Activation sets
  activationSucceeded before main's controller re-init.
- production-lifecycle.test.ts: keep both sets of tests.
- flow-generator-encoders.test.ts: Strom mock lists builtin.audioenc for
  Eyevinn#358's block check, and the recorder is fed on video_in_0 (Eyevinn#358).
- record-inputs-lifecycle.test.ts: mock main's reinitConnectedControllers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 2, 2026
Brings in main through Eyevinn#359. productions.ts: activation sets
activationSucceeded before main's controller re-init.
flow-generator-encoders.test.ts: the Strom mock lists builtin.audioenc for
the block check. record-inputs-lifecycle.test.ts: mock main's
reinitConnectedControllers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wagenet added a commit to wagenet/open-live that referenced this pull request Oct 2, 2026
Brings in main through Eyevinn#359. productions.ts: activation sets
activationSucceeded before main's controller re-init.
flow-generator-encoders.test.ts: the Strom mock lists builtin.audioenc for
the block check. record-inputs-lifecycle.test.ts: mock main's
reinitConnectedControllers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wagenet
wagenet force-pushed the fix-recording-reupload branch 2 times, most recently from 9d1924c to 0016343 Compare October 7, 2026 14:03
@wagenet

wagenet commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Claude (Claude Code), for @wagenet.

Pushed 0016343. Once #473's abortedOnAuthError is set, the production sweep now skips the remaining activation directories and the legacy directory, so a rejected store costs one download and one failed PUT in total instead of one per directory. Files already in object storage are still deleted from Strom, as on main, and deactivate's abort log is unchanged.

New test in recording-reupload.test.ts: it fails without the fix (6 fetches, not 2), and also fails with either half of the fix reverted (4 fetches). pnpm typecheck and pnpm test (1066) pass.

@wagenet

wagenet commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Claude (Claude Code), for @wagenet.

Landing order agreed with @wagenet: #404 and #407 merge first, unchanged. This PR then rebases onto main and resolves their conflicts as tested locally on the three-way merge (typecheck passes, 1079 tests pass): the deactivate condition becomes doc.recorderBlockId || isObjectStorageConfigured(), dates fall back activationStartedAt → startedAt → doc.updatedAt, two tests are adapted to the production sweep, and #407's spec section is updated for per-activation directories. Details are in the comments on #404 and #407.

wagenet and others added 3 commits October 9, 2026 09:40
The recorder's output_dir was recordings/<production id> for every
activation, and deactivate uploads everything in it. A file left there
after its upload (its delete from Strom failed, or was skipped because
the production changed mid-upload) was uploaded again at the next
deactivate and registered as a new RecordingDoc.

Give each activation its own subdirectory, store it on the production at
activate, and read it at deactivate (falling back to the shared directory
for productions activated before this change). Derive RecordingDoc ids
from the object key so a retried deactivate conflicts instead of
registering the same object twice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A per-activation directory is forgotten once its deactivate clears
recorderOutputDir, so a session whose upload or listing failed was never
uploaded. Deactivate now lists every activation directory under
recordings/<production id> and uploads each file whose RecordingDoc does
not exist yet. The directory name carries the activation start, which
becomes the RecordingDoc's startedAt; endedAt is the file's last write.

The idle watchdog, startup reconcile and activation-failure reset now
clear recorderBlockId and recorderOutputDir, and activation always writes
both. Before, a later activation without a recorder kept the stale block
id, and its deactivate split and uploaded the earlier session.

The sweep keeps delete-after-upload: once the isStillRecording guard
passes, it deletes each file that is in object storage, including files
skipped as already registered, and removes each directory that leaves
empty.

Strom's media listing marks directories with is_directory, not is_dir.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Once uploadFiles sets abortedOnAuthError, uploadProductionRecordings skips
the remaining activation directories and the legacy shared directory: the
store would reject them the same way, each after a full download from Strom.
Files already in object storage are still deleted from Strom, as on main.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@wagenet
wagenet force-pushed the fix-recording-reupload branch from f9bef78 to 38cc1d9 Compare October 9, 2026 16:43
@wagenet

wagenet commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Claude (Claude Code), for @wagenet.

Unstacked from #358: the branch is now origin/main plus this PR's three commits, so it merges independently. Dropped the earlier copy of #358's commits and two test-only commits (4294f04, a1b1315) that only touched #358's warnings field, which main doesn't have. recording-wiring.test.ts now asserts main's video_in pad. pnpm typecheck and pnpm test pass on each commit (1077, 1085, 1086). Merging #358 on top conflicts in one hunk of recording-wiring.test.ts.

The sweep skipped any file whose RecordingDoc existed. A file registered
while the recorder was still writing it left a truncated object, and if
its delete from Strom then failed, the next sweep skipped the complete
file and deleted it. Compare the registered size with the size Strom
lists, upload again on a mismatch, and update the existing RecordingDoc's
size and end time instead of leaving the stale ones.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@wagenet

wagenet commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Claude (Claude Code), for @wagenet.

Pushed d8ffaf2 after a review pass. A file registered while the recorder was still writing it left a truncated object, and if its delete from Strom then failed, the next sweep skipped the complete file and deleted it. A registered file is now skipped only when its size matches Strom's; otherwise it is uploaded again and its RecordingDoc is updated. The PR body now has the current commit hashes and lists three known limits that main does not have.

… recording

A re-uploaded file's RecordingDoc was refreshed with the deactivate time as
its end time, where every other RecordingDoc takes the file's last write.
Use the file's last write, falling back to the deactivate time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@wagenet

wagenet commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Claude (Claude Code), for @wagenet.

Pushed a one-line follow-up: a re-uploaded file's RecordingDoc now keeps the file's last-write time as its end time instead of the deactivate time, with an assertion in the existing test. The PR body has the new commit list, the 1097-test count for a merge with #358, and one more known limit.

wagenet added a commit to wagenet/open-live that referenced this pull request Oct 9, 2026
@birme

birme commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

This PR is part of wagenet's multi-PR recording feature line on open-live. Holding off on automated review/merge — this needs a human (or the relevant dev agent) to assess the whole set together, not a per-PR pass, because these touch the same recorder / deactivate-upload code and are merge-order-sensitive (e.g. #358 and #359 conflict in one test hunk).

Members, in dependency order:

Nothing further is needed from the author; this comment records the dependency on the PRs themselves rather than in a run log.

@birme birme 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.

pr-reviewer — APPROVE. Read-only review of 063c1b7.

Prior findings

# Source Finding Status
1 stack-flag (birme, 2026-09-28 / 2026-10-12) Hold for holistic stack review DROPPED — procedural hold, not a code finding; stack since unstacked, this PR merges independently
2 branch-blocked reason:conflict (birme, 2026-09-29) Real merge conflict with main FIXED — branch rebased/merged; a scratch merge onto current main applies cleanly here
— No gh ... /reviews or /comments entries — none

No OPEN findings. No Closes/Fixes #N directive in the body, so nothing auto-closes.

Verified against real source (head)

  • Strom MediaEntry fields are real, not fabricated. Eyevinn/strom types/src/api.rs defines MediaFileEntry { is_directory: bool, modified: u64 /* UNIX epoch seconds */ }. The PR's is_dir→is_directory and modified: string→modified: number (consumed as entry.modified * 1000 ms) match it. The old is_dir was genuinely wrong (directories were never filtered out).
  • "Upload each recording once" holds. RecordingDoc._id is now deterministic (recording-<sha256(bucket/key)[:32]>). The sweep skips a key whose doc exists and whose sizeBytes matches (isRecordingRegistered); a size mismatch (file still being written when first registered) re-uploads and refreshRegisteredRecording updates size+end time; concurrent/duplicate inserts hit a CouchDB conflict and are folded into the refresh path rather than inserting a second doc. Object keys are unchanged, so re-upload overwrites rather than duplicates.
  • Failed + orphaned sessions are swept at the next deactivate: uploadProductionRecordings lists every activation directory under recordings/<prod>, 404 ⇒ nothing to upload, dates each file by its activation-dir name (startedAt) and Strom modified (endedAt). The deactivate guard no longer requires recorderBlockId, so an idle-watchdog / reconcile / lost-flow session still gets uploaded.
  • No double/missed-upload: delete-from-Strom (#378) covers uploaded and already-registered files so a leftover whose delete failed is removed next time; failed uploads are never deleted and retry; isStillRecording guard leaves everything in place on reactivation; S3AuthError aborts the whole production sweep (one wasted download, not one per dir). Recorder fields are now cleared by watchdog, reconcile and activation-failure reset, and always rewritten on activate.
  • CI build-and-test = pass (covers typecheck + build + vitest; repo has no lint script). Co-author trailers present on all 5 commits; no secrets.

Tests

recording-reupload.test.ts covers the load-bearing cases: skip-already-registered, size-match skip, size-grew re-upload+relist, failed earlier activation picked up and dated by its own start, orphaned/legacy leftover with no current recorder, retried deactivate uploads nothing again, delete-including-registered, still-recording guard, cross-directory auth abort, never-recorded 404. production-lifecycle.test.ts pins watchdog+reconcile field clearing. recording-wiring.test.ts pins per-activation output_dir.

Non-blocking notes

  • recording-wiring.test.ts lines ~326–376 still use is_dir: false fixtures; they pass only because !e.is_directory is !undefined → truthy. Harmless (all are files), but the fixtures no longer match the real MediaEntry shape — worth aligning.
  • Transitional-only: a legacy shared-dir file previously registered under the old random-uuid id and whose Strom delete failed will be re-registered under the new deterministic id (one duplicate) on the first new-code deactivate. Strictly better than main (which duplicated on every deactivate) and within the author's documented known-limits. Not blocking.

Merge-ordering

main moved ahead at 14:45 today with #180 (748d53b, "take a preview PiP to program from a macro"), after this branch's last main-merge (12:50). So git diff main..HEAD shows #180's controller.ts / ws-macro-pip.test.ts as "removed" — this is staleness, not a mismerge: #180 is not an ancestor of the head, and merging current main restores that code cleanly. This PR's own 5 commits touch only recording code.

#358 / #359 shared test file: both edit src/__tests__/recording-wiring.test.ts. This PR asserts main's video_in pad + per-activation output_dir; #358 changes the pad name and filename_prefix in the adjacent hunk. Merging #358 after this PR conflicts in that one hunk and must be resolved by hand — land them in a known order.

Safe to merge standalone on current main.

@birme

birme commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

@wagenet #358 and #407 are now merged into main, so this PR is the next one in the recording cluster. It currently conflicts with main. Could you rebase it?

Per your earlier note on #407, the resolution should be:

  • startedAt: seg.activationStartedAt ?? seg.startedAt ?? doc.updatedAt
  • endedAt: seg.modifiedAt ?? finalizedAt

Please also:

  • Update recording-leftover-sweep.test.ts with the is_directory entries and the recordings-db get mock.
  • Update the "Segments left on Strom" section in docs/specs/vod-recording-minio.md and the RecordingDoc field comments to match.

The PR description still says "merges independently of #358" and "based on main". Please refresh it to say this builds on the merged #358.

Once it's rebased and CI is green, I'll review the full diff. #404 is waiting on the storage-target decision in #534.

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