Snapshots

One Node codebase, two production services, and exactly one paid background worker

Settled
  • Production runs brew-web and brew-worker on Cloud Run over Cloud SQL Postgres.
  • Only one worker holds the background lease, so paid work never runs twice.
  • Staging is a single instance, and the demo uses a bucket only.
  • Web scales from 2 to 10 instances behind one load balancer.
  • Alerts fire when the worker loses its lease or jobs wait over 10 minutes.
160Postgres connection alert level
Your call

When do we run more than one worker? Proposal: when over 3 brands wait 30 minutes.

Engineering · 5.1

Architecture

DraftLast checked Mon Oct 26Owner Param

Answer

Brew is one Node 22 codebase that runs as two production services (web and worker) on Cloud Run over Cloud SQL Postgres, plus a single-instance staging and a bucket-only demo, and this page settles where each piece of work runs and why only one worker may do paid background work.

Today

The shape

                    Cloudflare DNS (feed, brew, demo  ->  34.54.65.239)
                                       |
                 Google HTTPS load balancer "squareup-urlmap"
          (Cloud Armor brew-prod-armor on the production backend only)
        |                              |                               |
 feed.joinsquareup.com         brew.joinsquareup.com          demo.joinsquareup.com
 PRODUCTION                    STAGING                        DEMO (DEMO_MODE=1)
        |                              |                               |
 Cloud Run brew-web            Cloud Run shopos-feed           Cloud Run brew-demo
 ROLE=web, 2..10 inst,         ROLE=web,worker, exactly 1      exactly 1 instance,
 session affinity,             instance, CPU always on         bucket storage only
 ingress: LB only                      |                               |
        |  jobs (Postgres)             |                               |
        v                              |                               |
 Cloud Run brew-worker                 |                               |
 ROLE=worker, exactly 1,               |                               |
 internal, holds the                   |                               |
 background lease                      |                               |
        |                              |                               |
 Cloud SQL brew-prod           Cloud SQL brew-staging          gs://brew-prototype-demo
 (Postgres 17, HA,             (172.30.0.3)
 172.30.0.4)                           |
        |                              |
 gs://brew-prod                gs://brew-prototype
 (pictures, video, PDFs,       (binaries, plus a mirror of
 zips; versioned)              every row while it is the way back)

 Outside: Vertex AI (Gemini text, Gemini image, Veo, TTS, embeddings), Anthropic (switch back),
 OpenAI (image fallback, transcription), treg (SERP, Ad Library, AI answers, social, marketplaces),
 Apify (quick commerce, Indian marketplaces), Higgsfield and Sloosh (Studio and finals), Resend (team email),
 Shopify, Meta, Google Ads, GA4, Klaviyo, Judge.me, Slack (read-only connectors), headless Chromium (in the image)

Sources for the diagram: brew/.wt-prod/docs/production.md brew/tools/promote.sh:70-107 brew/deploy.sh:39 brew/tools/deploy-demo.sh memory brew-prod-backend

Services

Piece What it is Source
brew-web Production web: ROLE=web, 2 to 10 instances, 2 vCPU, 2 GiB, concurrency 80, session affinity, ingress internal and load balancer only, pool of 5 Postgres connections brew/tools/promote.sh:79-80
brew-worker Production worker: ROLE=worker, exactly 1 instance, 2 vCPU, 4 GiB, ingress internal, pool of 10 brew/tools/promote.sh:94-95
shopos-feed Staging: both roles in one instance, min 1 max 1, 2 vCPU, 4 GiB, timeout 3600 s, concurrency 200, STORAGE=pg brew/deploy.sh:39-40
brew-demo Demo: DEMO_MODE=1, one instance, all Gemini Flash, bucket storage brew/tools/deploy-demo.sh
Runtime identity Production runs as brew-prod-runtime; staging, the demo and ops jobs run as brew-runtime and cannot read production secrets or data brew/tools/prod-env.sh:5-6
Image node:22-bookworm-slim with ffmpeg, chromium and fonts; five npm dependencies: @anthropic-ai/sdk, @imgly/background-removal-node, date-holidays, pg, puppeteer-core brew/Dockerfile brew/package.json

Roles, the lease and the queue

  • ROLE is web, worker or both (the default, used locally and on staging). brew/roles.js:1-9
  • On Postgres one worker holds the background lease: a session advisory lock (key 727275) on a connection of its own. If that connection drops, the worker exits and Cloud Run restarts it. brew/roles.js:18-32
  • Only the lease holder runs timers, resumes builds and pays for watchers. Every timer is wrapped in leaderOnly. brew/roles.js:39 brew/server.js:520
  • A web-only instance never runs builder work itself. A JS Proxy turns every builder method, other than local reads such as status, list and events, into a job the worker runs. Clicks wait up to 90 s (PDFs 180 s); nudges such as seen, heal and bookAfter are fire and forget. brew/server.js:464-480
  • The job queue is the Postgres jobs table, claimed with FOR UPDATE SKIP LOCKED, leased for 120 s with a heartbeat, retried with backoff, at most one open job per key. Queues are interactive, slow and batch. Delivery is at least once, so every handler must be safe to run twice. brew/jobs.js:1-8 brew/db/migrations/002_jobs_counters_events.sql
  • Without Postgres (demo, local, tests) a job runs at once in-process. brew/jobs.js:7

How instances talk

