Engineering reference: the back-in-stock notifier architecture
This is the back-in-stock notifier with the friendly labels removed: the real resource names, the runtime, the table key schemas, the three public Function URLs, the reservation-expiry schedule, and the IAM scope. If you want to build it rather than understand it, start here.
Key takeaways
- Six Lambda functions, all Python 3.14 on arm64, with the notifier fed through one SQS queue and a dead-letter queue.
- Three public surfaces: Function URLs on
bisn-capture,bisn-detect, andbisn-reserve— no API Gateway. - Five DynamoDB tables, all on-demand: requests (the queue), restocks (the cap), reservations (the holds), opt-out, and audit.
- Exactly-once is enforced by three conditional writes: the dedup key, the restock id, and the atomic units decrement.
- One Bedrock model, Claude Haiku 4.5 via Global cross-Region inference, called only by the notifier. Single region,
eu-west-2.
The architecture, for engineers
This is the same system as Part 1 with the friendly labels removed and the real resources named. Everything is in one region, eu-west-2 (London), in one account. There is no API Gateway, no NAT Gateway, and nothing always-on; the only inbound surfaces are three Lambda Function URLs, outbound messaging goes through SNS and SES, and the one asynchronous hop — a detected restock to the notifier — is buffered on a single SQS queue.
bisn-capture, bisn-detect, bisn-reserve), an SQS-buffered notifier, five DynamoDB tables, Bedrock called only by the notifier, SNS for SMS and SES for email, and two scheduled jobs. One region, one account, no API Gateway.The DynamoDB data model
Five tables, all on-demand, all in eu-west-2. The schema is deliberately shaped so that the two hard guarantees — queue order and never-oversell — fall out of the keys rather than out of application logic.
bisn-requests— the waiting list. PKsku, SKjoined_ts(an ISO-8601 timestamp with a short random suffix for uniqueness). Because the sort key is the join time, a singleQueryon a SKU withScanIndexForward=truereturns the queue oldest-first — the ordering is the data. A global secondary index,bisn-requests-by-contact(PKsku#contact), backs the one-active-request-per-person check with a conditional write. Each item holds the channel, the request state (waiting/notified/reserved/converted), thelast_notified_restockid, and attlfor staleness expiry.bisn-restocks— one item per detected restock. PKsku, SKrestock_ts, plus a mintedrestock_id. The item carriesunits_available— the decrementable counter that is the whole never-oversell mechanism — and the restockstate. Writing this item conditionally on therestock_idnot already existing is what makes detection idempotent.bisn-reservations— the holds. PKreserve_token(the signed, single-use token from the notice). Each row records thesku, therequest_id, therestock_id, anexpiry_ts, and astate(held/converted/expired). A DynamoDB TTL onexpiry_tstidies old rows, but the sweep, not TTL, drives the functional expiry so timing is precise.bisn-optout— PKcontact(E.164 number or email). The suppression list, checked at capture and again at send.bisn-audit— PKsku, SKts, append-only. One row per notice sent and per restock detected, holding the facts each was built from — the record you reach for when a customer asks “why was I (not) told?”.
Lambda functions
Six functions, all Python 3.14 on arm64, all with CloudWatch Logs at 7-day retention. Each does one job; only the notifier runs off the queue, so the public webhooks always answer fast.
bisn-capture— a Function URL. Validates the notify-me tap, normalises the contact, checks the SKU against the catalogue andbisn-optout, and writes the request tobisn-requestsvia a conditional put onbisn-requests-by-contactso a repeat tap refreshes rather than duplicates. Nothing slow happens here.bisn-detect— a Function URL. Verifies the store signature, compares the new level to the last known one, and on a genuine below-to-above crossing mints arestock_id, writesbisn-restocksconditionally, recordsunits_available, and enqueues one batch onbisn-jobs.bisn-notifier— SQS-triggered on batch jobs. Readsbisn-requestsoldest-first, walks the front skipping opt-outs and the already-notified, caps atunits_available, makes the single Bedrock call, mints per-recipient reserve tokens, sends via SNS or SES, and writesbisn-audit— marking each recipientnotifiedfor the restock id with a conditional write before sending.bisn-reserve— a Function URL. Validates the signed token, holds a unit with a conditional decrement ofunits_availableonbisn-restocks, writes thebisn-reservationsrow, and redirects to the store checkout. A failed condition (units at zero) returns an honest “just gone”.bisn-catalog-sync— scheduled. Mirrors the product catalogue and per-SKU settings (name, threshold, reserve window, voice) from the store into the catalogue store the other functions read.bisn-sweep— scheduled. Queriesbisn-reservationsfor holds pastexpiry_tsthat never converted, incrementsunits_availableback onbisn-restocks, marks each reservationexpired, expires stalebisn-requests, and enqueues top-up batches onbisn-jobsfor any freed units.
Exactly-once, and never-oversell
Three conditional writes carry the whole correctness story, because SQS is at-least-once and webhooks retry. First, the dedup key: bisn-capture’s conditional put on sku#contact guarantees one place in the queue per person per item, however many times they tap. Second, the restock id: bisn-detect’s conditional write on a fresh restock_id means a re-sent webhook enqueues no second batch, and bisn-notifier’s conditional “notified for this restock” marker — written before the send — means a redelivered SQS message re-sends nothing. Third, the units decrement: bisn-reserve’s conditional units_available >= 1 before subtracting is what makes overselling impossible even under simultaneous clicks; DynamoDB serialises the writes, and the loser sees zero. The sweep’s expired flag closes the loop, so a returned unit is returned exactly once.
The queue between bisn-detect and bisn-notifier is bisn-jobs, with bisn-jobs-dlq as its dead-letter queue after five failed attempts. Because the notifier is idempotent per recipient per restock, a redelivery after a partial failure is safe — it resumes rather than re-sends. A batch that keeps failing (a malformed job, a downstream outage) lands in the DLQ with the restock id intact, alarms, and can be replayed once the cause is fixed; no notices are silently lost, and none are duplicated on replay.
IAM scope, observability, and region
Each function gets its own execution role scoped to exactly what it touches, no wildcards. bisn-capture can read the catalogue and bisn-optout and conditionally write bisn-requests — it cannot call Bedrock, SNS, or SES. bisn-detect can read the signing secret, write bisn-restocks, and send to bisn-jobs — nothing more. bisn-notifier is the only role with bedrock:InvokeModel, scoped to the one Haiku profile; it can publish to SNS and SES, read bisn-requests and bisn-optout, and write bisn-restocks markers and bisn-audit, but cannot delete from any table. bisn-reserve can conditionally update bisn-restocks and write bisn-reservations, and holds the token-signing secret — it has no messaging permissions at all. The scheduled functions hold only the narrow catalogue and table permissions they need and no inbound surface.
Observability is CloudWatch throughout: structured logs at 7-day retention, metrics on batch size versus units available (a notifier that ever sends more notices than units is a bug worth paging on), on DLQ depth, and on reserve-conversion rate. An AWS Budgets alarm watches monthly spend — with SMS the line most likely to move, it’s the cheapest early warning that volume, or a loop, is running hot. Everything runs in eu-west-2 from one infrastructure-as-code definition; the only cross-Region path is Bedrock’s Global inference profile, which routes the single model call for capacity and holds no data. The model id is anthropic.claude-haiku-4-5 via that Global profile, invoked only by bisn-notifier.
That’s the whole system: a notify-me tap becomes an ordered place in a queue, a signed inventory webhook becomes a counted restock, and the two meet in a notifier that tells people oldest-first, never past the number of units in hand, each with a link that holds one unit for a short while and passes it on if they don’t. Five tables, six small functions, three conditional writes doing the real work, and one region — a fair queue and a firm never-oversell promise for a couple of dollars a month.
Design rules that shaped the build
- One job per function. Six small Lambdas beat one that does everything; only the notifier runs off the queue.
- Order and cap live in the schema. The join-time sort key is the queue; the units counter is the cap — not application guesswork.
- Three conditional writes hold the line. The dedup key, the restock id, and the units decrement give exactly-once and never-oversell.
- Least privilege, per role. Only the notifier can call Bedrock, SNS, and SES; only reserve can decrement the units count.
- Fail into the DLQ, not into silence. Idempotent retries resume safely; a stuck batch alarms and replays without duplicating notices.
- One region, one model.
eu-west-2throughout; Bedrock Haiku 4.5 via Global inference, called once per restock.