The purchase-order allocation platform, taken apart.
An end-to-end distributed pipeline that turns a client's raw purchase-order file into store-level allocations and delivers them back — under a hard 15-minute SLA. The interesting part is not the allocation maths. It is that nothing in the chain promises exactly-once execution, so every boundary had to be made idempotent, leased, and replayable.
00The one-pager
Everything below in a single screen. If a conversation only has five minutes, this is the whole project.
A retail client pushes purchase-order files to GCS. Each PO must be allocated across hundreds of stores respecting demand, store capacity, pack structures, allocation multiples, PO type and allocation method — automatically, and inside a 15-minute SLA.
- A few hundred PO rows explode into ~180K store-level rows; several POs share a worker.
- 15–18 distinct processing paths (3 PO types × 5–6 allocation methods).
- Five independent systems in one flow, none of them exactly-once.
- Persistence, not computation, was the SLA bottleneck.
The workflow end to end: orchestration, batch/state model, idempotency and leases, preprocessing pipeline, integration contract with the existing optimisation service, post-processing persistence, finalisation and recovery, plus capacity validation.
Assume at-least-once everywhere. Make correctness come from deterministic business identifiers, durable Postgres state, expiring leases and replayable outputs — not from hoping a message is delivered once.
- Caller-generated batch_id, so Init retries are safe.
- allocation_code as the idempotency key, not the task ID.
- Postgres for locks, because the protected state already lives there.
- Polars over Pandas for concurrent, transformation-heavy work.
- DuckDB → COPY → temp table → set-based insert.
- Outbox + deterministic GCS paths for final delivery.
Validated POs process automatically with no manual intervention, retries and crashes became routine rather than incidents, and result persistence stopped being the thing that ate the SLA.
01The two-minute answer
Said out loud, in this order. Problem → ownership → hardest part → performance → outcome.
“The client pushed PO files to our GCS bucket. Each file held multiple purchase orders, and each PO could carry hundreds of product- and pack-level rows. The system had to allocate that inventory across hundreds of stores while respecting PO type, allocation method, pack constraints, allocation multiples, demand and store-level capacity — automatically, inside a 15-minute SLA.”
“My ownership was the end-to-end allocation workflow. The ingestion team landed and validated PO data. From there my team built the automatic processing flow: Google Workflows per PO type, an Init API for batch assignment, a Process API for bounded picking, Cloud Tasks for per-PO preprocessing, Postgres-based idempotency, Polars transformations, integration with the existing Gurobi service, post-processing persistence, and final output generation.”
“The hardest part was correctness under retries and partial failures. Batch IDs generated inside the API could strand POs when a response was lost, so we moved generation to the Workflow and made Init idempotent. The Cloud Task plus DB-update window we handled by accepting at-least-once dispatch and making Preprocessing idempotent on allocation_code. For final delivery we used an outbox plus deterministic upload paths so GCS writes could be retried safely.”
“On performance, preprocessing used Polars because PO rows expand to store level and several POs share a worker. In post-processing we took result persistence from roughly 90 seconds to under four, using DuckDB for cleansing, COPY into a Postgres temp table, and one set-based insert into the main table. Net: end-to-end ownership, distributed failure handling, and a pipeline that meets its SLA.”
02Scope & ownership
The single most common way this answer goes wrong is claiming the whole stack. Be precise about the boundary — it reads as seniority, not modesty.
| Area | Mine | Someone else's |
|---|---|---|
| File ingestion & PO validation | Consumed the contract | Ingestion team — implementation |
| Orchestration (Workflows, Init, Process) | Design + implementation | — |
| State model, idempotency, leases | Design + implementation | — |
| Preprocessing & Polars pipeline | Design + implementation | — |
| Optimisation solver internals | Input/output contract, retries, failure handling | Existing shared Gurobi service |
| Post-processing & persistence | Design + the rewrite | — |
| Finalisation & delivery | Design + implementation | — |
| Capacity & load validation | Owned | — |
“My ownership was not one API. I owned the allocation workflow end to end — orchestration, state transitions, idempotency, preprocessing, the integration with the existing optimisation service, post-processing persistence, finalisation, failure recovery and capacity validation. I did not build the solver, and I did not build ingestion.”
Facts to have ready
Exact months from kickoff to production, split into design / build / hardening / go-live.
Backend engineers on the allocation workflow, ingestion engineers, optimisation-service contacts, PM, EM, QA. Name who reported into the work and who was a partner team.
Name the modules and APIs you wrote or rewrote personally — Init/Process handlers, the idempotency layer, the Polars strategy framework, the persistence path. Interviewers probe this when the story sounds architectural.
03How it was broken down
The work was sequenced so that each milestone produced something observable, and so that the risky parts — the distributed boundaries — were hardened before scale testing rather than after.
M1 — Contract & state model
Agree the ingestion handoff, define po_master statuses, and pick allocation_code as the identifier that ties PO, receipt and batch together for tracing and idempotency downstream.
M2 — Happy path, one PO
Workflow → Init → Process → Cloud Task → Preprocessing → solver → Post-processing → Finalize, working end to end for a single PO with no concurrency and no retry handling.
M3 — Business coverage
The 15–18 combinations of PO type and allocation method, behind a pluggable strategy interface so a new method is a new class rather than a new branch in a growing conditional.
M4 — Correctness hardening
Caller-generated batch IDs, the Postgres idempotency table with owner and lease, atomic takeover, the outbox for finalisation, and deterministic output paths. Each one came from writing down a specific crash window and closing it.
M5 — Performance & capacity
Profile the stages, find persistence eating most of the budget, rewrite it around DuckDB and COPY, then load-test at 100 concurrent POs to confirm the SLA holds with headroom.
M6 — Go-live & observability
Batch IDs, allocation codes, retry counts, statuses and attempts all traceable, so a stuck PO is a query rather than an investigation.
04The business problem
The client pushed purchase-order inventory data to a GCS location. The system had to convert that raw PO data into store-level allocations and write final output files back to GCS. Allocation is hard because it is not an even split: it has to respect product-level inventory, store-level demand, pack structures, allocation multiples, PO type and allocation strategy simultaneously.
The shape of the data is what drives the architecture. A PO arrives with a few hundred product-level rows. Once those rows are exploded across eligible stores, the working set grows by three orders of magnitude.
300 PO rows × 600 stores = 180,000 store-level candidate rows 10–12 POs active on one worker: 180,000 × 10 = ~1.8M rows of intermediate processing 100 concurrent POs system-wide = 150–200M row-level computations
And the requirement was never “process the file eventually”. It was to pick up validated POs automatically and have allocation results available inside the operating SLA.
05Input model
Each client file held at most ~1,000 rows, spread across multiple purchase orders. A logical PO was identified by po_id + receipt_id, and one PO could carry 100+ rows with different product and pack attributes.
| Attribute | Meaning in the workflow |
|---|---|
| po_id + receipt_id | Logical identity of a PO/receipt unit. |
| po_type | Receipt, Split, or Prior Distro — three separate processing flows. |
| allocation method | 5–6 methods, producing 15–18 combinations with PO type. |
| product_code | Product/SKU-level input used to build store-level allocation rows. |
| pack_id, pack size, master pack size | Inputs for pack-aware distribution. |
| allocation multiple | Allocated quantity must land on multiples of a business-defined value. |
| business columns | Additional columns driving the 15–18 processing paths. |
06Requirements
Functional
- Ingestion. Client pushes raw PO files to GCS.
- Validation. PO-level validation before allocation processing begins.
- Automatic picking. Validated POs are picked up with no manual intervention.
- PO type separation. Distinct handling for Receipt, Split and Prior Distro.
- Chunked dispatch. Bounded work per call — 20 POs per Process invocation.
- Preprocessing. Explode to store level, then transform, validate, filter, aggregate.
- Optimisation. Call the existing Gurobi service for constrained allocation paths.
- Post-processing. Consume solver output, transform, persist.
- Delivery. Generate the final output and upload to GCS.
- Recovery. Survive retries, crashes, duplicate tasks and lost responses.
Non-functional, and what each one forced
| Requirement | Design consequence |
|---|---|
| 15-minute SLA | Every stage bounded and measurable; the persistence bottleneck had to go. |
| At-least-once delivery | Cloud Tasks and Workflow retries mean every API must be idempotent. |
| Scalability | Multiple POs per worker — memory and CPU had to scale past a single PO. |
| Reliability | Every gap between a DB write, a task creation and a GCS upload is a crash window. |
| Observability | Batch IDs, allocation codes, retry counts, statuses and attempts all traceable. |
| Separation of concerns | Clear ownership lines between ingestion, allocation, optimisation and delivery. |
07High-level system design
GCS is the integration point for client files and solver input/output. Google Workflows orchestrates picking, one workflow per PO type. Cloud Tasks gives retryable async execution per PO. PostgreSQL holds durable lifecycle and idempotency state. Polars does the transformation work, DuckDB and COPY do the persistence, and the shared Gurobi service handles the constrained allocation paths.
“The system deliberately avoids assuming exactly-once execution across Workflow, Cloud Tasks, Postgres, GCS and the solver. Every distributed boundary is made safe with deterministic identifiers, durable state transitions and replayable outputs instead.”
08Lifecycle & state
Retries are only unambiguous if the database can answer “has this already happened?”. The key objects are po_master, the preprocessing idempotency table, the allocation result tables, and the finalize outbox.
| Status | Meaning |
|---|---|
| -120 | Validation failed at PO/receipt level — must never be picked for allocation. |
| -100 | Validated, waiting to be picked. |
| 0 | Picked / processing started (stage-specific semantics). |
| IN_PROGRESS | A worker currently owns this logical allocation operation. |
| COMPLETED | Durably done. A retry returns success without reprocessing. |
| FAILED | Terminal or retry-analysable, depending on business semantics. |
allocation_code is the central business identifier. It ties PO, receipt and batch together, and it is the same value used for idempotency, tracing, solver input, solver output, post-processing and finalisation. One identifier for one logical operation, all the way through.
09Stage walkthrough
Workflows and the Init API
Three Google Workflows, one per PO type. Each generates a batch_id, calls Init to assign that batch to eligible POs, then calls Process repeatedly until the batch is exhausted.
batch_id was generated inside Init. If Init tagged rows and then crashed before responding, Workflow retried, generated a fresh batch ID, and the already-tagged POs were left behind under the old one — stranded, and breaching the SLA.
The fix was to move identity generation to the caller, so a retry is the same logical operation.
Workflow:
batch_id = current_timestamp_bigint
call Init(batch_id)
Init API:
UPDATE po_master
SET batch_id = :batch_id
WHERE processed_status = -100
AND batch_id = -1
AND po_type = :po_type;
-- Init(batch_id) is now safe to retry: the identity is stable.“The idempotency key has to exist before the side effect begins. If the server generates the identity after it starts mutating state, a lost response leaves the caller with no stable way to retry.”
Process API and Cloud Task dispatch
Process fetches the next set of unique POs for the batch — 20 at a time — assigns each an allocation_code, creates a Cloud Task per PO, updates status, and returns progress so the Workflow knows whether to loop again.
110 unique POs in one batch: call 1 → 20 POs → all_processed = false call 2 → 20 POs → all_processed = false ... call 6 → 10 POs → all_processed = true
Why 20. Bounded work per call keeps each HTTP request inside its timeout, controls the rate of Cloud Task creation, keeps progress observable, and lets the Workflow drive the loop. Dispatching an arbitrarily large batch in one call is the version of this that pages someone at 2am.
Process must create a Cloud Task and update Postgres. Those cannot commit atomically — Cloud Tasks is an external system. If the task is created and the DB write fails, a Workflow retry picks the same PO and creates a second task. That is not a bug to eliminate; it is a property to absorb downstream.
“We accepted that Cloud Task dispatch is at-least-once. Correctness came from business-level idempotency in the Preprocessing API, not from pretending the task would be created exactly once.”
10Locks vs. durable state
A row lock only protects concurrent transactions while the transaction is open. A SELECT … FOR UPDATE with no state change is not enough: after COMMIT the lock is gone and a later request sails through. The useful pattern is lock, validate, and transition state — atomically.
| Mechanism | Solves | Does not solve |
|---|---|---|
| FOR UPDATE | Serialises concurrent transactions on the same row. | Does not durably record that the PO was already processed. |
| NOWAIT | Fails fast instead of blocking on a held lock. | Does not skip ahead; the application must handle the error. |
| SKIP LOCKED | Queue-like consumers skipping locked rows. | Unnecessary when the workflow already serialises picking per batch. |
| Status column | Blocks later requests by recording durable state. | Says nothing about external side effects (task creation, GCS upload). |
| Idempotency key | Ties retries and duplicate tasks to one logical operation. | Still needs durable completion + lease state to survive crashes. |
In this system there was no normal concurrent Workflow execution for the same batch_id. The real problem was never two workers racing — it was retry after partial completion. That is why idempotent downstream processing mattered more than SKIP LOCKED.
11Idempotency & leases
A duplicate request into Preprocessing could be a retry of the same Cloud Task, or a genuinely separate task created for the same PO. Task ID and retry count identify the transport attempt, not the work — so the key had to be the business operation: allocation_code.
CREATE TABLE preprocessing_idempotency (
allocation_code TEXT PRIMARY KEY,
status TEXT NOT NULL, -- IN_PROGRESS / COMPLETED / FAILED
owner_id TEXT,
lease_expires_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);Three statements carry the whole protocol — claim, take over, complete.
-- 1. first claim: insert if absent
INSERT INTO preprocessing_idempotency (
allocation_code, status, owner_id, lease_expires_at
)
VALUES (:allocation_code, 'IN_PROGRESS', :owner_id, NOW() + INTERVAL 'N minutes')
ON CONFLICT DO NOTHING;
-- 2. takeover: only if the previous owner looks dead
UPDATE preprocessing_idempotency
SET owner_id = :new_owner,
lease_expires_at = NOW() + INTERVAL 'N minutes',
updated_at = NOW()
WHERE allocation_code = :allocation_code
AND status = 'IN_PROGRESS'
AND lease_expires_at < NOW();
-- 3. complete: only if this worker still owns the operation
UPDATE preprocessing_idempotency
SET status = 'COMPLETED',
updated_at = NOW()
WHERE allocation_code = :allocation_code
AND owner_id = :owner_id;If the row is already COMPLETED, the API returns success immediately. If it is IN_PROGRESS with a live lease, the request does not duplicate work. If the lease has expired, a retry can atomically take ownership.
Why not allocation_code + retry_count
Retry count changes on every attempt. Put it in the key and every retry becomes a different key — which is exactly not having idempotency. Retry count is a metric; it is not part of identity.
Edge cases this closes
| Edge case | Handled by |
|---|---|
| Same Cloud Task retries | Same allocation_code → same row. No new logical operation. |
| Duplicate Cloud Tasks created | Different task IDs, one allocation_code. Only one owner processes. |
| Worker crashes after claiming | Lease expires; a later retry takes ownership atomically. |
| Worker crashes mid-Polars | No COMPLETED state exists, so the work replays after lease expiry. |
| Worker succeeds, response lost | DB says COMPLETED — the retry gets a 200 and does nothing. |
| Delayed duplicate arrives late | Durable COMPLETED blocks it even long after any lease is gone. |
| Two workers attempt takeover | Conditional UPDATE — exactly one wins. |
| Processing outlives the lease | Conservative TTL, or heartbeat renewal keyed on owner_id. |
| Redis unavailable | Not a scenario — Postgres is the source of truth. |
“The idempotency key was the logical allocation operation, not the Cloud Task. Cloud Tasks gave us at-least-once delivery; Postgres idempotency made repeated delivery safe.”
Postgres vs. Redis for the lock
| Question | Postgres | Redis |
|---|---|---|
| Where does the protected state live? | Best when it is already in Postgres. | Better for ephemeral or cross-service work not tied to one row. |
| Is durability required? | Strong fit — idempotency, payments, orders, allocation state. | Not sufficient alone for correctness-critical completion state. |
| Throughput | Fine at moderate concurrency; can get hot under huge lock volume. | Excellent for very high-frequency short-lived locks. |
| Failure behaviour | Survives restarts, transactional. | TTL, eviction and restart semantics need careful handling. |
| Fits | PO processing, payment idempotency, order transitions. | Cache stampedes, rate limiting, short-lived coordination. |
Keep the lock close to the state it protects. Postgres for correctness-critical durable transitions; Redis for high-frequency ephemeral coordination where a duplicate is tolerable or protected elsewhere.
12Finalisation & the outbox
Finalize reads the persisted allocation result, generates the client-facing output, uploads it to GCS, and marks the allocation complete. Same shape of problem as before: the upload can succeed and the process can die before the DB is updated.
Post-processing transaction:
BEGIN;
persist allocation result;
insert outbox event FINAL_UPLOAD(allocation_code);
COMMIT;
Finalize worker:
claim outbox event;
generate deterministic file path from allocation_code;
upload to GCS;
mark outbox event COMPLETED;
mark allocation COMPLETED;A transactional outbox does not make an external side effect exactly-once. It guarantees the intent is durably recorded alongside the state change. The upload itself still has to be idempotent — which is why the destination path is derived deterministically from allocation_code: a retry overwrites the same object rather than creating a second one.
“The outbox gave us durable at-least-once finalisation. Deterministic output paths and allocation-code idempotency made repeated finalisation safe.”
13The failure matrix
This table is the project. Every row is a crash window someone wrote down before it happened in production.
| Stage | Failure | Risk | Protection |
|---|---|---|---|
| Init | Batch assigned, response lost | New batch created; old POs stranded | Workflow-generated batch_id as the key |
| Process | Task created, DB status not updated | Same PO picked again | Preprocessing idempotency on allocation_code |
| Preprocessing | Task retries after timeout | Duplicate computation | Idempotency row + status + owner + lease |
| Preprocessing | Crash after claiming | PO stuck in progress | Lease expiry + atomic takeover |
| Preprocessing | Completes, response lost | Task retries | COMPLETED returns 200, no reprocessing |
| Preprocessing | Work outlives the lease | Second worker starts | Lease renewal / conservative TTL |
| Gurobi | Output written, callback fails | Post-processing never starts | Retry via allocation_code + GCS path |
| Post-processing | Persistence too slow | SLA breach | DuckDB → COPY → temp → main |
| Finalize | Upload succeeds, DB write fails | Output uploaded twice | Outbox + deterministic object path |
| Final output | Delayed duplicate task arrives | Reprocess / re-upload | Durable COMPLETED + same object path |
14Preprocessing & why Polars
Once ownership was acquired, Preprocessing fetched the PO data, expanded it to store level, joined store and demand data, applied filters and validations, computed derived columns, and prepared solver input.
One PO goes from hundreds of product rows to hundreds of thousands of store-level rows. With several POs on a worker, that is around two million rows of intermediate data at once — and the work is transformation-heavy: filters, joins, group-bys, validations, ranking and business rules across 15–18 combinations of PO type and allocation method.
Polars fits that shape: a Rust columnar engine with vectorised operations, parallel execution and memory-efficient representation. Python describes the computation; the heavy lifting happens in native code.
| Approach | Strength | Why not here |
|---|---|---|
| Plain Python | Flexible, simple for small logic | Interpreter loops, hand-rolled joins and grouping, unmaintainable at this complexity |
| Pandas | Mature, fine for medium datasets | Weaker under concurrent heavy transformation with memory pressure and an SLA |
| SQL only | Excellent for relational work in the DB | Dynamic multi-strategy business logic is awkward to express and test |
| Spark | Good for large distributed batch | Operationally far too heavy for a per-PO, low-latency request path |
| Polars | Rust engine, columnar, parallel, lazy optimisation | Newer ecosystem — needed some team ramp-up |
Pandas can handle 180K rows. The honest reason for Polars is the combination: multiple concurrent POs, large intermediate sets, transformation-heavy pipelines, memory limits and SLA pressure.
15Gurobi & the optimisation
Some allocation strategies are optimisation problems, not calculations. The system has to choose the best distribution among enormously many valid ones while satisfying inventory, capacity, pack, multiple, demand and business constraints simultaneously.
Decision variable x[p, s, store] = units of product p / size s allocated to a store Inventory constraint Σ over stores x[p, s, store] ≤ available_inventory[p, s] Store capacity constraint Σ over products x[p, s, store] ≤ store_capacity[store] Objective maximise Σ demand_score[p, s, store] × x[p, s, store]
In practice many variables must be integers — you cannot ship 3.7 units — and pack constraints plus allocation multiples push the problem from pure LP toward mixed-integer linear programming. Gurobi handles both, using presolve, simplex/barrier for the LP relaxation and branch-and-cut for the integer part.
Why not greedy
Greedy optimises the next local decision, which is fine for the simpler methods. But once packs, multiples, inventory limits, demand and capacity interact, filling the highest-demand store first can break a pack constraint or foreclose a better global distribution. A solver is the difference between a locally sensible answer and a globally good one.
“We used an existing Gurobi-based optimisation service for the constrained paths. I did not build the solver internals. My ownership was the system around it: preparing correct store-level input, calling it reliably, consuming output, persistence, and making the whole pipeline meet its SLA.”
1690 seconds → under 4
When the solver finished, it wrote output to GCS and called Post-processing, which read the output, transformed it into the application's allocation-result format, validated and coerced it, and persisted it. For a single PO that could be 200K rows or more — and the naive path was eating most of the SLA budget on its own.
the old path
rows = transformed_output_rows
for batch in chunks(rows, 1000):
INSERT INTO allocation_result_main (...) VALUES (...), (...), ...;
# 200K rows ÷ 1,000 = ~200 round trips, each paying:
# SQL + parameter construction, network latency, parse, plan, execute,
# WAL generation, index maintenance, transaction bookkeeping,
# plus Python loop overhead on top.the new path
- Read and transform the solver output.
- Use DuckDB for in-engine cleansing and schema-aware type coercion.
- Bulk-load into a PostgreSQL temporary table with COPY.
- Run any set-based validation or normalisation in SQL.
- INSERT INTO main SELECT … FROM temp — one set-based operation.
- Commit.
The win is not a faster insert. It is a different unit of work: hundreds of application-driven statements became one bulk ingestion plus one set-based move. DuckDB earns its place before the COPY by making the data schema-aligned on arrival, so Postgres is not re-validating and re-coercing row by row. The temp table earns its place by giving a staging area where dedup and normalisation happen in SQL before the final table is touched.
| Cost | Batched INSERT loop | COPY → temp → main |
|---|---|---|
| Network round trips | ~200 for 200K rows | One bulk load |
| Parse / plan overhead | Paid per batch | Amortised to near nothing |
| Python overhead | Looping, batching, parameter serialisation | Delegated to the bulk path |
| Database execution | Many separate inserts | Bulk ingest + one set-based insert |
| Type coercion | Repeated, inconsistent, in Python | Once, schema-aware, in DuckDB |
| Failure handling | Partial batches complicate recovery | Stage, then commit one result |
| Observed | ~90 seconds | under 4 seconds |
“The improvement came from cutting repeated application-to-database round trips and moving from row-oriented writes to a bulk load plus a set-based database operation.”
17Scale & capacity validation
A single PO passing is not evidence. The dimension that matters is intermediate row expansion multiplied by concurrency — so the load tests were built around concurrent POs, not throughput of one.
| Principle | How it shows up here |
|---|---|
| Bound work per request | Process picks 20 POs at a time, never the whole batch. |
| Fan out safely | Cloud Tasks per PO, with controlled concurrency and isolated retry queues. |
| Use durable state | Postgres tracks status, ownership, completion and outbox events. |
| Optimise the hot path | Polars for transformation, DuckDB COPY for persistence. |
| Never redo expensive work | Results are persisted before delivery, so delivery retries independently. |
| Make retries safe | Business-level idempotency on allocation_code. |
Load tests sustained 100 concurrent POs and roughly 150–200M row-level computations inside the SLA.
18Technology decision record
| Technology | Why | Alternative & trade-off |
|---|---|---|
| GCS | Durable file handoff between client, preprocessing, solver and output. | Direct API transfer — more coupling, more memory pressure. |
| Google Workflows | Stateful orchestration per PO type, with retries and loop-until-done. | A cron/scheduler has no state; Kafka is heavier than this needs. |
| Cloud Tasks | Retryable per-PO async execution with controlled dispatch. | Kafka is better for streams, weaker for HTTP work items with retry semantics. |
| PostgreSQL | Durable lifecycle, idempotency, lease, results and outbox in one place. | Redis is faster for ephemeral locks, insufficient as the only source of truth. |
| Polars | Transformation-heavy, memory-efficient, parallel — under an SLA. | Pandas handles one PO fine; less suited to concurrent heavy workloads. |
| Gurobi (existing service) | Mature solver for genuinely constrained allocation. | Greedy works for simple methods; building a solver is not a real option. |
| DuckDB | Fast local analytical transforms and schema-aware cleansing pre-load. | Python/Pandas coercion adds per-row overhead. |
| Temp table + COPY | Bulk staging and one set-based final insert. | 1,000-row batch inserts meant ~200 round trips and 90 seconds. |
| Outbox | Durably records finalisation intent so delivery can be replayed. | Direct upload then status update leaves an unsafe crash window. |
19Alternatives seriously considered
The four decisions most likely to be challenged, and the honest version of each argument — including what the rejected option would actually have been good at.
- Kafka instead of Cloud Tasks. Kafka is the better answer for a continuous event stream with multiple consumers and replay. This was discrete, per-PO, retryable HTTP work with a natural completion — Cloud Tasks gave that with far less operational surface. Trade-off accepted: weaker replay story, which the outbox and durable state cover instead.
- Redis for locking. Genuinely faster for high-frequency short-lived locks. Rejected because the state being protected already lived in Postgres, and adding Redis would have introduced a second consistency boundary while still needing durable completion state somewhere. Trade-off: more lock traffic on the primary database, acceptable at this concurrency.
- Pandas instead of Polars. Cheaper in team familiarity and ecosystem. Rejected on the concurrency profile, not on single-PO size. Trade-off accepted: a ramp-up cost on a newer library.
- SKIP LOCKED queue semantics. The natural instinct for a picking API. Rejected because the Workflow already serialised picking per batch — the actual threat was retry-after-partial-completion, which a lock does nothing about. Solving the wrong problem elegantly is still solving the wrong problem.
- Doing more work in SQL. Would have kept computation next to the data. Rejected because the business logic branches across 15–18 paths with derived calculations — far easier to structure, unit test and extend in application code with a dataframe engine.
20Cross-functional work & estimation
Four groups touched this: the ingestion team upstream, the optimisation team who owned the solver service, PM and EM on scope and dates, and the allocation team building the workflow. Most of the coordination cost was in contracts, not code.
- Ingestion. Agreeing what “validated” means and which statuses the allocation flow is allowed to pick — the -120 / -100 split is that agreement written into the schema.
- Optimisation team. A service contract: what store-level input looks like, how allocation_code, configuration and GCS paths are passed, what the callback guarantees, and what happens on failure. This is the boundary where most integration bugs would otherwise live.
- PM. Translating “fast enough” into a number. The 15-minute SLA is what made the persistence rewrite fundable rather than a nice-to-have.
- EM. Sequencing — specifically the argument for spending a milestone on correctness hardening before scale testing, on the grounds that load-testing an incorrect system just produces confident wrong numbers.
How the estimates were built
Estimated per milestone rather than per ticket, with the two genuinely unknown areas — solver integration behaviour and persistence throughput — explicitly time-boxed as spikes before committing dates. The 15–18 processing combinations were estimated as one framework plus N strategies, so the cost curve was flat per additional method instead of linear in unknowns.
One concrete instance where you converted ambiguity into scope: what was unclear, the options you put in front of them, the trade-off you recommended, what got cut or deferred, and the outcome. A named decision beats a description of the process.
21Mentorship
The question is never “did you help people”. It is whether someone's scope grew because of you, and whether you can name it. Structure each story the same way: where they started, what the gap was, what you actually did, what they owned afterwards.
| Track record | Context |
|---|---|
| Tech Lead — Impact Analytics | Led the allocation platform team; design reviews, delivery and production ownership. |
| 5 developers mentored — WorkIndia | Directed delivery across design, implementation and production support. |
| 3 engineers led — Blowhorn | Last-mile delivery and the Kirana module, built from inception. |
The one story to prepare properly
Who they were, their level, and what they could not yet do on their own.
Be specific and technical — e.g. could implement a feature but not reason about failure modes; wrote correct code but not testable code; strong locally, avoided cross-team design conversations.
The concrete mechanism: pairing on a design doc, handing over a scoped-but-real piece of the workflow, review comments aimed at reasoning rather than syntax, putting them in front of the partner team on purpose.
What they owned afterwards that they did not own before, and the ladder movement: promotion, scope increase, becoming the on-call owner of a component. That sentence is the whole answer.
“X was strong at implementation but had not owned a distributed failure story. I handed them the finalisation and outbox path with the failure matrix as the spec, reviewed their design rather than their diffs, and had them present it to the optimisation team. They ended up owning that component end to end, including on-call, and moved to [level] at the next cycle.”
22Self-initiated work
Vendor-driven and reactive work does not demonstrate much. Two things here were not asked for by anyone.
Inside this project — the reliability and performance rework
Nobody filed a ticket saying “batch IDs generated server-side will strand POs on retry”. That came from sitting down and enumerating what happens if each API dies between its side effect and its response. The same applies to the persistence rewrite: the requirement was an SLA, not a technique — profiling the stages and finding that one step was spending 90 seconds was self-directed, and the DuckDB/COPY design was a proposal, not an assignment.
Outside it — mtptrace
An org-wide OpenTelemetry library, started because debugging across FastAPI microservices was guesswork and the commercial APM bill was real. Runtime module discovery, wrapt-based auto-instrumentation, AST analysis, build-time caching, configurable sampling, error-preserving span filters, tenant-aware tracing, trace–log correlation and automatic PostgreSQL query visibility — packaged so a team gets tracing by adding an import. It replaced the commercial APM with Grafana Tempo and cut trace-storage cost by roughly $21,000 a year.
That is the cleaner “innovation and initiative” answer, because it was invented rather than assigned, adopted beyond one team, and had a number attached.
“Two kinds. Within the allocation platform, the reliability model and the persistence rewrite were both things I identified and proposed rather than picked up. Separately, I built mtptrace — an OpenTelemetry library adopted across our FastAPI services that replaced a commercial APM and saved about $21K a year. That one started as nobody's project.”
23Learning curve
Three things this project actually taught, stated as principles rather than war stories.
- Identity before side effect. If the server invents the identifier after it starts mutating state, a lost response is unrecoverable. Generate it at the caller. This one rule would have prevented the original Init bug and generalises to every retryable API.
- Idempotency belongs to the business operation. Task IDs, retry counts and message IDs describe transport. Key on the thing the business considers one operation, or duplicates find a way through.
- The bottleneck is rarely the interesting code. The expensive part of this pipeline was not the optimisation or the transformations — it was writing rows to a database in the obvious way. Profile before optimising anything you find intellectually appealing.
Ramp-up cost worth naming honestly: Polars was new to the team, and reasoning about lease durations versus worst-case processing time took iteration to get right — a lease too short causes duplicate work, too long causes stuck POs.
24Outcome & metrics
| Metric | Before | After |
|---|---|---|
| Result persistence per PO | ~90 seconds | under 4 seconds (>20×) |
| PO pickup | Manual intervention possible | Fully automatic on validation |
| Retry after partial failure | Stranded POs, SLA breach | Safe replay, no duplicate work |
| Duplicate final uploads | Possible on crash | Deterministic path, idempotent |
| Validated concurrency | — | 100 concurrent POs in SLA |
| Computation volume in SLA | — | 150–200M row-level computations |
Go-live date and outcome, share of POs processed automatically, SLA adherence in production, manual effort removed, incident/rework reduction. Engineering metrics prove competence; a business number is what makes it a staff-level story.
25The hardest part
Two candidate answers. Lead with the first — it is a reasoning story, not a tuning story — and keep the second as the follow-up when they ask about performance.
1. Correctness across five systems that each promise only at-least-once
The difficulty was not any single mechanism; it was recognising that Workflow, Cloud Tasks, Postgres, GCS and the solver service cannot be made to agree atomically, and that every gap between a side effect and its acknowledgement is a distinct failure mode with a distinct fix. Enumerating those windows produced the failure matrix; the matrix produced the design. Caller-generated identity, a business-level idempotency key, an owner-and-lease protocol with atomic takeover, and an outbox with deterministic destinations are four different answers to four different questions — treating them as one “make it idempotent” task is how these systems end up subtly broken.
The subtlest single decision was the lease. It converts “a worker is processing” from an assertion into a claim with an expiry, which is the only version of that statement that survives a process being killed.
2. Persistence, once it turned out to be the bottleneck
Harder in a different way: the code was correct, readable and obviously slow only once measured. The fix required stepping outside “make the insert faster” and changing the unit of work entirely — bulk load into staging, then one set-based move.
26Question drill
Read the question, answer it out loud, then open the card and compare.
Scope, ownership, leadership
What exactly did you own — and what didn't you?
Owned: architecture, orchestration, the state and idempotency model, the preprocessing pipeline, solver integration, persistence performance, reliability and capacity validation.
Did not own: the Gurobi service's mathematical formulation and internals, and the upstream ingestion implementation. Say the second half unprompted — it buys credibility for the first.
How did you break the work down?
Six milestones: contract and state model → single-PO happy path → the 15–18 business paths behind a strategy interface → correctness hardening → performance and capacity → go-live and observability. Correctness deliberately preceded load testing.
How did you collaborate with your PM and EM?
- PM: turned “fast enough” into the 15-minute SLA, which is what justified the persistence rewrite.
- EM: argued for and won a hardening milestone before scale testing.
- Both: milestone-level estimates with the two unknown areas time-boxed as spikes first.
Have one specific ambiguity-to-scope decision ready with a name and an outcome.
How did you mentor engineers?
One story, four beats: where they started, the specific technical gap, the mechanism you used (scoped real ownership plus design-level review, not diff-level), and what they owned afterwards including any level change.
What was self-initiated rather than assigned?
Within the project: the failure-mode enumeration that drove the reliability design, and the persistence rewrite. Outside it: mtptrace, the org-wide OpenTelemetry library that replaced a commercial APM and saved ~$21K/year.
Technical
Why Google Workflows?
Stateful orchestration with retries and loop-until-complete semantics, and a clean separation of the three PO-type flows. A scheduler alone carries no state between calls.
Why Cloud Tasks and not Kafka?
Kafka is stronger for event streams with multiple consumers and replay. This was discrete, per-PO, retryable HTTP work with a defined completion — Cloud Tasks fit it with much less operational weight.
Why is allocation_code the idempotency key?
Because it identifies the business operation. Task IDs and retry counts identify transport attempts — a duplicate task for the same PO has a different task ID but the same allocation_code, and that is exactly the case you need to catch.
Why Postgres for idempotency? When would Redis be better?
Postgres because the protected state already lived there — one consistency boundary, durable across restarts, transactional with the business state.
Redis when the coordination is high-frequency and ephemeral — cache stampede prevention, rate limiting, short-lived task coordination — and duplicate execution is either tolerable or protected somewhere else.
Isn't SELECT … FOR UPDATE enough?
No. The lock dies with the transaction. It serialises two concurrent transactions but records nothing durable, so a retry ten minutes later proceeds happily. Locks handle concurrency; durable state and idempotency handle retries.
Why Polars over Pandas?
Not because 180K rows is too much for Pandas — it is not. Because several POs run concurrently on one worker with transformation-heavy pipelines, memory constraints and an SLA. Polars' Rust columnar engine and parallelism fit that profile.
Why a solver at all — couldn't you write the allocation rules yourself?
For the simpler methods, yes, and those are handled deterministically. Once pack constraints, allocation multiples, inventory limits, demand and store capacity interact, greedy choices are locally sensible and globally poor. That is a constrained optimisation problem, and it belongs in a mature MILP solver.
Why did DuckDB COPY make persistence 20× faster?
It changed the unit of work. Batched inserts meant ~200 round trips, each paying parse, plan, WAL and Python overhead. The new path does schema-aware coercion once in DuckDB, one bulk COPY into a temp table, then a single set-based insert into the main table.
What happens if the Gurobi service fails?
Retry keyed on allocation_code and the GCS paths, with statuses observable throughout. Because downstream completion is durable, a retry cannot duplicate work that already finished.
Does the outbox give you exactly-once delivery?
No, and it is worth being precise about that. The outbox durably records the intent to upload alongside the state change. The upload itself is made safe separately, by deriving the destination path deterministically from allocation_code so a retry rewrites the same object.
What would you redesign?
- Introduce the outbox earlier — at Process dispatch, not just at finalisation.
- Tighten the status model; some statuses carry stage-specific meaning that is easy to misread.
- Formal ownership heartbeats and lease metrics rather than relying on a conservative TTL.
- Alerting on leases that expire without completion — the earliest signal that something is wedged.
27What not to say
| Do not say | Say instead |
|---|---|
| “I built the Gurobi solver.” | The service already existed; I integrated it and owned the workflow around it. |
| “Polars was required — 180K rows is too much for Pandas.” | Pandas handles one PO; Polars was chosen for concurrent transformation-heavy work under an SLA. |
| “Cloud Tasks gave us exactly-once processing.” | Cloud Tasks gives at-least-once; idempotency is what makes duplicates safe. |
| “The outbox solved exactly-once upload.” | The outbox made finalisation durable; idempotent upload made retries safe. |
| “FOR UPDATE solves duplicates.” | Locks solve concurrent transaction conflicts; durable state solves retries and late duplicates. |
| “We processed millions of rows.” | Name the shape: 300 rows × 600 stores per PO, ~1.8M intermediate rows per worker. |
28Gaps to fill before the interview
Six facts this write-up cannot supply. Each one is a question a hiring manager will ask, and a blank is more damaging than a modest number.
- Project duration. Months from design to production, split by phase.
- Team composition. Backend, ingestion, optimisation, PM, EM, QA — and who you led.
- Your own code. Named modules, APIs and functions you personally wrote or rewrote.
- Business impact. Go-live outcome, automation rate, SLA adherence, manual effort removed, incidents avoided.
- PM/EM story. One concrete instance of turning ambiguity into scope and a technical plan.
- Mentorship story. One engineer: gap, coaching, and what they owned afterwards.
“The biggest design principle was making every distributed boundary recoverable. Deterministic business identifiers, durable state, leases, idempotent consumers, bulk persistence and outbox-backed finalisation — so retries and crashes became normal operating conditions instead of production incidents.”
That's the whole system. The other five are back on the projects page — or the terminal on the home page takes questions.
← back to projects