Need Mechanism Source
A stored object changed objects trigger sends `pg_notify('objects', key version)`; other instances drop their cache
New cards, a connector read again bus table plus NOTIFY, kept a day brew/bus.js:1-3
Build progress on any instance build_events table plus NOTIFY, kept 3 days brew/build-events.js:1-4
Who is in a jam jam_presence unlogged table, read every 2 s brew/presence.js:1-3
Day caps across instances counters table, written within 1 s, pulled every 2 s; take() for strict caps brew/counters.js:1-5
Sign out everywhere sessions and session_revocations plus NOTIFY brew/sessions.js:1-4
Two people editing one jam shared-doc.js: each instance writes only the parts it changed; the other side's new list items merge in brew/shared-doc.js:1-5
One paid Studio make per board make_claims rows taken in one transaction brew/jam2/make-claim.js:1-4 brew/db/migrations/009_make_claims.sql

Request path

serve() tries handlers in this order: the health probe, a cross-site refusal, demo routes, the Shopify app, auth, the closed-store gate, the moved-brand redirect, Sloosh, jam mail links, Slack, connectors, the team gate, memory, cadence moves, brand routes, brand files, the public Brand Book, the mic, the feed service (which mounts the jam, Studio, briefs and the Brand Book API), then x-brand routes, /research/ and /gen/ media, static files and pages. brew/server.js:1110-1168

There is no route registry: each module matches its own paths with regexes. brew/docs/handover/engineering/04-api-routes.md

Restarts and deploys

  • SIGTERM runs one shutdown path: no new move starts, jams and workspaces flush, and runs are left unfinished so the next instance resumes them. brew/server.js:1066-1081 brew/CLAUDE.md
  • At boot the lease holder runs builder.resumeAll() and finals.reconcileAll(), so builds, rewrites and paid finals carry on and are never paid twice. brew/server.js:735-738
  • retire.js and control/live.json still keep one live revision on bucket deployments; production sets RETIRE_OFF=1 because the lease does that job there. brew/retire.js:1-10 brew/tools/prod-env.sh:9

AI providers

  • ai.js gives one call shape for Claude or Gemini; gemini-client.js maps Claude names to Gemini models. brew/ai.js:1-3 brew/gemini-client.js:1-5
  • Today staging and production run AI_PROVIDER=gemini: Plays on gemini-3.1-pro-preview, everything else on gemini-3.5-flash. brew/deploy.sh:39 brew/tools/prod-env.sh:9 brew/moves-engine.js:17
  • Draft media: Vertex Gemini image, then OpenAI; video Veo 3.1 fast then Veo 3.1; voice Gemini TTS then OpenAI. brew/media.js:1-7

Tomorrow

Item Version Tag
Web and worker split, one lease holder, Postgres queue v1 Stays
One worker for all brands. Per-brand build leases so several workers can build at once v2 New
A route table that declares each route's auth level, checked by one test v2 New
Staging on the bucket mirror (OBJECTS_MIRROR=1, OBJECTS_READTHROUGH=1) as the way back v2 Goes, once Postgres has run a month without a rollback
Demo on the bucket with one instance v2 Changes: move to Postgres so one storage path is tested
retire.js and control/live.json v3 Goes, once every site is on Postgres
The feed service as one 348 KB file mounting the jam and Studio v3 Changes: split by area (feed, jam, Studio, brief)

How we do it

  1. Anything that spends money in the background must run under leaderOnly(...) or whenLeader(...). A web instance must never start paid work. brew/roles.js:39
  2. Anything a click needs from the builder goes through the builder proxy; add a method to BUILD_LOCAL only if it is a pure read. brew/server.js:468
  3. A new job handler must be safe to run twice. Paid calls carry their own idempotency keys. brew/jobs.js:4-5
  4. A new piece of shared state across instances uses Postgres (a table, NOTIFY, or counters), never process memory.
  5. Check what the worker is doing on production:
gcloud run jobs execute brew-ops --region=asia-south1 --wait   # runs db/status.mjs: Arata's connections, table sizes

Numbers

Metric Definition Where measured
Lease holder Which worker revision holds the background lease db/prod-clean.mjs EXPECT_WORKER_REV; alert "worker lost its lease" brew/.wt-prod/docs/production.md
Job queue wait Age of the oldest queued job; alert past 10 minutes alerts.js brew/.wt-prod/docs/production.md
Web instances Between 2 and 10 Cloud Run brew-web brew/tools/promote.sh:80
Postgres connections Instances times pool size; alert above 160 Cloud Monitoring brew/.wt-prod/docs/production.md

Open

  1. When do we run more than one worker? Proposal: stay on one worker until a build queue of more than 3 brands waits over 30 minutes, then build per-brand leases.
  2. When does staging stop mirroring to the bucket? Proposal: turn OBJECTS_MIRROR and OBJECTS_READTHROUGH off on 25 Nov 2026 if no rollback to the bucket happened.
  3. Should the demo move to Postgres? Proposal: yes, in v2, on its own small database, so the demo tests the same storage as production.
Sources
  • brew origin/main b91e364: server.js:460-480, 520, 735-738, 1066-1168
  • brew origin/main: roles.js, jobs.js, db/index.js, objects.js, shared-doc.js, bus.js, presence.js, counters.js, sessions.js, build-events.js, instance.js, moved.js, retire.js
  • brew origin/main: db/migrations/001-009
  • brew origin/main: deploy.sh:39, tools/prod-env.sh:6-9, tools/promote.sh
  • brew/.wt-prod/docs/production.md
  • brew origin/main: docs/handover/engineering/01-architecture.md (state of 15 Oct, corrected here)
  • memory brew-prod-backend, brew-storage, brew-gemini-provider, brew-pro-switch-and-flush

Source file content/engineering/architecture.md · For agents: Markdown · JSON