A terabyte a run, and not one byte in RAM.
A scheduler-triggered export platform that partitions a BigQuery table, writes each partition to GCS server-side, and streams it to a client's SFTP box — ~1 TB per run, 50 partitions in flight, on a 16 GB worker. The whole design is an argument about where data is allowed to exist, and what happens when any step is retried.
00The one-pager
Everything below in a single screen.
Deliver large tenant-specific datasets from a BigQuery table owned by another team to each client's SFTP location, on a schedule, without missing a row and without a human watching it.
- ~1 TB per run against a 16 GB worker.
- SFTP is a remote box we neither own nor can scale.
- Cloud Tasks retries — every stage can run twice.
- A partially-written remote file looks complete to the client.
Scheduler → orchestrator → partition planner → one Cloud Task per partition at concurrency 50 → BigQuery exports server-side to GCS → worker streams GCS to SFTP in 32 MB chunks. Postgres holds run state, leases and outbox events.
- Deterministic run_id from tenant + job + schedule time.
- A per-run temp table carrying partition_number.
- EXPORT DATA instead of pulling rows into Python.
- Stream to a .tmp path, then rename.
- operation_key + owner + lease per stage.
- Outbox for every cross-system handoff.
At-least-once execution, eventually consistent completion. A partition is either delivered or it is in the failure table and the email report. There is no third outcome.
No tenant name appears in the code. Source table, bucket, remote path template, partition size, format, delimiter, compression, credentials and schedule are all configuration — so a new client is a config row, not a release.
01The high-level design
Start here. Everything after this section is a zoom into one box on this diagram.
The organising principle is that orchestration and execution are different jobs. The scheduler does nothing but fire. The orchestrator writes control-plane rows. Cloud Tasks owns fan-out, retry and rate limiting. Workers move exactly one partition each and know nothing about the run as a whole.
| Component | Responsibility | Deliberately does not |
|---|---|---|
| Cloud Scheduler | Fires the orchestrator at the tenant's configured time. | Know anything about partitions or data. |
| Orchestrator API | Derives run_id, loads tenant config, creates the run row. | Touch BigQuery or move data. |
| Partition planner | Counts rows, builds the temp table, writes the manifest, emits per-partition events. | Export anything itself. |
| Cloud Tasks | Async fan-out, bounded concurrency, retry with backoff. | Guarantee exactly-once delivery. |
| Export worker | Submits one EXPORT DATA job for one partition. | Read rows into process memory. |
| SFTP worker | Streams one GCS object to one deterministic remote path. | Download the file first. |
| Postgres control DB | Run state, partition state, leases, outbox, failures. | Store any of the actual payload. |
| Outbox relay | Claims durable intents and performs the side effect. | Make external calls exactly-once. |
“Data never enters the application. BigQuery writes to GCS server-side, and the worker moves the GCS object to SFTP a chunk at a time — so the only thing that scales with file size is time, not memory.”
02The two-minute answer
“We had to deliver large tenant datasets out of BigQuery to client SFTP locations on a schedule — up to around a terabyte per run. The naive version of this is a job that queries the table, builds a file and uploads it, and that dies immediately: you cannot hold a terabyte, or even a 700 MB partition times fifty, in a 16 GB worker.”
“So the design has two halves. The first is making the work divisible and deterministic. A scheduler triggers an orchestrator, which derives a run ID from tenant, job and scheduled time — so a retry maps to the same logical run. A planner counts the source rows, creates a run-specific BigQuery table with a partition_number column assigned by row number over a stable business key, and writes one manifest row and one Cloud Task per partition.”
“The second half is making sure data never lands in the application. The export worker doesn't SELECT rows — it submits a BigQuery EXPORT DATA job that writes the partition straight to a deterministic GCS path. The SFTP worker then streams that object in 32 MB chunks to a temp remote path and renames it at the end, so the client never sees a half-written file. Fifty concurrent workers times a 32 MB buffer is about 1.6 GB, which fits comfortably.”
“Everything is at-least-once. Cloud Tasks retries, responses get lost, a worker can upload and then crash before updating the database. So every stage claims an operation key in Postgres with an owner and a lease, the GCS URI and the remote path are deterministic, and every handoff between stages goes through a transactional outbox. If a partition still fails after max attempts, it lands in a failure table and an email report — the only acceptable failure is a loud one.”
“And none of it is tenant-specific. Source table, bucket, remote path template, partition size, format and credentials are all config, so onboarding a new client is a config row rather than a code change.”
03Scope & ownership
| Area | Mine | Someone else's |
|---|---|---|
| Source dataset correctness | Consumed the contract; validated row counts | Upstream analytics team |
| Partitioning strategy & temp table | Design + implementation | — |
| Orchestrator, planner, both workers | Design + implementation | — |
| Control data model, idempotency, outbox | Design + implementation | — |
| Memory & concurrency model | Owned | — |
| Tenant config & onboarding path | Owned | — |
| Client SFTP endpoint | Negotiated throughput and path conventions | The client |
| Cloud Tasks / GCS / IAM provisioning | Specified queue settings and quotas | Platform / DevOps |
Months from design to first production run, and who else wrote code on it. Say what you led.
How many tenants and jobs ended up on it. “Generic” only becomes a platform claim when a second and third tenant onboarded without a code change — that is the number to have.
04Problem & requirements
A client needs their data, as files, on their own SFTP server, every day at a time they chose. The upstream team owns the BigQuery table. Everything between those two facts was ours: partitioning, export, upload, retry, reporting, and the guarantee that nothing goes missing quietly.
Functional
- Run automatically at the configured schedule, per tenant and per job.
- Read from a configured BigQuery source table.
- Create an expiring work table carrying partition_number 1…N.
- One task per partition; one deterministic GCS object per partition.
- Upload each partition to the configured client SFTP destination.
- Drive everything — table, bucket, path template, format, delimiter, compression, credentials, schedule — from tenant config.
- Produce a report showing success and failure per run and per partition.
Non-functional, and what each one forced
| Requirement | Design consequence |
|---|---|
| No full-file buffering in app memory | Server-side export, plus chunked streaming — the two decisions the whole design rests on. |
| Bounded concurrency | A queue that enforces it, not a worker pool that hopes. Protects BigQuery, GCS and the client's SFTP box. |
| At-least-once execution | Every stage idempotent on a business key; deterministic destinations everywhere. |
| Eventually consistent completion | Retries must finish delayed work with no human in the loop. |
| No silently missed partition | A terminal failure must produce a row and an email, not a log line. |
| Reusable across tenants | Zero tenant identifiers in code paths. |
| Observable | Per-run and per-partition state queryable at any moment mid-run. |
05The control data model
Neither BigQuery nor an SFTP server can tell you whether a run succeeded. BigQuery knows about jobs; SFTP knows about files. Only a control database knows that 1,412 partitions were planned, 1,411 are uploaded, and one failed three stages ago.
The state machine matters because it is what a retry reads. A task arriving for a partition already marked UPLOADED returns 200 and does nothing — the work is not repeated, and Cloud Tasks stops retrying because it got a success.
06Scheduler & run start
The entry point is deliberately thin. It validates config, derives an identity, records the run, and hands off. If it did real work, a scheduler retry would double it.
POST /runs/start
{ "tenant_id": "tenant_a",
"job_name": "daily_sftp_export",
"scheduled_at": "2026-09-14T20:00:00+05:30" }
run_id = tenant_id + job_name + scheduled_date_time
operation_key = RUN_START:{run_id}The run ID is derived, not generated. Same tenant, same job, same scheduled occurrence — same ID, forever. That single choice is what makes the scheduler safe to retry.
INSERT INTO transfer_runs (run_id, tenant_id, status, created_at) VALUES (:run_id, :tenant_id, 'PLANNING', NOW()) ON CONFLICT (run_id) DO NOTHING;
“The identity of a scheduled occurrence should be computable from its inputs. If the server invents it, the caller has no stable way to ask ‘did this one already happen?’ — and a lost response becomes a duplicate run.”
07Partitioning strategy
Partitioning is what turns one terabyte-sized problem into 1,400 independently retryable ones. The planner counts the eligible rows, divides by the tenant's partition size, and materialises a run-specific table that carries the partition number as a column.
CREATE OR REPLACE TABLE `project.transfer_work.transfer_run_20260914_203000`
OPTIONS (expiration_timestamp = TIMESTAMP_ADD(CURRENT_TIMESTAMP(), INTERVAL 36 HOUR)) AS
WITH numbered AS (
SELECT t.*, ROW_NUMBER() OVER (ORDER BY t.stable_business_key) AS rn
FROM `project.analytics.table1` AS t
WHERE t.tenant_id = @tenant_id AND t.export_date = @export_date
)
SELECT * EXCEPT(rn),
CAST(CEIL(rn / @partition_size_rows) AS INT64) AS partition_number
FROM numbered;Why materialise a table at all
- Retries read identical content. The same run_id + partition_number always yields the same rows.
- The expensive assignment happens once. Not once per retry, and not 1,400 times.
- Workers get tiny payloads. A task carries a run ID and an integer.
- Cleanup is automatic. A 36-hour expiry means no orphaned work tables accumulate.
ROW_NUMBER() needs a stable ordering key. Order by something non-deterministic and partition 42 contains different rows on a retry than it did on the first attempt — which means rows silently duplicated across files and rows silently missing from all of them. Nothing errors. The client just gets wrong data.
CEIL(ROW_NUMBER()) vs NTILE
| Approach | Gives you | Use when |
|---|---|---|
| CEIL(ROW_NUMBER() / size) | Fixed partition size; the last partition is a remainder. | File size matters — e.g. the client caps file size, or you are sizing for memory. |
| NTILE(N) | Fixed partition count, evenly balanced. | Wave count matters more than file size, and you want uniform worker durations. |
We used the first because partition size is what maps to export duration and SFTP transfer time — the two things that had to stay predictable.
08Fan-out & bounded concurrency
With the manifest written, the planner creates one Cloud Task per partition. The queue — not the application — enforces how many run at once.
for partition_number in range(1, partition_count + 1):
gcs_uri = f"gs://{bucket}/{tenant_id}/{run_id}/part-{partition_number:05d}.csv.gz"
remote_path = render_remote_path(template, run_id, partition_number)
insert transfer_partitions(run_id, partition_number,
gcs_uri, remote_path, status='DISCOVERED')
create Cloud Task → POST /partitions/export
{ "run_id": run_id,
"partition_number": partition_number,
"operation_key": f"BQ_EXPORT:{run_id}:{partition_number}" }Both destinations are computed now, written to the manifest, and never recomputed. A retry three hours later writes to exactly the same GCS object and exactly the same remote path — which is what makes the retry an overwrite instead of a duplicate.
“Application-level concurrency limits are per-process. Scale to three instances and you have three times the limit you thought you had — against a client SFTP box that will start refusing connections. Putting the limit in the queue makes it a property of the system rather than of a deployment.”
09The export worker
One worker, one partition. It does not query rows — it asks BigQuery to write them.
EXPORT DATA OPTIONS (
uri = 'gs://tenant-export-bucket/tenant_a/{run_id}/part-00042-*.csv.gz',
format = 'CSV',
compression = 'GZIP',
overwrite = true,
header = true,
field_delimiter = ','
) AS
SELECT * EXCEPT(partition_number)
FROM `project.transfer_work.transfer_run_20260914_203000`
WHERE partition_number = 42;The worker submits the job and polls for completion. What it holds in memory is job metadata — an ID and a status — not 700 MB of rows. BigQuery scans the partition and writes to GCS entirely server-side, which also means the bytes never cross our network.
overwrite = true is safe here precisely because of the temp table: the same partition of the same run produces byte-identical output, so a re-export replaces the object with the same content. Without the deterministic partitioning upstream, that flag would be dangerous.
On durable success the worker moves the partition to GCS_EXPORTED and — in the same transaction — inserts the SFTP_UPLOAD outbox event.
10The SFTP worker
The other half of the memory argument. The object is read from GCS and written to SFTP in bounded chunks, and it is written to a temporary name first.
def upload_partition_to_sftp(gcs_uri, remote_final_path):
remote_tmp_path = remote_final_path + ".tmp"
with gcs.open(gcs_uri, "rb") as src: # a stream, not a download
with sftp.open(remote_tmp_path, "wb") as dst:
while True:
chunk = src.read(32 * 1024 * 1024) # 32 MB
if not chunk:
break
dst.write(chunk)
sftp.rename(remote_tmp_path, remote_final_path) # atomic-ish revealClients poll their inbox directory. Write directly to the final path and a crash mid-transfer leaves a file that looks finished — correct name, plausible size — and gets ingested. The temp-then- rename pattern means a partial transfer is visibly a .tmp file that nothing picks up, and the final name appears only when the bytes are all there.
On a retry the worker checks first: if the final path already exists with the expected byte size and checksum, it marks the partition uploaded without re-sending a single byte. If it finds a stale .tmp, it restarts that partition cleanly.
11The memory argument
This is the part of the design most worth being able to draw from memory, because it is the part an interviewer can attack with arithmetic.
| Approach | Worker memory | Verdict |
|---|---|---|
| SELECT rows into Python, write a file | Hundreds of MB per task + object overhead | OOM at concurrency 50 |
| BigQuery EXPORT DATA → GCS | Job metadata only | The export answer |
| Download the GCS object, then upload | 700 MB × 50 = 35 GB | 2.2× the server |
| Stream GCS → SFTP in chunks | 32 MB × 50 ≈ 1.6 GB | The upload answer |
50 × 700 MB is 35 GB, not 3.5 GB. I got this wrong once while reasoning about it, and the error is worth remembering: at 3.5 GB the buffered design looks survivable on a 16 GB box, so you never go looking for the streaming one. An order-of-magnitude slip does not make a design slightly worse — it makes a broken design look fine.
The other reason streaming wins is that memory is the one resource that fails catastrophicallyrather than gradually. A slow pipeline delivers late; a pipeline that OOMs at partition 900 of 1,400 loses its worker mid-flight, and every in-flight partition has to be reclaimed by lease expiry.
12Operation keys & leases
Cloud Tasks gives at-least-once delivery. A task can run twice; a worker can finish and die before responding; a duplicate task can be created for the same partition. So the key has to identify the logical operation, not the transport attempt.
| Stage | Operation key | Durable completion check |
|---|---|---|
| Run start | RUN_START:{run_id} | A row exists in transfer_runs |
| Planning | PLAN_PARTITIONS:{run_id} | Manifest rows exist for the run |
| Export | BQ_EXPORT:{run_id}:{n} | Partition status ≥ GCS_EXPORTED |
| Upload | SFTP_UPLOAD:{run_id}:{n} | Status UPLOADED, or the remote file matches expected metadata |
| Report | EMAIL_REPORT:{run_id}:{type} | Report event completed |
-- claim
INSERT INTO operation_idempotency
(operation_key, status, owner_id, lease_expires_at, attempt_count)
VALUES (:operation_key, 'IN_PROGRESS', :owner_id, NOW() + INTERVAL '10 minutes', 1)
ON CONFLICT (operation_key) DO NOTHING;
-- take over only from an owner that looks dead
UPDATE operation_idempotency
SET owner_id = :new_owner_id,
lease_expires_at = NOW() + INTERVAL '10 minutes',
attempt_count = attempt_count + 1
WHERE operation_key = :operation_key
AND status = 'IN_PROGRESS'
AND lease_expires_at < NOW(); -- 1 row = took over, 0 rows = someone else owns it
-- complete only if we still own it
UPDATE operation_idempotency
SET status = 'COMPLETED', completed_at = NOW()
WHERE operation_key = :operation_key
AND owner_id = :owner_id;The lease is what separates “a worker is doing this” from “a worker said it was doing this before it was killed”. Without an expiry, a crashed worker holds a partition forever and the run never completes. With one, the work returns to the pool automatically — no operator, no cleanup job.
“Cloud Tasks gave us at-least-once delivery; Postgres made repeated delivery safe. The key was the business operation — run plus partition plus stage — because a duplicate task has a different task ID but the same operation.”
13The outbox
Three places in this pipeline need a database change to reliably cause something outside the database: planning must create export work, a finished export must trigger an upload, and a finished run must send a report. Each is a dual-write, and each gets an outbox.
BEGIN;
UPDATE transfer_partitions SET status = 'GCS_EXPORTED' WHERE …;
INSERT INTO outbox_events (event_type, operation_key, run_id, payload_json, status)
VALUES ('SFTP_UPLOAD', 'SFTP_UPLOAD:{run_id}:{n}', :run_id, :payload, 'PENDING')
ON CONFLICT (operation_key) DO NOTHING;
COMMIT;The relay then claims events under a lease, exactly like the stage handlers:
while True:
events = claim_pending_events(limit=100, lease='5 minutes')
for event in events:
try:
execute_event_idempotently(event)
mark_completed(event)
except RetryableError as e:
release_or_extend_for_retry(event, e)
except TerminalError as e:
mark_failed_and_record_failure(event, e)A transactional outbox does not make an external side effect exactly-once. It guarantees the intent is durably recorded alongside the state change. The relay can still perform the upload and crash before marking the event complete — so the upload itself has to be idempotent, which is what the deterministic GCS URI and remote path are for.
14The failure matrix
Every row here is a crash window that was written down before it happened in production.
| Failure point | Risk | Protection |
|---|---|---|
| Scheduler fires twice | Duplicate run | Derived run_id + ON CONFLICT DO NOTHING |
| Orchestrator dies after creating the run | Run exists, never planned | Retry sees the run and continues planning |
| Temp table made, DB not updated | Planning looks incomplete | Deterministic table name; CREATE OR REPLACE is safe to repeat |
| Cloud Task created, DB update fails | Duplicate task for one partition | Worker idempotency on operation_key |
| Export succeeds, worker crashes before status update | Re-enters the export stage | Deterministic GCS object; re-export is byte-identical |
| SFTP transfer partially writes | Client ingests a truncated file | .tmp path + rename only on completion |
| Upload succeeds, crash before DB update | Re-uploads the same partition | Final path deterministic; metadata match ⇒ mark done |
| Worker killed mid-partition | Partition stuck IN_PROGRESS | Lease expiry + atomic takeover |
| Retries exhausted | Partition never delivered | Failure row + terminal state + email report |
The only acceptable miss is a loud one
INSERT INTO transfer_failures
(run_id, partition_number, stage, error_code, error_message, attempts, failed_at)
VALUES (:run_id, :partition_number, :stage, :error_code, :message, :attempts, NOW());
UPDATE transfer_partitions SET status = 'FAILED_TERMINAL' WHERE …;
INSERT INTO outbox_events (event_type, operation_key, run_id, payload_json, status)
VALUES ('EMAIL_REPORT', 'EMAIL_REPORT:' || :run_id || ':FAILURE', :run_id, :payload, 'PENDING')
ON CONFLICT (operation_key) DO NOTHING;A partition that cannot be delivered after max attempts is a business problem, not an engineering secret. It gets a row, a terminal state and a line in an email naming the run, the partition and the stage that failed.
15Throughput & capacity
partitions = ceil(total_rows / partition_size_rows) waves = ceil(partitions / concurrency) run_time ≈ waves × avg_partition_time + orchestration_overhead worked example ~1 TB run, ~700 MB partitions → ~1,400 partitions concurrency = 50 avg partition time = 120 s (export + stream + verify) waves = 28 run_time ≈ 28 × 120 s ≈ 56 min
Four levers move that number, and only two of them are ours: partition size, average partition duration, concurrency, and SFTP throughput. The last one belongs to the client, and in practice it is the binding constraint — which is why the honest answer to “why not concurrency 200?” is that the destination would start refusing connections long before our workers broke a sweat.
| To go faster | Cost |
|---|---|
| Raise concurrency | More BigQuery slot pressure, more GCS requests, more SFTP connections — capped by the client. |
| Smaller partitions | More waves and more per-task overhead; more, smaller files for the client to handle. |
| Larger partitions | Fewer waves, but a failed partition costs more to redo and export duration grows. |
| Better compression | Less to transfer, more CPU in BigQuery — usually the cheapest win available. |
16Tenant-generic configuration
The thing that turned this from a job into a platform: no tenant identifier appears in a code path. Everything that differs between clients is a row.
{
"tenant_id": "tenant_a",
"job_name": "daily_sftp_export",
"source_table": "project.analytics.table1",
"work_dataset": "project.transfer_work",
"gcs_bucket": "tenant-export-bucket",
"gcs_prefix_template": "{tenant_id}/{run_id}/",
"remote_path_template": "/incoming/{tenant_id}/{export_date}/part-{partition_number:05d}.csv.gz",
"partition_size_rows": 500000,
"file_format": "CSV",
"compression": "GZIP",
"delimiter": ",",
"include_header": true,
"sftp_secret_id": "projects/.../secrets/tenant-a-sftp",
"cloud_tasks_queue": "bq-sftp-export-prod",
"max_concurrency": 50,
"max_attempts": 10,
"schedule": "0 20 * * *"
}Two details worth pointing at. Path templates rather than path builders — the client dictates their directory convention, and a template absorbs that without a code branch. And credentials by secret reference, never by value, so onboarding never involves a config row containing a password.
max_concurrency and max_attempts are per-tenant for the same reason: one client's SFTP box handles 50 parallel connections comfortably and another falls over at 10.
17Metrics & alerts
Mid-run, the question is never “is it running?” — it is “will it finish in time, and is anything stuck?”. The metrics are chosen to answer that.
| Area | What to watch | What it catches |
|---|---|---|
| Run health | Runs started / completed / failed, duration p95 | Schedule drift, runs that never finish |
| Partition progress | discovered / exported / uploaded / failed / pending | Whether the run will make its window |
| Cloud Tasks | Queue depth, retry count, oldest task age | A stage failing repeatedly and silently |
| BigQuery | Export job duration, bytes processed, quota errors | Slot contention with other workloads |
| SFTP | Throughput, connection and auth failures, rename failures | The client's box throttling or expiring credentials |
| Idempotency | Lease takeovers, stuck IN_PROGRESS rows | Workers dying mid-partition |
| Outbox | Pending events, event age, relay lag | Intents recorded but never executed |
Two of those are the early-warning pair: lease takeovers rising means workers are dying, and outbox event age rising means the relay has stopped making progress. Both show up before the run misses its window.
18Decision record
| Decision | Why | Alternative & trade-off |
|---|---|---|
| Cloud Tasks fan-out | HTTP retry, backoff and dispatch limits for free | Kafka / Pub-Sub worker pool — higher streaming scale, much more consumer infrastructure |
| Per-run BigQuery temp table | Deterministic, cheap retries | Recompute partitions per worker — cheaper to write, wrong under retry |
| EXPORT DATA to GCS | Bytes never enter the application | Pull rows and write the file — simple, and it OOMs |
| Chunked streaming to SFTP | Bounded memory regardless of file size | Download then upload — 35 GB at concurrency 50 |
| Temp path + rename | A partial transfer is never mistaken for a complete file | Write to the final path — faster, and it corrupts the client's ingestion |
| Postgres for idempotency | Durable, transactional, next to the business state | Redis lock — faster, not sufficient as the only completion record |
| Derived run_id / operation_key | Retries can find their own prior work | Server-generated IDs — a lost response becomes a duplicate run |
| Outbox on every handoff | Intent survives a crash between stages | Call the next stage directly — an unprotected dual-write window |
| Failure table + email | Auditable list of what did not land | Logs and alerts only — nobody can answer “what did the client miss?” |
| Config-driven everything | A new tenant is a row | Per-tenant code paths — faster for tenant one, unmaintainable by tenant five |
19Alternatives, argued fairly
- A managed data-transfer service. Genuinely less code. Rejected because the requirement was per-tenant partitioned files with client-dictated naming, compression and directory conventions, plus a per-partition audit trail — the shaping and reporting is most of the work, and that is the part a managed mover does not do.
- Kafka or Pub/Sub instead of Cloud Tasks. Better for continuous event streams with multiple consumers and replay. This is discrete, bounded, per-partition HTTP work with a natural completion, and Cloud Tasks gives retry semantics and a dispatch-rate limit without a consumer fleet. Trade-off accepted: a weaker replay story, which the outbox and durable state cover.
- One big export instead of partitions. BigQuery will shard a large export for you. Rejected because the unit of retry then becomes the whole terabyte — one SFTP failure at 95% and you start again. Partitions make failure cheap and progress observable.
- Spark or Dataflow. The right answer if this were a transformation problem. It is a movement problem with a shaping step that BigQuery already does, so a distributed compute framework would be operational weight bought for nothing.
- Redis for the locks. Faster, and genuinely better for high-frequency short-lived coordination. Rejected because the protected state already lives in Postgres, and a durable completion record has to exist somewhere regardless — adding Redis would mean two consistency boundaries instead of one.
- Push to client cloud storage instead of SFTP. Strictly better technically. Not available: SFTP was what the client could consume, and the design has to end where the client is.
20Cross-functional work
- Upstream analytics team. The contract was which table, which filter columns, and — critically — which column is a stable business key. The partitioning correctness argument depends entirely on that answer, so it had to be theirs explicitly rather than my assumption.
- The client. Directory convention, file naming, compression, how many parallel connections their box tolerates, and what they do with a .tmp file. Concurrency 50 is a negotiated number, not a computed one.
- Platform / DevOps. Cloud Tasks queue configuration, GCS lifecycle rules, service-account IAM, and Secret Manager entries per tenant.
- PM. Turning “daily” into a delivery window, which is what made partition size and concurrency answerable numbers rather than guesses.
One concrete negotiation — most likely the SFTP throughput ceiling or the stable-key question — where you converted an ambiguous external constraint into a design parameter. Name the constraint, the options, and what got decided.
21Mentorship
This project has an unusually good teaching surface: the failure matrix is a ready-made curriculum in distributed-systems reasoning, and each row is a self-contained lesson.
- “Where can this crash?” as a review question. Asking it of every handler teaches the dual-write problem far better than explaining the dual-write problem.
- Deterministic identity as a transferable idea. Once someone sees why run_id is derived rather than generated, they apply it everywhere — it is the same lesson as the batch-ID bug on the allocation platform.
- Hand over a stage, not a ticket. A whole stage — the SFTP worker, say — comes with its own failure modes, its own idempotency argument and its own on-call surface.
One engineer: where they started, the specific gap, what you did (pairing, scoped ownership, design-level review), and what they owned afterwards — plus any level change that followed.
22Self-initiated work
The delivery requirement was assigned. Two things about how it was built were not.
- Generalising it. The ask was one tenant's export. Building it config-driven — path templates, per-tenant partition size and concurrency, secrets by reference — cost more up front and meant the second and third tenants were config rows. Nobody asked for that; it was the difference between a job and a platform.
- The failure matrix. Nobody writes a ticket saying “a crash between the SFTP upload and the status update will re-send the file”. That came from sitting down and enumerating what happens if each stage dies between its side effect and its acknowledgement — which is where the outbox, the leases and the temp-rename all came from.
For a purely self-initiated project with an external adoption story, the stronger answer is mtptrace — that one started as nobody's project at all.
23Learning curve
- Do the arithmetic before the architecture. The 35 GB number is the entire justification for streaming. Getting it wrong by 10× would have made a broken design look acceptable — and the bug would have surfaced in production, at partition 900, at 2am.
- Determinism is a feature you design in, not a property you hope for. Derived run IDs, row-number partitioning on a stable key, computed GCS URIs, computed remote paths. Every one exists so that a retry can find its own prior work instead of creating new work.
- The constraint you do not control shapes the design most. The client's SFTP box set the concurrency, which set the wave count, which set the run time, which fed back into partition size. Designing around an unscalable dependency is a different discipline from designing for scale.
- “Generic” is cheap early and expensive late. Config-driving it from the start cost maybe a week. Retrofitting it after three tenants had bespoke code paths would have cost far more.
24Outcome & metrics
| Metric | Naive approach | Delivered design |
|---|---|---|
| Peak worker memory | ~35 GB — will not run | ~1.6 GB on a 16 GB box |
| Data through the application | Every byte | One 32 MB chunk at a time |
| Unit of retry | The whole run | One partition |
| Partial file exposure | Possible on any crash | Impossible — temp path + rename |
| Duplicate delivery on retry | Likely | Deterministic paths + durable state |
| Missed partitions | Silent | Failure row + email, always |
| Onboarding a new tenant | A code change | A config row |
Tenants and jobs live, actual run volume and duration in production, delivery success rate, and any manual effort the platform replaced. One concrete incident it handled without a human — a client SFTP outage mid-run that recovered on retry, say — is worth more than all the architecture above.
25The hardest part
Making retries safe across four systems that share no transaction
Postgres, BigQuery, GCS and a remote SFTP server. No two of them can commit together, and every gap between a side effect and its acknowledgement is a distinct failure with a distinct fix. Enumerating those gaps produced the failure matrix; the matrix produced the design.
The subtlest one is the SFTP upload. It is the only step where a partial result is visible to someone else — the client's ingestion job can pick up a half-written file and process it as real data before we even notice the transfer died. Postgres can be rolled back and a GCS object can be overwritten, but a file the client has already consumed cannot be recalled. That asymmetry is why the temp-then-rename pattern is not a nicety.
The runner-up: a correctness bug that no system reports
If the partitioning ORDER BY is not stable, a retried partition exports different rows than the original attempt. No error is raised anywhere — BigQuery succeeds, GCS succeeds, SFTP succeeds, every status turns green, and the client receives files with duplicated and missing rows. It is the only bug in this system that the system itself cannot detect, which is why the stable-key question had to be answered by the team that owns the data rather than assumed by me.
26Question drill
Read the question, answer it out loud, then open the card and compare.
The design
Walk me through the system in one minute.
Scheduler fires the orchestrator, which derives a run ID and records the run. A planner counts the source rows, creates an expiring BigQuery table with a partition_number column, writes one manifest row per partition and enqueues one Cloud Task each at concurrency 50. Each export worker submits an EXPORT DATA job writing its partition straight to a deterministic GCS path. Each SFTP worker streams that object in 32 MB chunks to a temp remote path and renames it. Postgres holds run and partition state, operation leases, outbox events and terminal failures; an email report closes the run.
Why partition at all? BigQuery can shard a big export for you.
Because the unit of retry would become the whole terabyte. One SFTP failure at 95% and you restart everything. Partitions make failure cheap, progress observable per unit, and concurrency a dial you can actually turn.
A terabyte through a 16 GB server. How?
It never passes through the server. BigQuery writes each partition to GCS server-side — the worker holds job metadata, not rows. Then the SFTP worker streams the GCS object in 32 MB chunks. Fifty concurrent tasks × 32 MB ≈ 1.6 GB.
The alternative — download the file, then upload it — is 50 × 700 MB = 35 GB, more than twice the box.
Why a temp table? Why not compute the partition boundaries in each worker?
- Retries read identical content for the same run and partition number.
- The expensive row-numbering runs once per run, not once per task or retry.
- Worker payloads stay tiny — a run ID and an integer.
- A 36-hour expiry cleans up without a job to maintain.
What breaks if the ORDER BY in the partitioning query isn't stable?
Silent data corruption. A retried partition exports a different set of rows than the first attempt, so some rows ship twice and others never ship. Nothing errors — every status goes green. It is the only failure in this system that the system cannot detect itself, which is why the stable business key had to be confirmed by the team that owns the table.
ROW_NUMBER with CEIL, or NTILE?
CEIL(ROW_NUMBER() / size) fixes the partition size and lets the last partition be a remainder — right when file size drives export duration and transfer time, which it did here. NTILE(N) fixes the partition count and balances evenly — better when you care about uniform wave duration more than file size.
Why 50 concurrent workers? Why not 500?
Because the ceiling is not ours. Every concurrent worker is a connection to a client SFTP box we neither own nor can scale, plus BigQuery slot pressure and GCS request volume. 50 is the number that destination tolerated. It is a negotiated constraint, and it is per-tenant config for exactly that reason.
Why does the queue enforce concurrency instead of the application?
Application-level limits are per-process. Run three instances and you silently have three times the concurrency you intended — against a client box that will start refusing connections. In the queue, the limit is a property of the system rather than of a deployment.
Correctness
Cloud Tasks retries. What stops a partition being delivered twice?
Three things together. The operation key — SFTP_UPLOAD:{run_id}:{n} — is claimed in Postgres with an owner and a lease, so only one worker proceeds. The destination path is deterministic, so a genuine re-send overwrites rather than adds. And on retry the worker checks the final path first: if it exists with the expected size and checksum, it marks the partition uploaded without transferring a byte.
Why is the operation key the business operation rather than the task ID?
Because a duplicate task for the same partition has a different task ID and the same operation. Task IDs and retry counts identify transport attempts; run + partition + stage identifies the work. Key on the transport and duplicates walk straight through.
What is the lease for? Isn't the status column enough?
The status says a worker claimed it. The lease says the claim is still believable. Without an expiry, a worker killed mid-partition holds that partition forever and the run never completes — you would need an operator or a cleanup job. With one, the work returns to the pool automatically and a later retry takes over atomically.
Does the outbox give you exactly-once delivery?
No — and being precise here matters. The outbox guarantees the intent is committed atomically with the state change, so it cannot be lost. The relay can still perform the upload and crash before marking the event complete. Exactly-once-looking behaviour comes from the external operation being idempotent: deterministic GCS URI, deterministic remote path, durable partition status.
Why write to a .tmp path and rename?
Because a partial file at the final path is indistinguishable from a complete one to the client's ingestion job — right name, plausible size — and once they consume it you cannot take it back. It is the one place in this pipeline where a partial result escapes to someone else. The rename makes the final name appear only when every byte has landed.
A worker uploads successfully, then crashes before updating Postgres. What happens?
The lease expires and a retry takes over the operation. It checks the final remote path, finds the file with the expected byte size and checksum, and marks the partition UPLOADED without re-transferring. If the metadata does not match, it re-streams to the temp path and renames again.
Is overwrite=true on the BigQuery export dangerous?
It would be, without the temp table. Because partition assignment is deterministic for a given run, re-exporting partition 42 produces byte-identical output — so the overwrite replaces the object with the same content. The safety of that flag is inherited entirely from the determinism upstream.
Postgres or Redis for the idempotency store?
Postgres, because the protected state already lives there — one consistency boundary, transactional with the partition status, and durable across restarts. Redis is better for high-frequency ephemeral coordination, but it is not sufficient as the only record of completion, so you would end up needing Postgres anyway and running both.
What if a partition just keeps failing?
After max attempts it goes terminal: a row in transfer_failures with stage, error code, message and attempt count; the partition marked FAILED_TERMINAL; and an EMAIL_REPORT outbox event. The only acceptable undelivered partition is one that is explicitly recorded and reported — never one that quietly is not there.
Scale & operations
How long does a 1 TB run take, and what would you change to halve it?
~1,400 partitions at concurrency 50 is 28 waves; at ~2 minutes a partition that is roughly 56 minutes plus orchestration.
To halve it: double concurrency (capped by the client's SFTP box), shrink partitions (more waves but shorter ones — usually a wash), or reduce transfer time via compression. In practice the client endpoint is the binding constraint, so the honest answer is often “we cannot, without them”.
How do you know mid-run whether it will finish in time?
Partition progress counters — discovered, exported, uploaded, failed, pending — against elapsed time give you the completion projection directly. The two leading indicators of trouble are lease takeovers rising (workers dying) and outbox event age rising (the relay stalling); both appear before the run misses its window.
What makes this reusable rather than one tenant's job?
No tenant identifier appears in a code path. Source table, work dataset, bucket and prefix template, remote path template, partition size, format, delimiter, compression, credentials by secret reference, queue, concurrency, max attempts and schedule are all config. Onboarding is a row, and per-tenant concurrency exists because one client's box handles 50 connections and another falls over at 10.
What would you redesign?
- Checksum verification as a hard gate on every partition, not a best-effort metadata check.
- Adaptive concurrency that backs off when SFTP error rates climb, instead of a fixed number.
- Resumable uploads where the SFTP server supports it, so a 90%-done 700 MB transfer is not thrown away.
- A manifest file delivered alongside the partitions so the client can verify completeness themselves.
- Per-tenant run-level SLOs with alerting, rather than alerting only on terminal failures.
27What not to say
| Do not say | Say instead |
|---|---|
| “We processed a terabyte in the application.” | A terabyte moved through the system; the application never held more than 32 MB at a time. |
| “Cloud Tasks guarantees each partition runs once.” | Cloud Tasks gives at-least-once; operation keys and deterministic paths make repeats safe. |
| “The outbox made delivery exactly-once.” | The outbox made the intent durable; idempotent upload made the retry safe. |
| “We used 50 workers because that is what the server could handle.” | 50 is what the client's SFTP endpoint tolerated — the constraint was external. |
| “50 × 700 MB is about 3.5 GB.” | 35 GB. Do the multiplication out loud — the whole streaming argument rests on it. |
| “Partitioning was just for parallelism.” | Parallelism and — more importantly — making the unit of retry small and deterministic. |
28Gaps to fill before the interview
- Duration and team. Months to first production run, who else contributed, what you led.
- Tenants live. How many onboarded without a code change — that is what proves “generic”.
- Production numbers. Real run sizes, real durations, delivery success rate.
- One incident. A failure the platform absorbed without a human — the best possible evidence the reliability design was real.
- Mentorship story. One engineer: gap, coaching, what they owned afterwards.
“The design principle was that data should never exist where it cannot be afforded, and work should never exist where it cannot be identified. Server-side export and chunked streaming handle the first; derived run IDs, deterministic paths, operation leases and an outbox handle the second. Retries and crashes became normal operating conditions instead of incidents.”
That's the pipeline. Two more deep dives sit alongside it — the allocation platform and mtptrace — or the terminal on the home page takes questions.
← back to projects