Repository navigation
Infra: send video.ready once HLS is ready - #4200
Merged
Merged
Conversation
added 3 commits
November 13, 2026 06:27
video.ready fired on the status change to ready, which happens when the first output (usually the MP4) finishes, so every event carried hlsUrl null. The event is now decided inside the locked status write. A video that becomes ready with an output still encoding gets a readyEventDueAt mark in its processing state, and the write that settles the last output clears it and sends the event. A video already announced, or ready before this change, never carries the mark and sends nothing more. The processing workflow's poll sends it after 30 minutes if an output never settles.
Unknown or missing events get hand-written messages instead of zod's wording, and errors with a webhook code link to the webhooks guide.
The webhooks guide says video.ready waits for every playback URL, adds a runnable node:http receiver (tested), uses 2 space indents, and says a second DELETE returns 200. Get Started links webhooks from step 4, points look and behaviour at the embed options, and the playlist start sentence states the rule from #4097 (720p on most videos).
Member
Author
|
@greptileai please review |
added 3 commits
November 13, 2026 06:34
A failed send on the final poll reported both parts done, so the workflow ended with the event unsent. Finished parts now read as waiting, and the next poll tries again. The hold-up tests now finish HLS so they reach dispatch.
String() on a caller's object can throw, which turned a bad event into a 500. A value that is not a string is no longer quoted.
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Member
Author
|
@greptileai please review again |
|
Sandchest reviewed Sandchest reviews every change for vulnerabilities an attacker could use · Review details |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What
video.readynow fires once the video is ready and every output has settled (complete, failed, or unavailable on the media server), so its body carries every playback URL that exists.hlsUrlis set unless HLS failed. Before this, the event fired on the status change toready, which happens when the first output (usually the MP4) finishes, so everyvideo.readycarriedhlsUrl: null, and retries re-sent that stale body.Also in this PR (docs and error wording, no behaviour change beyond the messages):
node:httpreceiver (run by a test), the Node example in 2-space indent, and a note that a secondDELETEof an endpoint returns200.docspoints at/docs/api/webhooksfor webhook error codes; unknown and missingeventsget hand-written messages.How
The decision is made in
mutateDeveloperVideo, inside the transaction that holds the video's row lock, by the puredeveloperVideoEventStep(previous, next, now):ready, all outputs settledvideo.readynowready, an output still encodingprocessing.readyEventDueAt = nowis written with the changevideo.ready, and the mark is removed in the same writevideo.readywith the URLs that exist, mark removederroredvideo.errored, as beforeBecause every writer takes the row lock, exactly one write can remove the mark.
enqueueDeveloperVideoEventbuilds the body from the committed row after that transaction ends, and also refusesvideo.readywhile the committed row still carries the mark, so a rolled-back write sends nothing. The body is built when the event fires (now after HLS), and the delivery workflow re-sends that same body and event id on every retry.Stuck HLS backstop. The processing workflow keeps polling a ready video while any output is pending or running (every 20 s at most).
readDeveloperVideoParts, which each poll calls, now sends a duevideo.ready. The bound:video.readyarrives at most 30 minutes plus one poll (20 s) after the video became ready, withhlsUrl: nullif HLS is still encoding. Independently, the workflow's existing 3 h deadline and its crash handler both mark unfinished outputs failed, which settles them and fires the event. The final poll also sends a marked video whose last output was settled by a write that did not send (for example by the previous deployment during rollout). A failure there is logged and retried on the next poll, never thrown, so it cannot fail the video.Videos ready before deploy. Old code fired at the status change and never wrote the mark. A ready video without the mark never fires again, whatever update arrives later. That covers videos announced by old code and videos that became ready before webhooks existed.
The mark lives in the
processingJSON column. No migration. REST status and Get Video are unchanged: a polling client can still readreadywithhlsUrl: null. Regular Cap videos are not touched: this is only the developer video path.Proof
Each condition the launch coordinator set, with its test in
developer-webhook-dispatch.test.ts(real processing code over the fake row-locked database):hlsUrlnull, within the boundis sent once, with hlsUrl null, when HLS never settles: no event at 29:59 after ready, one at 30:00 withhlsUrl: null, none when HLS completes lateris sent with hlsUrl null when processing times out on a stuck HLS jobwaits for HLS when the MP4 makes the video ready firstwaits for the MP4 when HLS makes the video ready firstqueues one event carrying both URLs when both outputs finish at once(concurrent) andqueues one event when both outputs settle in one updatequeues nothing more when the encoder's callbacks arrive againre-sends the same event id and body on every retry: runs the real delivery workflow through retry, retry, delivered; all three attempts get the identical job withhlsUrlsetis sent with hlsUrl null when HLS failssends no second event for a video that was ready before this change: HLS completes, then a poll 60 minutes later; no endpoint lookup, no eventsends no event for a video that was ready before webhooks existed: unavailable HLS is retried and completes; no eventis sent on the next poll when the last output was settled without sendingis not sent while the committed row still carries the markvideo.erroredtests, plus the pure-step testsMutation check: with the poll backstop removed, the never-settles test fails; with the mark check removed, both pre-deploy tests fail; with the old fire-at-flip rule restored, 8 tests fail; with the committed-mark guard removed, the rollback test fails.
Docs:
developer-webhook-docs.test.tsnow extracts the receiver from the guide, runs it withnode, and checks that a forged signature gets400, Cap's signature gets200, and a duplicate event id is handled once. It also checks the guide and OpenAPI state the 30-minute bound from the constant, and that no code block uses tab indents.Review follow-ups: a failed send on the workflow's final poll now reports finished parts as waiting so the workflow polls again (
keeps the workflow polling when sending a due event fails); the docs receiver refuses bodies over 1 MB with413(tested with a 2 MB POST); a non-string event no longer reachesString()in the error message (tested with{"toString": null},400).Production baseline before this change, using the proof script that will run after merge (one endpoint behind a tunnel, one 12 s 1080p upload, Get Video Status polled every second): the video read
readywithhlsUrlnull,video.readyarrived withhlsUrl: null, andhlsUrlappeared on Get Video Status 0.6 s after the event. After merge the same script requires onevideo.readywithhlsUrlset and no second event.Tests
developer-*,deliver-developer-*,process-developer-*, OpenAPI, docs, API contracts,infra-*, media-server progress callback, media processing workflows): 140 files, all pass.apps/webunit suite: 9,519 passed vs 9,499 on main (+20 new), 7 failed on both, all inshare-stage-identity.test.ts, which this PR does not touch.tsc --noEmitonapps/web: no new errors (one existing error inorganization-settings-ownership.test.ts, untouched).workflow build --target standalone: builds; the flow bundle contains nodrizzle-ormor@cap/databaseruntime.Production proof after deploy
Run on production after the merge commit deployed, with the same script as the baseline (real endpoint on a tunnel, one upload through the documented flow):
video.readyhlsUrlset in the eventmp4Urlsetvideo.erroredTimings: upload complete 36.93 s, status
readywithhlsUrlnull 38.21 s,video.readyreceived withhlsUrlset 40.34 s. No second event in the following 30 s. The endpoint and video were deleted after the run.