System design: panda clips

Task descriptionInterviewer

Design a service that lets users extract and store panda clips from uploaded videos. Users can tag clips with words so that other users can search for and watch those clips.

The interviewer's analogy: you go to a zoo, record a video, upload it to the system → the system automatically cuts out the parts where a panda is in the frame. Those clips can then be tagged, watched, shared and searched (like YouTube).

The second full-task write-up, and a different task. In the scraping jobs service the queue decided everything: 28 jobs a second, the peak, the pool, priorities. Here the flow is a fraction of a video a second, and the queue is not a problem at all.

The problem is the unit of work. One video is hundreds of megabytes on the way in, a minute of GPU inside, and clips watched thousands of times. So the write-up, following the same order of understand, ask, write down, estimate, draw, arrives at different decisions: bytes flow past the API, the queue lives in a table, a clip is two timecodes in the original, and a CDN serves it by range.

The order is the same:

  1. Understand what is asked. Retell the task as a set of promises the system makes.
  2. Ask. Find out everything the brief lacks that the solution needs.
  3. Write down the requirements. Functional: what the system can do. Non-functional: with what properties.
  4. Estimate. GPUs, bytes on disk, bytes in delivery.
  5. Draw. C1, C2 and below, justifying every decision with numbers.
  1. Step 1 of 33

    What is actually being asked

    You retell the task in your own words and check it with whoever brought it. The paragraph above has no numbers, but it has four promises, and each has a price.

    • Accept a video and release. The user uploads and leaves. Clips come later: between upload and result there is processing that takes longer than anyone will hold a connection open.
    • Find the panda on its own. Nobody labels videos by hand. So there is a neural detector inside, and it has confidence, errors and a GPU price.
    • Store the clips and let people watch them. The brief says "cuts out", but that does not mean "creates a file". A clip can be stored as a pair of timecodes and shown as a piece of the original. This is one of the main decisions of the write-up, and it is made with numbers, not with the wording of the brief.
    • Tag and search. People add words, other people search by words. We need a tag dictionary and a search that sees a fresh tag.

    The interviewer's YouTube analogy sets the proportion: few upload, many watch. Everything asked fits in one word, video, and that word decides what the system will be: a video weighs hundreds of megabytes, while a request to the scraping service weighed kilobytes.

    What the brief lacks: how many videos, how long, from what device, how soon the clips are needed, and what a clip is, a file or a mark. That is the next step.

  2. Step 2 of 33

    The questions without which there is nothing to calculate

    This wording fits both one machine with a graphics card and a GPU farm in three regions. Which one we need depends on answers the brief does not contain.

    • How many videos, and how long? Five thousand five-minute videos a day and five thousand hour-long ones are tasks an order of magnitude apart in GPU and in disk. Without this number no estimate adds up.
    • What are they shot with? A phone records 1080p at about 10 Mbit/s: five minutes is 375 MB, an hour is four and a half gigabytes. One request of that size will not go through an API. And if the phone records HEVC, a browser may not play it; that is a question about a one-time transcode on upload.
    • Is a clip a separate file, or timecodes in the original? The whole storage design hangs on this: cutting and keeping only the pieces is cheap on disk, but the original is gone; keeping the original and marking the pieces costs more disk, but any video can be run again.
    • How soon are the clips needed? Minutes: a normal pool. Seconds: processing starts before the upload ends, and that is a different system.
    • What if no panda is found? It is a normal outcome, and it needs its own state and a message to the user.
    • What counts as a clip? Minimum length, padding at the edges, the pause after which the panda "left the frame". These are detector rules, and product sets them.
    • Who watches, and from where? Public and worldwide means a CDN serves the bytes, not our server.
    • Are tags free words or a dictionary? Who adds them? This decides search and whether moderation is needed.

    Answers in words go into requirements, numbers go into estimates. Where product says "don't know", we write down an assumption and check it with a number.

  3. Step 3 of 33

    Who uses the system

    Three kinds of users, with different needs.

    • The uploader. Filmed at the zoo, pressed "upload", left. Coming back, they want to see the clips of their video, caption them, and understand why there are none if there are none. They care that an hour-long video finishes uploading on poor mobile internet.
    • The viewer. Opened the gallery or a link from a messenger, searches by tag, watches, adds a tag. There are a hundred times more viewers than uploaders, and they will not wait for a player.
    • The on-call engineer. Comes when a video has been "processing" for an hour. They need the stage, the attempt number and the last error.

    Two requirements for the design follow. A video and a clip have an id through which all three read the same state. And the uploader's path differs from the viewer's not only in endpoints but in bytes: one pours in hundreds of megabytes, the others pull megabytes out, and neither should pass through our API.

  4. Step 4 of 33

    Functional requirements

    Requirements live as a file in the service repository. The table on the right is that file; the step's rows are under the text, move them into the table. There is one document for the whole write-up: numbers and new properties will be added in the SLO and estimate steps.

    A functional requirement describes an action of the system, and every action has a caller. So the table has a separate column: who presses the button.

    The neural network is not in the table. It is a way to fulfil FR-2, not a requirement: from the uploader's point of view the system "finds the panda", and they do not see with what. Likewise there is no queue, no GPU and no CDN: those are decisions, and they appear on the diagram once the numbers justify them. And a "clip" in the table is what a viewer watches and tags; whether it is a file or timecodes is decided in the estimate.

    FR-8 stands apart: delete a clip with no panda in it. It looks minor, but it is the only way to fix a detector mistake without a human on our side, and it comes back in the detection step.

    Into the requirements table
    scraper/docs/REQUIREMENTS.md
    Functional0
    #The system canWho calls it
    FR-1Drag here from the text on the left
    FR-2Drag here from the text on the left
    FR-3Drag here from the text on the left
    FR-4Drag here from the text on the left
    FR-5Drag here from the text on the left
    FR-6Drag here from the text on the left
    FR-7Drag here from the text on the left
    FR-8Drag here from the text on the left
    Non-functional0
    #PropertyTarget
    Empty so far
  5. Step 5 of 33

    Out of scope

    What the system does not do is written down as explicitly as what it does. In six months someone will ask why there are no recommendations, and the answer should be in the document.

    • Moderation. Someone will upload a video with no panda and with something that should not be in the gallery. That is a separate loop with people, rules and reports.
    • Recommendations. The first version lists by time and by tag. "Pick by taste" is a separate product with its own data.
    • Other animals. There is one detector and it looks for a panda. A model per animal is a different pipeline and a different budget.
    • Manual editing. Moving a clip boundary by a second. If a clip is timecodes, that is cheap: two numbers in a row. We still do not do it in the first version, but the door stays open, unlike with cutting.
    • Live streams. Processing a stream rather than a file is a different system.

    On the right are ten statements from the same conversation. Sort them by kind: some properties sound like functions, and those are exactly what reviews argue about.

    Ten statements from the conversation with the interviewer. Sort them: what the system does, with what properties, and what it does not do at all.

    1. Upload a video and get its id
    2. Watch a clip by link
    3. Give a clip a tag and a description
    4. Find clips by tag
    5. An hour-long video uploads to the end and resumes after a dropped connection
    6. A clip starts playing in under two seconds
    7. A zoo archive does not delay a tourist video
    8. A tag just added shows up in search immediately
    9. Remove videos that show something indecent instead of a panda
    10. Pick clips for a viewer by taste
    Sorted 0 of 10
  6. Step 6 of 33

    Non-functional requirements

    The second section is properties. A non-functional requirement adds no actions to the system; it sets a guarantee for the ones that exist.

    They are checked through consequences, so under each one it says what breaks without it. This task has three properties the scraper did not have, and all three are about bytes.

    A video uploads without passing through our API. An hour-long clip is gigabytes. A request that size hits the body limit, the load balancer timeout and the memory of the service receiving it. The bytes must go straight to storage.

    A clip starts playing fast from any country. The viewer does not wait, and the clip's bytes must not pass through our service either.

    One uploader does not take the whole pool. A zoo will upload ten years of archive, and a tourist with one video will wait a day behind it.

    The Target column is empty for now. Numbers come next.

    Into the requirements table
    scraper/docs/REQUIREMENTS.md
    Functional0
    #The system canWho calls it
    Empty so far
    Non-functional0
    #PropertyTarget
    NFR-1Drag here from the text on the left
    NFR-2Drag here from the text on the left
    NFR-3Drag here from the text on the left
    NFR-4Drag here from the text on the left
    NFR-5Drag here from the text on the left
    NFR-6Drag here from the text on the left
    NFR-7Drag here from the text on the left
    NFR-8Drag here from the text on the left
  7. Step 7 of 33

    The numbers we commit to

    A property without a number cannot be checked. An SLO sets the share, the limit and the window.

    • Upload intake: 99.9% over 30 days. Start and complete answer even when the GPU pool is down. That is 43 minutes a month when uploading is impossible.
    • 99% of videos reach a final state within a day with no human. The final states are ready, no pandas, and failed. A video stuck in processing forever is worse than a failed one.
    • 95% of videos up to ten minutes get their clips within 15 minutes. This is a promise about the queue and the pool. It breaks when uploads outpace what the GPUs work off.
    • The first frame within two seconds in 95 cases out of 100, from any country. We count the upper bound, not the average, and not only from our own city.
    • Search within 300 ms in 99 cases out of 100, and a fresh tag visible within five seconds. The second number matters more: a search that cannot find a tag added a minute ago looks broken.

    The targets go into the column next to the rows already in the table. Decisions are made by these numbers later: the third one broken, we grow the pool; the fourth one broken, we look at the CDN and at where the index sits in the file.

    Into the requirements table
    scraper/docs/REQUIREMENTS.md
    Functional0
    #The system canWho calls it
    Empty so far
    Non-functional0
    #PropertyTarget
    NFR-1Drag here from the text on the left
    NFR-3Drag here from the text on the left
    NFR-4Drag here from the text on the left
    NFR-5Drag here from the text on the left
    NFR-6Drag here from the text on the left
  8. Step 8 of 33

    Estimate: how many GPUs

    The calculator is on the right. Five thousand videos a day is 0.06 videos a second on average and 0.23 at peak, if the peak is four times the average. For comparison, the scraping jobs service had 28 jobs a second. The flow here is a hundred times smaller, and at this flow the queue is not a question. The question is what happens to each video.

    Each video costs GPU time. Not every frame: the model looks at two frames a second, and a five-minute video takes about a minute, 0.2 seconds of GPU per second of video. That is 83 GPU hours a day. Processing does nothing else: there is nothing to cut or transcode, a clip is timecodes.

    How many GPUs to keep is given by Little's law: the flow at peak multiplied by the time of one processing run. 0.23 videos a second at 60 seconds each is 14 videos in flight at once. A GPU processes one video at a time, so that number is also the pool size at peak. Four are enough on average: the pool grows and shrinks with the queue depth.

    The queue wait is a slider, not a result: Little's law does not say what it will be, but it translates it into videos. Five minutes of waiting is 69 videos in the queue at peak and six minutes from upload to clips; NFR-4 holds with room to spare. Push the wait to half an hour, and the promise is broken.

    Push the GPU factor to one, so the model looks at every frame, and the pool grows to seventy. One detector slider costs more than everything else in this design, and that is the first argument for sampling frames rather than running them all.

    Into the requirements table

    What we know

    What follows from it

    Average flow
    0.06 videos/s
    Flow at peak
    0.23 videos/s
    One video on a GPU
    60 s
    GPU hours per day
    83.3 h
    In processing at once
    14 GPUs
    Waiting at peak
    69.4 videos
    From upload to clips
    6 min

    Flow: 5,000 videos a day is 0.06 a second, 0.23 at peak. A broker is for thousands.

    Little's law: 0.23 videos/s × 60 s on a GPU = 14 videos in flight at once, and that is how many GPUs you need.

    This needs a pool of GPU workers that grows with the queue, separately from the API. A task table handles this flow; a broker is not needed.

  9. Step 9 of 33

    Estimate: how many bytes on disk

    Now size, the reason the task is what it is.

    A five-minute phone video at 10 Mbit/s weighs 358 MB. Five thousand of those a day is 1.7 TB of uploads per day. That much does not pass through our API; that is already requirement NFR-2, and on the diagram it becomes the first decision.

    A clip is timecodes, so the original stays: it is what gets watched. 1.7 TB a day is 622 TB a year, and storage grows at that rate for as long as the videos live. The first thirty days, while a video is being watched, it stays hot, in a tier for frequent reads; after that it moves to an infrequent-access tier by a storage rule. In numbers that is 51 TB hot and 571 TB cold after a year.

    The calculator also counts the alternative. If clips were cut into separate files and only those were kept, with a panda in frame for a fifth of the video and a clip encoded at 3 Mbit/s, that would be 37 TB a year, 17 times less. That is the price of "timecodes instead of cutting", and it has to be said out loud.

    What it buys. No cutting and no transcoding: no CPU per video, no files that can drift away from the rows in the database. A clip boundary moves by changing two numbers. A missed panda can be found by a rerun, and a new model can be applied to the archive. With cutting, none of that is possible: the original is gone.

    Push the video length to an hour or the bitrate to 50 Mbit/s, and the storage bill becomes the biggest in the system. Then cutting comes back to the table, and that is the calculator's honest answer. The storage budget row goes into the table; its number comes in the retention step.

    Into the requirements table

    What we know

    What follows from it

    One original
    358 MB
    Uploaded per day
    1.71 TB
    Clips per day
    15,000 clips
    Originals per year
    622 TB
    In the hot tier
    51.2 TB
    In the infrequent-access tier
    571 TB
    If we cut clips, per year
    37.3 TB
    Timecodes cost more than cutting by
    ×16.7

    An original: 5 min × 10 Mbit/s = 358 MB; 5,000 a day is 1.71 TB per day and 622 TB per year.

    Cutting would store 37.3 TB a year, ×16.7 less. Timecodes are paid for in storage, not in GPU and a second copy.

    Hundreds of terabytes: recent videos stay hot, the rest moves to an infrequent-access tier by a storage rule. Cutting would save several times over, but would take reprocessing away.

  10. Step 10 of 33

    Estimate: how much is watched

    The third calculator is about viewers. If every uploaded video collects a hundred clip views, that is 500 thousand views a day and 23 a second at peak. Each view is 20 seconds of the original at 10 Mbit/s, 25 megabytes: the clip is not transcoded, it is watched at the bitrate the phone recorded. In total 11 TB a day and 341 TB a month.

    That is traffic that cannot pass through our service: a CDN serves it, and our API serves only the address and two timecodes. Row NFR-12 goes into the table; its number comes on the playback frame.

    The bitrate while watching is a slider. Push it to three megabits: the traffic drops threefold, to 102 TB a month. That is what a one-time transcode on upload would give, one file per video at the delivery bitrate, still no cutting. The first version does not do it: it is CPU per video and a second copy. When the traffic bill outgrows the storage bill, the slider shows how much it would save.

    The second half of the calculator is metadata. Each view is one read of the clip page from the database, and every fifth viewer also searches. At peak that is 28 reads a second against 0.7 clip writes: forty reads per write. The system is read-heavy, as the YouTube analogy promised, but 28 reads a second is nothing for one database with an index. A cache and a separate read side are not needed here; they will be needed when views grow a hundredfold, and the road is known: the chapter on CQRS.

    Push views to a thousand per video: bytes grow to petabytes a month, while database reads are still under three hundred a second. Bytes grow faster than requests, and that is the main proportion of the task.

    Into the requirements table

    What we know

    What follows from it

    Views per day
    500,000 views
    Views at peak
    23.1 /s
    Bytes to viewers per day
    11.4 TB
    Bytes to viewers per month
    341 TB
    Searches at peak
    4.63 /s
    Metadata reads at peak
    27.8 /s
    Clip writes at peak
    0.69 /s
    Reads per write
    ×40

    Bytes: 500,000 views × 20 s × 10 Mbit/s = 11.4 TB a day, 341 TB a month. That is CDN traffic, not API traffic.

    Metadata: 27.8 reads/s at peak against 0.69 writes: one database with a tag index.

    Terabytes a day: bytes come from the CDN as ranges of the original, the API returns metadata and timecodes, and the database takes the reads without a cache.

  11. Step 11 of 33

    C1: the system and everything that talks to it

    Now we can draw. The first frame is C1: the system as one block.

    On the left is the user: they upload, watch and tag. On the right is the CDN, somebody else's network of caches through which the viewer gets the clip's bytes. We do not operate it, only configure it, and its availability is not in our SLO, but NFR-5 cannot be met without it.

    The two lines from the user are the colour legend for every following frame: writes go into the service, reads come from the CDN. That both byte paths bypass our services becomes visible on C2.

  12. Step 12 of 33

    The way in: bytes past the API

    The first elements inside the boundary are api-gateway, video-api, the clips-pg database and the videos-s3 store.

    Upload takes three moves. POST /videos creates a video row in the uploading state and returns presigned URLs for the parts. The phone sends the parts straight to videos-s3: in parts, re-uploading a dropped part, with no request body limit on our side. POST /videos/{id}/complete says everything has arrived and moves the video to uploaded.

    Two small requests pass through video-api, and of the 358 MB of video, not a single byte. That is NFR-2 on the diagram. A service that proxied terabytes a day would have to scale by traffic rather than by requests, and it would do nothing with those bytes except pass them along.

    The file that landed here is the only copy of the video for its whole life. The detector will take frames from it, and the viewer will watch clips from it.

    • New on the diagramapi-gatewayThe entry point: checks who came, holds the shared limits and routes the request inside. Knows nothing about videos.
    • New on the diagramvideo-apiThe only service with endpoints: starts an upload, accepts complete, writes tags, serves the gallery and search. Video bytes do not pass through it.
    • New on the diagramclips-pgpostgresVideos, clips, tags. The video row is also the processing task; the clip row is two timecodes.
    • New on the diagramvideos-s3s3The video as uploaded, the only copy. The user sends the bytes here directly, the detector reads them from here, the CDN pulls them from here.
  13. Step 13 of 33

    The queue is a table

    After complete the video has to be processed, and the usual move is a broker: video-api publishes "video uploaded", a worker reads it. There is no broker here, and that is a decision, not an omission.

    The videos row is the task. It has the uploaded status, an attempt number, a lease deadline and a next-run time. The clip-pipeline service asks the database once a second: SELECT … WHERE status = 'uploaded' AND next_run_at <= now() ORDER BY … FOR UPDATE SKIP LOCKED LIMIT 1, sets processing and a lease, and goes to work. Several workers take tasks from one table without getting in each other's way: locked rows are skipped.

    Why this beats a broker here specifically. The flow is 0.23 videos a second at peak; fourteen workers at one query a second is fourteen index lookups. The video state and the task are one row, so there is no dual write of database plus broker, the one that gave the scraper its outbox. And the queue is visible with one SQL query: how many wait, how many are in flight, which task is the oldest.

    A broker is for thousands of messages a second, many different consumers, or events that must reach several services. None of that is in the task: there is one consumer, and it is heavy. The table itself and the queries against it are on the next step.

    • New on the diagramclip-pipelineA scheduler with the processing inside: takes the next video from the table under a lease, downloads the original, finds the panda and writes the clip timecodes. Cuts nothing.
  14. Step 14 of 33

    The videos table as a queue

    Here is the table. Four columns turn a video row into a task.

    • status is where the video is in its lifecycle. The queue is the rows with the value uploaded, and only those.
    • next_run_at is when the task may be taken. Immediately for a new one, after a pause for one that failed.
    • lease_until is until when the task belongs to someone. While a worker works, it keeps moving this time forward.
    • attempt and error: how many times it was taken and how it ended.

    The index is partial: only over uploaded rows, by run time. It holds a handful of rows rather than millions, and the poller's query reads exactly the head of the queue.

    Taking is one UPDATE with a subquery. FOR UPDATE SKIP LOCKED skips rows a neighbour has already grabbed; RETURNING hands the worker everything it needs. The transaction is short: take and release. The processing itself runs without a lock, under the lease.

    The last query returns stuck tasks: a processing row with an expired lease becomes uploaded again, with attempt plus one and a pause that grows with every attempt. Why that is needed comes in the lease step.

    videos.sql
    -- The video row is the task: status, attempt, lease_until, next_run_at.
    CREATE TABLE videos (
      id           uuid PRIMARY KEY,
      owner_id     uuid NOT NULL,
      status       text NOT NULL,  -- uploading | uploaded | processing | ready | empty | failed
      video_key    text NOT NULL,
      duration_s   int,
      attempt      int  NOT NULL DEFAULT 0,
      lease_until  timestamptz,
      next_run_at  timestamptz NOT NULL DEFAULT now(),
      error        text,
      created_at   timestamptz NOT NULL DEFAULT now()
    );
    
    -- An index over the head of the queue only: uploaded rows are few, the rest never enter it.
    CREATE INDEX videos_queue ON videos (next_run_at)
      WHERE status = 'uploaded';
    
    -- Take the next one under a lease. SKIP LOCKED: rows grabbed by a neighbour are skipped, not waited for.
    UPDATE videos
    SET status = 'processing',
        lease_until = now() + interval '10 minutes'
    WHERE id = (
      SELECT id FROM videos
      WHERE status = 'uploaded' AND next_run_at <= now()
      ORDER BY next_run_at
      FOR UPDATE SKIP LOCKED
      LIMIT 1
    )
    RETURNING id, video_key, attempt;
    
    -- Return stuck tasks: the lease expired, so the task is queued again with attempt plus one and a pause.
    UPDATE videos
    SET status = 'uploaded',
        attempt = attempt + 1,
        next_run_at = now() + (interval '1 minute' * power(2, attempt)),
        lease_until = NULL
    WHERE status = 'processing' AND lease_until < now();
  15. Step 15 of 33

    Table or broker

    The matrix is the same requirements as in the table, against four options. A row is what we need, a cell is what the option gives.

    Kafka drops out first. It is a log: a consumer remembers an offset in a partition, not the fate of each message. A task worth a minute of GPU holds the whole partition, a delayed retry and a dead-letter queue are built by hand, and fairness between uploaders does not live in a log at all. And an outbox is still needed on top: the video row and the message are two writes.

    RabbitMQ and SQS are real work queues, and the scraper needed exactly that. But both have the same dual write; in RabbitMQ a message unacknowledged for ten minutes is a matter of timeouts and heartbeats, in SQS it is a visibility timeout extended by hand. Fairness between owners is your own code on top in both cases.

    A table in Postgres covers every row: the lease is a column, the retry is next_run_at, fairness is an ORDER BY, the dashboard is a GROUP BY status. It loses only on growth: hundreds of tasks a second on one table with SKIP LOCKED still work, thousands mean a broker. But at thousands of videos a second we would have a different problem, and it would not be about the queue but about petabytes.

    What we needPostgres tableour pickRabbitMQKafkaSQS
    NFR-9Flow: one video a second at peak✓a fraction of a video a second: one index is enough✓with room to spare✓orders of magnitude to spare✓practically unlimited
    NFR-3A task takes minutes, and it is visible who holds it✓lease_until in the row, extended while working~an unacked message holds the channel; consumer timeout✗a long task holds the whole partition~visibility timeout up to 12 hours, extended by hand
    NFR-3Video state and task are one write✓an UPDATE of one row✗needs an outbox: database and broker are two writes✗needs an outbox✗needs an outbox
    NFR-11Delayed retry with a pause✓next_run_at = now() + backoff~TTL and dead-letter, or the delay plugin✗no: retry topics are built by hand✓ChangeMessageVisibility, up to 15 minutes
    NFR-7Fair between uploaders✓ORDER BY owner in the same query~a queue per owner, selection in your own code✗no: partition order✗no: FIFO within a group
    NFR-14The queue is visible with one SQL query✓SELECT status, count(*) … GROUP BY status~queue length and unacked in the admin UI, without the video row~consumer lag, without the fate of a message~age and depth in metrics, without the video row
    Nothing new to operate✓the database is already there~a cluster and quorum queues✗a cluster, partitions, rebalances✓managed by AWS
    Grows to thousands a second~up to hundreds a second; beyond that, a broker✓tens of thousands a second✓millions✓practically unlimited
    Total7.5 / 84.5 / 82.5 / 85 / 8

    ✓ out of the box~ possible, by hand✗ no

  16. Step 16 of 33

    From the original to the timecodes

    The pipeline took the task. Three actions follow, and none of them creates a new video.

    The original is downloaded from videos-s3 once, to the worker's local disk. The detector samples frames from it, runs them through the model and merges the hits into ranges: start and end in milliseconds, and a confidence. From the frame with the most confident panda it makes a thumbnail and puts it into videos-s3, next to the video.

    Then one transaction in clips-pg: the clip rows with their timecodes and the video status ready. Until it commits, the video is still processing for the whole system, and it has no clips.

    The clip's bytes are the bytes of the original, which already sits where the user put it. The pipeline cuts and transcodes nothing: ffmpeg here only extracts frames.

  17. Step 17 of 33

    A worker died holding a video

    Processing takes a minute, sometimes ten. In that time a worker can die: the model ran out of memory on a 4K frame, the cloud took back a spot machine, someone deployed a release. The video stays processing, and the dashboard does not show it, because formally it is being processed.

    So the task has a lease. Taking a video, the worker sets lease_until ten minutes ahead and extends it every minute while it works. The same clip-pipeline looks once a minute for processing rows with an expired lease and returns them to uploaded: attempt plus one, next_run_at after a pause that grows with every attempt. After the third, failed with the text of the last error.

    A retry must be safe. Clips are written with the key (video_id, start_ms), thumbnails with deterministic names: half the clips from the previous attempt get overwritten, not doubled. That is the same idempotency as at intake, only inside the pipeline. The original is not touched by a retry at all; it is only read.

    Permanent errors, a corrupt file, not a video at all, are not worth retrying: those go straight to failed. The line runs not by error code but by whether a retry can give a different result.

    Into the requirements table
  18. Step 18 of 33

    Reading: gallery, page, search

    The read side is the same video-api. The scraper split reads and writes into separate services; here 28 reads a second at peak do not justify a second service, and the split is postponed until the numbers change.

    Three requests. The gallery: clips by time, paged by cursor. The video page: the clips of one video_id, plus stage and attempts if the video is still processing; that is FR-2 and half of NFR-14. Search: clips by tags, with a step of its own.

    What the API returns in each of them: metadata, the address of the original and two timecodes. Not a single byte of video is in the responses. Where those bytes are is the next frame.

  19. Step 19 of 33

    Playback: a range of the original from the CDN

    GET /clips/{id} returns a video_url, start_ms and end_ms. The address points not at our service but at the CDN, with videos-s3 behind it. The player opens the original at the right second: with a request carrying the Range header it asks the CDN only for the bytes of the clip, not the whole file. On a miss the CDN node pulls that range from storage and keeps it.

    One detail makes this possible. A phone often writes the frame index, the moov atom, at the end of the MP4, and a player cannot start in the middle without reading the file to the end. Moving the index to the front once, on upload, solves this without transcoding: the bytes are copied as they are.

    Why a CDN, if there is already S3? Storage can serve ranges on its own, by link, and at a small traffic that is the right answer; the delivery calculator says so. A CDN in front of it is there for three things.

    • Distance. The bucket lives in one region, and a viewer from the other hemisphere would cross an ocean for every chunk. A CDN node stands next to the viewer.
    • Repeats. A good clip is watched a hundred thousand times. Without a CDN that is a hundred thousand reads from the bucket; with one, the bucket serves a range once per node, and the rest comes from the edge cache.
    • Money. Egress from storage to the internet is billed higher than egress from a CDN, and every request to the bucket is billed too.

    Two requirements close at once. NFR-12: 11 TB a day pass through neither video-api nor api-gateway. NFR-5: the first frame comes from the nearest edge, not from one region. The clip has a thumbnail shown while the first chunk loads.

    Clips are public, as on YouTube, so the links are plain. If product wants private videos, signed CDN URLs appear in this same place, and it is the only place that changes.

    Into the requirements table
  20. Step 20 of 33

    C2 as a whole

    The service is assembled. Only the connections here; an arrow at both ends is a connection in both directions.

    Two byte paths bypass our services: the original goes from the user straight into videos-s3, and the same original goes from videos-s3 through the CDN to the viewer, by range. Our services touch the bytes exactly once, inside the pipeline, and only read them.

    In the middle is one database: the videos with their tasks, the clips with their timecodes and tags, and search. No broker, no separate read side, no cache: the numbers do not justify any of those yet, and each would add a component that can break.

    What the diagram does not show yet: how the detector decides there is a panda in the frame, how the pool is shared between uploaders, and where exactly search lives. That comes next.

  21. Step 21 of 33

    Upload step by step

    The C2 diagram shows who is connected to whom, but not in what order. Here is one upload from top to bottom, in three frames.

    Start: POST /videos, a row in the database, presigned URLs in the answer. Between the first and the second frame the user sends the parts to videos-s3 themselves, for minutes, sometimes hours, and none of our services takes part.

    Completion: complete, the uploaded status, 202. At this moment the video has not gone anywhere yet; it is a row in a table that the first free worker will take.

    Processing: taking under a lease, the original to the local disk, a thumbnail to storage, timecodes and status to the database in one transaction. The original stays in place, and no new video file has appeared. The user takes no part in this frame and learns the result from the video page.

  22. Step 22 of 33

    Watching step by step

    The viewer's request is shorter. Search by tag is one query to the database over the index; the answer is a list of clips, each with the original's CDN address and two timecodes.

    From then on the player talks only to the CDN: it asks for the byte range where the clip starts, and more as it plays. A CDN node goes to videos-s3 on a miss, once per range, and serves the next viewers from its cache. Our services are absent from the second half of the sequence entirely, and counted in bytes that is almost the whole sequence.

  23. Step 23 of 33

    C3: inside clip-pipeline

    We go one level down. C3 shows the modules of a single service. clip-pipeline was one box on C2; inside there are three.

    • task-poller takes the next task under a lease, extends the lease and returns expired ones. Everything that belongs to the queue lives here.
    • panda-detector decodes the original at two frames a second, runs the frames through the detector in batches and merges the hits into ranges: with a confidence threshold, a minimum length and padding at the edges. From the best frame it makes a thumbnail and puts it next to the video.
    • videos-repository is the only module that goes to the database: timecodes and status in one transaction.

    If a one-time transcode of the original to the delivery bitrate is ever needed, it becomes a separate module on a CPU, not a GPU, and it makes one file per video, not per clip.

    • New on the diagramtask-pollerThe whole queue: the next task under a lease, extension, returning expired ones.
    • New on the diagrampanda-detectorFrames through the network, hits into ranges with a threshold, a minimum length and padding at the edges; a thumbnail from the best frame.
    • New on the diagramvideos-repositoryThe only module that goes to clips-pg: timecodes and status in one transaction.
  24. Step 24 of 33

    Data

    Four tables, and the first of them is also the queue.

    videos. Who owns it, where the file is, what status it is in. The attempt, lease_until, next_run_at and error columns are the task: until the video is in a final state, the row lives in the queue. An index on (status, next_run_at) is the whole broker.

    clips. Two timecodes in the original, the detector's confidence, the thumbnail key in videos-s3, a title and a description. A clip has no file: the bytes are the original by range from start_ms to end_ms. So moving a boundary is updating two numbers, and deleting a clip is deleting a row. And a tags text[] column, a copy of the tags for search: the index is built on it, and that is the search step.

    tags and clip_tags. The dictionary of normalised words, and who added which tag. The second is needed to remove your own tag and so one tag is not added twice. A tag is written to clip_tags and to clips.tags in one transaction; two copies are the price of search without a separate engine.

    There are no ranges without a clip in the data: if the detector found something, it is a clip; if not, the video has the empty status.

    videosA video and its processing task: status, attempt and lease in one row.
    iduuid
    owner_iduuid
    statustext
    video_keytext
    duration_sint
    attemptint
    lease_untiltimestamptz
    next_run_attimestamptz
    errortext
    created_attimestamptz
    clipsA clip: two timecodes in the original, the detector confidence, a thumbnail and tags for search. A clip has no file.
    iduuid
    video_iduuid
    start_msint
    end_msint
    scorereal
    thumb_keytext
    titletext
    descriptiontext
    tagstext[]
    created_attimestamptz
    tagsThe tag dictionary: one normalised word, one row.
    idbigint
    nametext
    clip_tagsWho added which tag: this table lets a tag be removed.
    clip_iduuid
    tag_idbigint
    user_iduuid
    created_attimestamptz
  25. Step 25 of 33

    The contract: a receipt and addresses

    The contract as cards. Three of them are about upload, and the video bytes are in only one, which is not ours.

    POST /videos answers 201 with an id and presigned URLs for the parts. A PUT to such a URL is S3, not us: the signature limits the period and the object, and the client re-uploads a dropped part itself. complete is 202 Accepted: there are no clips yet, there is a receipt and an address to watch.

    The move to uploaded is a conditional UPDATE … WHERE status = 'uploading'. A repeated complete after a lost answer changes nothing and gets the same 202: processing starts once. That is NFR-8, and the idempotency key here is the video id itself.

    The remaining cards are reads and tags. GET /clips/{id} returns a video_url on the CDN and the pair start_ms, end_ms: the player asks for the range itself. DELETE /clips/{id} closes FR-8: the row and the thumbnail go, the original only together with the video.

    • POST/videos201 Created

      Create a video. The answer carries the id and presigned part URLs: the bytes go there, past the API.

      request
      Idempotency-Keysizecontent_type
      response
      idstatus: uploadingupload.part_urls[]upload.expires_at
    • PUT{upload.part_url}200 OK

      A part of the video, straight to storage. Not our endpoint: this is S3, we only signed it.

      request
      part bytes
      response
      ETag
    • POST/videos/{id}/complete202 Accepted

      The upload is finished. A repeat with the same id gets the same answer: processing starts once.

      request
      parts[].etag
      response
      status: uploadedlinks.self
    • GET/videos/{id}200 OK

      What is happening to the video: stage, attempts, finished clips.

      response
      statusstageattemptsclips[]
    • GET/clips?tag=&cursor=200 OK

      Clips by tag, paged by cursor.

      response
      items[]next_cursor
    • GET/clips/{id}200 OK

      The clip page: description, tags, the original address on the CDN and two timecodes. The player asks for the range itself.

      response
      titledescriptiontags[]video_urlstart_msend_msthumbnail_url
    • POST/clips/{id}/tags201 Created

      Add a tag. The word is normalised; the same tag is not added twice.

      request
      tag
      response
      tags[]
    • DELETE/clips/{id}204 No Content

      Remove a clip: a false positive, or your own video. The row and the thumbnail go; the original goes only with the video.

    Into the requirements table
    scraper/docs/REQUIREMENTS.md
    Functional0
    #The system canWho calls it
    Empty so far
    Non-functional0
    #PropertyTarget
    NFR-2Drag here from the text on the left
    NFR-8Drag here from the text on the left
  26. Step 26 of 33

    The life of a video

    A video has six states, and three of them are final.

    uploading → uploaded → processing → ready. Next to it two other ends: empty, when the detector found nothing, and failed, when the attempts ran out or the upload was never completed within a day.

    Who moves it is shown by colour. Into uploaded: video-api on complete. Into processing and on to ready or empty: the pipeline, and only after the timecodes commit. Back to uploaded and into failed: the poller by lease and attempts, not the detector itself. A crashed worker moves nothing; whoever notices the expired lease does it for it.

    empty is an honest state, not an error. Half the videos from a zoo will be of an enclosure with no panda, and the user is told so in words, not with an empty page. And the original stays: if the detector was wrong, the video can be queued again, with the same move to uploaded.

    • client
    • video-api
    • pipeline
    • poller by lease

    processing

    A worker holds the lease: downloads the original, finds the panda, writes timecodes. If the lease expires, the video goes back to uploaded.

    Where next

    • → readytimecodes written · pipeline
    • → emptyno pandas · pipeline
    • → uploadedlease expired or error, backoff · poller by lease
    • → failedthree attempts · poller by lease
  27. Step 27 of 33

    Detection: threshold, length, errors

    The neural network is not a black box with a yes or no answer but a source of confidence per frame. The rules that turn it into clips are set by product, and they go into the requirements table.

    Threshold. A frame counts as a panda frame at confidence above 0.7. Lower means more false clips; higher means more misses. The threshold is tuned on a labelled sample and changed without a deploy.

    Length and merging. Hits are merged into a range if the pause between them is under two seconds; a range shorter than five seconds is dropped; a second is added at each edge so the panda does not walk into the frame on the clip's first frame. All of this is arithmetic over timecodes, and the rules can change without touching a single file.

    Two kinds of errors. A false positive, a clip with a raccoon, is fixed by the uploader through FR-8, and that is cheap: a row goes. A miss, a panda with no clip, is fixed by a rerun: the original is in place, and "run it again" is an ordinary task in the same queue, with a new threshold or a new model. The cutting design has no such path.

    A new model. Applies to new uploads immediately, and to the archive through the same queue, one video at a time, in the background and without hurry. That is the second thing bought by keeping the originals.

    Into the requirements table
    scraper/docs/REQUIREMENTS.md
    Functional0
    #The system canWho calls it
    Empty so far
    Non-functional0
    #PropertyTarget
    NFR-13Drag here from the text on the left
  28. Step 28 of 33

    A noisy neighbour on the GPUs

    Uploader isolation is written down as one row. The simulator on the right shows what it means.

    Three users: a tourist and a school trip with a video every two seconds, and a zoo that uploaded its archive, four hundred videos at once. A pool of eight GPUs at four seconds per video works off two videos a second. Switch on the shared queue: the zoo occupies the pool for three minutes, and the tourist has not a single clip ready in that time. The system is right all along: it takes tasks in order of arrival.

    Switch to "equal share". The pool is the same; only the rule for the next task changed: whoever has the fewest in flight goes next. The tourist gets clips in seconds, the zoo gets a third of the pool.

    In a table this is one ORDER BY in the poller's query: owners with fewer in flight first, then by upload time. What a broker would need a per-owner queue and a scheduler of your own for is one line of SQL here. Weights, more for a paying zoo, are the same line with a coefficient.

    Into the requirements table
    Order00:00

    Start it and watch the colours of the slots in the pool. They show whose video is occupying it.

  29. Step 29 of 33

    Where search lives

    Tag search is not full-text search. It is an exact match of words from a dictionary, and it needs no separate engine.

    A tag is normalised on the way in: lower case, trimmed spaces, two to thirty-two characters. The normalised words sit in clips.tags, with a GIN index built on it. The query "clips with tags panda and cub" is WHERE tags @> ARRAY['panda','cub'], one pass over the index and a sort by time. At 28 reads a second that is milliseconds.

    Freshness comes for free: the tag is written to clips.tags in the same transaction as clip_tags, and search sees it right after the commit. NFR-6 asked for five seconds; we got zero.

    Suggestions while typing are a separate query to the tags dictionary by prefix, with an index for LIKE 'pan%'.

    When OpenSearch or the like becomes needed: full-text search over descriptions with typo tolerance, ranking by popularity, thousands of searches a second. The delivery calculator shows thousands are far away. Until then an engine is an extra component with its own sync and its own lag, which is exactly what would break NFR-6.

  30. Step 30 of 33

    What to keep, and for how long

    Three classes of data with different fates.

    Originals. They live as long as the video does: they are the clips. 622 TB a year at the current flow is the main cost line, and it is managed by a storage rule rather than by code. For the first thirty days, while a video is being watched, it sits in a tier for frequent reads; then it moves to an infrequent-access tier, where storage is cheaper and each read costs more. Repeated reads are covered by the CDN: a cold original comes up from storage once per node. Deleting a video takes the original, the thumbnails and the clip rows, and purges the CDN by address. Abandoned uploads, a row without complete for a day, are picked up by a scheduled cleanup.

    Thumbnails. Kilobytes per clip; their volume does not show on the bill.

    Rows in the database. Millions of clips a year is a table Postgres barely notices. videos needs attention of its own: the status changes several times in a row's life, and dead versions pile up. Autovacuum copes, but it has to be watched, because the queue runs on this same table.

    The number for NFR-10 goes into the table: the original lives as long as the video, about 622 TB a year, an infrequent-access tier after thirty days.

    Into the requirements table
    scraper/docs/REQUIREMENTS.md
    Functional0
    #The system canWho calls it
    Empty so far
    Non-functional0
    #PropertyTarget
    NFR-10Drag here from the text on the left
  31. Step 31 of 33

    What to measure

    There are many metrics; five are enough for decisions.

    • The age of the oldest task in uploaded. More important than the queue length: the pool grows by it, and it shows the pool is not keeping up before NFR-4 breaks.
    • Time from complete to ready, p95. Maps directly to the SLO.
    • Failures by error class. "Out of memory", "cannot decode", "model did not answer" are three different incidents, and they cannot be mixed into one failure rate.
    • The share of empty. Doubled in a day means the threshold drifted or the model degraded, and users have not complained yet.
    • CDN hit ratio and egress from videos-s3. If storage egress grows with views, the CDN is not caching ranges, and the storage bill will say so later than the metric.

    And stage with attempts on the video page: the on-call engineer should not need the database to see "attempt 2 of 3, crashed on frame 1,840".

    Into the requirements table
    scraper/docs/REQUIREMENTS.md
    Functional0
    #The system canWho calls it
    Empty so far
    Non-functional0
    #PropertyTarget
    NFR-14Drag here from the text on the left
  32. Step 32 of 33

    What it cost

    The system keeps all four promises from the brief. Here is what was given up for that.

    Storage is the main line on the bill. A clip is timecodes, so the original is kept whole: 622 TB a year against 37 if clips were cut into files. This is a decision for the sake of reprocessing and simplicity, and it should be written down with the number, so nobody asks in a year why so much.

    Traffic at the phone's bitrate. The viewer gets a piece of the original as it is: 341 TB a month instead of 102 with a one-time transcode to the delivery bitrate. When the traffic bill overtakes the storage bill, a transcode on upload becomes a separate module, one file per video, still without cutting.

    The queue lives in the database. One database is one failure domain: Postgres down means intake, processing and the gallery are down. The queue table needs attention to vacuum, and growth to thousands of tasks a second will require a broker and an outbox.

    One service for writes and reads. They scale together. With views up two orders of magnitude, the read side will have to be split out with a cache in front of it.

    Product quality is bounded by the model. The threshold and merging rules can be tuned and the archive rerun, but "the wrong panda" is solved only by a better model.

    The decision gets revisited if private videos appear (signed CDN URLs), live streams (different processing), hour-long 4K videos (cutting comes back to the table), or a flow of hundreds of videos a second (a broker, and the pipeline split into pools).

  33. Step 33 of 33

    The answer: decisions against requirements

    At the start of the write-up we wrote down the requirements; along the way, numbers were added to them. What remains is to check that each has an answer.

    On the left are the decisions made during the write-up, on the right the requirements table in full. Put each card on the row it covers. Some decisions cover several rows, some rows have several decisions.

    If a card lands in the wrong place, it will say underneath what it really answers. A requirement left without a card means the write-up did not answer it and the design needs more work.

    Decisions

    Covered 0 of 22

    Functional

    • FR-1Start a video upload and get an address to send the bytes to
    • FR-2Report that the upload is finished and see what is happening to the video: processing, ready, no pandas
    • FR-3Show the clips of one video
    • FR-4Watch a clip
    • FR-5Give a clip a tag and a description; remove your own tag
    • FR-6Search clips by tags
    • FR-7Share a clip by link
    • FR-8Delete your own video with its clips, and a clip with no panda in it

    Non-functional

    • NFR-1Upload intake survives processing being down
    • NFR-2A video of any size uploads without passing through our API
    • NFR-3An uploaded video is not lost
    • NFR-4Clips appear without a long wait
    • NFR-5A clip starts playing fast
    • NFR-6Search answers fast and finds a fresh tag
    • NFR-7One uploader does not take the whole GPU pool
    • NFR-8Repeating "upload finished" does not start a second processing run
    • NFR-9The system handles the peak flow
    • NFR-10Storage grows within budget
    • NFR-11A temporary failure does not fail the video
    • NFR-12Watching a clip does not load our API with bytes
    • NFR-13A clip without a panda can be removed, and a panda is not missed
    • NFR-14For every video it is visible what stage it is at and why it is stuck

Use ← and → to move between steps.

Check yourself

Why is there no message broker in this design?

What is stored for a clip?

Why do video bytes not pass through video-api?

Where does tag search live?

What problem does the task lease solve?

Answered 0 of 5