Skip to content

One trip through the whole system

The fastest way to understand this codebase is to follow a single action all the way down. This is what happens between somebody tapping Publish to the circle (Publicar en el círculo on a phone set to Spanish) and their friends being able to watch, touching seven layers on the way. Terms in bold are in the glossary.

Everything before this point — the draw, the recording, the edit — is covered where it happens below.

0. Before the button exists

By the time anyone can publish, three things have already happened.

The draw. A scheduler tick found a circle whose turn had ended, or one that had never had a turn at all, and picked a vlogger (app/services/turns.py::tick → app/services/draw.py). The weighting favours whoever has had fewest turns and excludes the previous vlogger unless they are the only candidate. The circle gets a push notification.

The recording. app/record/[turnId].tsx. Each take is written to the phone's sandbox and appended to the manifest (src/storage/clipVault.ts). Nothing is uploaded and nothing touches the server, so somebody can record on Monday and finish on Wednesday. Flipping the camera closes one segment and opens another inside the same take.

The edit. src/edit/ builds an EDL from the manifest, chooses an engine (engine.ts), and renders. On Android that is the native module: decode → OpenGL → re-encode, one encoder for the whole timeline. The result is one mp4 in the app's sandbox, and the review screen plays it.

1. The tap

app/review/[turnId].tsx → publishVlog() in src/edit/publish.ts.

The publish button deliberately sits above the editing controls. The premise of the product is that nobody wants to edit, so the default path has to be the shortest one; reordering and deleting are there for when something is wrong, not as a step everyone walks through.

2. Asking permission to upload

POST /turns/{id}/upload-url → app/api/turns.py, carrying the file's size_bytes and its content_type (which can only be video/mp4).

The server checks the caller is the vlogger and the turn is still open, and refuses with 413 anything over MAX_UPLOAD_BYTES before signing anything. It works out the object key (circles/{circle}/vlogs/{turn}.mp4) and returns a presigned URL — a signed permit that expires. On R2 the signature covers the content type and the exact content length, so the bucket itself refuses a different file. It does not accept the file.

It does write two things down: a provisional Vlog row in uploading, which a retried upload reuses rather than duplicating, and the turn moves to rendering.

This is the hinge of the whole architecture. If the video went through the API, the machine would need to be big enough for everybody watching a premiere at once, which is the one moment they all arrive together. Instead the server authorises a transfer it never carries. See the diagram in architecture.md.

3. The upload

Back in publish.ts: a plain HTTP PUT from the phone straight to Cloudflare R2, with the headers the server said to send and nothing else. It streams off disk through createUploadTask (src/api/fileUpload.ts), with a deadline on silence rather than on the whole transfer: two minutes without a byte moving cancels it, however long a slow but healthy upload takes.

Retried with exponential backoff, because this is a phone on mobile data. The retry is safe because the object key is derived from the turn, so a second attempt overwrites the first rather than leaving debris. Only what can go differently is retried: no answer at all, a stall, a 408, a 429 or a 5xx. Any other answer is the bucket's considered opinion and is not asked again — except a 403, which is the signed URL expiring under a slow upload, and gets one fresh ticket from step 2.

If the app dies in the middle, the manifest says uploading, and the next launch calls /complete first: the PUT may well have finished just before the app died, and asking costs one small request where re-uploading costs the whole vlog. Only a 409 sends it back to upload again.

Almost everything that ever goes wrong with R2 goes wrong in the gap between signing and uploading, because the signature covers exactly the headers the client sends. scripts/r2_check.py exists to cross that gap on purpose.

4. Telling the server it worked

POST /turns/{id}/complete.

The server confirms the object really exists in the bucket — it does not take the client's word for it — and checks the size and content type of what is actually there. An object over MAX_UPLOAD_BYTES or not an mp4 is deleted on the spot and answered with 413 or 415. Then it marks the Vlog row ready, moves the turn to published, and sets the premiere to end once the circle's premiere_hours (24 by default) have passed.

Idempotent, deliberately. A phone that uploads, loses signal before the reply and retries must not create a second vlog or restart the premiere.

5. Everybody else finds out

The push goes to every member of the circle except the vlogger, except whoever has muted this circle for themselves, and lands on every device each of them has signed into — see Push: one token per device, muted per circle. Nothing is sent from inside the transaction. The push is queued while the turn is published, and only once the commit has landed is the queue turned into messages: muted members dropped, and each remaining recipient's text rendered in the language on their own account, since one circle can hold both. The round trip to Expo then runs in the background after the response has gone back (send_after_commit in app/services/turns.py). Sending is fire-and-forget: a notification that fails must never stop a vlog from publishing, and one about a commit that never happened must never go out.

6. Watching

app/watch/[turnId].tsx asks the API for the turn, which includes a playback_url. With a CDN domain configured that is a plain public URL and the video is served from Cloudflare's edge — the API machine is not involved at all. Without one it is signed against the bucket instead.

POST /vlogs/{id}/view records that somebody watched, which is what the "who has seen it" list is built on, and the numbers the vlogger is pushed when the premiere closes. It is not what ends a premiere early: that takes a re-roll vote from every active member (votes_needed_for in app/services/voting.py). From here somebody can also comment — which pushes the vlogger back, the same way — or pull their own copy onto the device with Save to device.

7. And round again

24 hours later a scheduler tick finds the premiere has expired, archives the turn and draws the next vlogger. The same tick() also hands out strikes for deadlines that passed with nothing recorded.

tick() is idempotent and runs every 15 minutes in production. Both facts matter: idempotent because it must survive being run twice, and 15 minutes because the database sleeps between ticks — see limits.md.

  • gotchas.md — the things that will confuse you, and cost hours if nobody tells you first.
  • architecture.md — why the pieces are arranged this way.
  • decisions.md — why they are not arranged some other way.