projects~/ deep-dive/data-delivery
~/ cat projects/data-delivery/DEEP_DIVE.md

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.

~1 TB per run~1,400 partitions50 concurrent workers1.6 GB peak worker memory0 silently missed partitions

00The one-pager

Everything below in a single screen.

problem

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.

why it is hard
  • ~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.
the shape

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.

key decisions
  • 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.
the guarantee

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.

reuse

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.

~1 TBmoved per run
1.6 GBpeak memory, not 35
20waves at concurrency 50
~40 mintypical run
6control tables
1config row per tenant

01The high-level design

Start here. Everything after this section is a zoom into one box on this diagram.

control planedata planedurable stateCloud Schedulerper tenant / jobOrchestrator APIrun_id + tenant configPartition plannermanifest + temp tableCloud Tasks queueconcurrency = 50 · retriesBigQuery sourceowned upstreamBQ temp table+ partition_number36 h expiryExport workerEXPORT DATA → GCSGCS objectsone per partitionSFTP worker32 MB chunksclientSFTPplansone task per partitionPostgres control DBruns · partitions · leasesoutbox · failuresoutbox relayclaims + executesenqueues next stagefailure tableretries exhaustedemail reportper run + partitionthe only data that ever touches the worker is a 32 MB chunk — BigQuery writes to GCS server-siderun_id and partition_number make every unit of work deterministic, and therefore retryable
Control plane on top, data plane in the middle, durable state underneath. Nothing heavy runs in the scheduler.

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.

ComponentResponsibilityDeliberately does not
Cloud SchedulerFires the orchestrator at the tenant's configured time.Know anything about partitions or data.
Orchestrator APIDerives run_id, loads tenant config, creates the run row.Touch BigQuery or move data.
Partition plannerCounts rows, builds the temp table, writes the manifest, emits per-partition events.Export anything itself.
Cloud TasksAsync fan-out, bounded concurrency, retry with backoff.Guarantee exactly-once delivery.
Export workerSubmits one EXPORT DATA job for one partition.Read rows into process memory.
SFTP workerStreams one GCS object to one deterministic remote path.Download the file first.
Postgres control DBRun state, partition state, leases, outbox, failures.Store any of the actual payload.
Outbox relayClaims durable intents and performs the side effect.Make external calls exactly-once.
the one-sentence framing

“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

spoken version

“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

AreaMineSomeone else's
Source dataset correctnessConsumed the contract; validated row countsUpstream analytics team
Partitioning strategy & temp tableDesign + implementation—
Orchestrator, planner, both workersDesign + implementation—
Control data model, idempotency, outboxDesign + implementation—
Memory & concurrency modelOwned—
Tenant config & onboarding pathOwned—
Client SFTP endpointNegotiated throughput and path conventionsThe client
Cloud Tasks / GCS / IAM provisioningSpecified queue settings and quotasPlatform / DevOps
duration and team

Months from design to first production run, and who else wrote code on it. Say what you led.

tenants live

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

RequirementDesign consequence
No full-file buffering in app memoryServer-side export, plus chunked streaming — the two decisions the whole design rests on.
Bounded concurrencyA queue that enforces it, not a worker pool that hopes. Protects BigQuery, GCS and the client's SFTP box.
At-least-once executionEvery stage idempotent on a business key; deterministic destinations everywhere.
Eventually consistent completionRetries must finish delayed work with no human in the loop.
No silently missed partitionA terminal failure must produce a row and an email, not a log line.
Reusable across tenantsZero tenant identifiers in code paths.
ObservablePer-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.

tenant_transfer_configtenant_id + job_namesource_tablegcs_bucket / prefixremote_path_templatepartition_size_rowsformat · delimiter · gzipmax_concurrencyscheduletransfer_runsrun_id PKtenant_idtemp_tablestatustotal_rowspartition_counttransfer_partitionsrun_id + partition_numbergcs_uriremote_pathstatusattemptsbyte_size · checksumoperation_idempotencyoperation_key PKstatusowner_idlease_expires_atattempt_countlast_erroroutbox_eventsevent_id PKoperation_key UNIQUEevent_typestatus · owner · leasepayload_jsontransfer_failuresrun_id + partition_numberstageerror_codeerror_messageattemptsfailed_atevery table is keyed by somethingthe caller can reconstruct:run_id = tenant + job + scheduled_timeoperation_key = STAGE:run_id:partitionwhich is why a retry can always find its own work
Neither BigQuery nor SFTP can answer “did this run finish?”. Postgres is the only place that knows.
DISCOVEREDEXPORTINGGCS_EXPORTEDSFTP_UPLOADINGUPLOADEDmax attempts exhaustedFAILED_TERMINALrow in transfer_failuresa retry that arrives latereads the state and returns200 without redoing workterminal failure is loud:recorded, counted, emailednever silently dropped
Every transition is guarded by operation_key + owner_id + lease_expires_at. A partition is never between states.

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 general principle

“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.

source tableowned by the upstream teamCOUNT(*) ÷ partition_size_rows→ N partitionsCREATE TABLE … 36 h expiry+ partition_number columnEXPORT DATA per partitionserver-side BQ → GCSone Cloud Task per partitiondeterministic payloadmanifest rows 1…Nin Postgrespartition_number = CEIL(ROW_NUMBER() OVER (ORDER BY stable_key) / partition_size_rows)the ORDER BY has to be on a stable business key — order by something non-deterministic andthe same partition_number returns different rows on a retry, which is silent data corruption
The expensive assignment happens once per run. A retry re-reads it — it never recomputes it.
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.
the failure mode hiding in the ORDER BY

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

ApproachGives youUse 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.

waves = ⌈partition_count ÷ concurrency⌉  ·  run_time ≈ waves × avg_partition_timeconcurrency 2540 waves~80 minconcurrency 5020 waves~40 minconcurrency 10010 waves~20 minbut concurrency is not a free dialevery extra worker adds BigQuery slot pressure, GCS requests, egress — and connections to a clientSFTP box we do not own and cannot scale. 50 was chosen as the number that box tolerated,not the number our workers could sustain.the bottleneckis never theworker fleet
1,000 partitions · ~2 min each. Halving the wave count halves the run — until something downstream says no.
why the queue enforces concurrency, not the code

“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 reveal
why the rename matters more than it looks

Clients 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.

buffer the filestream the filedownload 700 MB → RAM× 50 concurrent tasks35 GB resident — plus Python object overheadread 32 MB chunk → write× 50 concurrent tasks1.6 GB resident — bounded, regardless of file sizepeak worker memory, to scale, on a 16 GB serverbuffered35 GBstreamed1.6 GB16 GB — the server2.2× over the boxcheck the arithmetic out loud: 50 × 700 MB = 35,000 MB ≈ 35 GB, not 3.5 GBan order-of-magnitude slip here is the difference between “fits comfortably” and “OOMs on the first run”
Same throughput, same concurrency. The only difference is whether the file ever exists in the process.
ApproachWorker memoryVerdict
SELECT rows into Python, write a fileHundreds of MB per task + object overheadOOM at concurrency 50
BigQuery EXPORT DATA → GCSJob metadata onlyThe export answer
Download the GCS object, then upload700 MB × 50 = 35 GB2.2× the server
Stream GCS → SFTP in chunks32 MB × 50 ≈ 1.6 GBThe upload answer
do the multiplication out loud

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.

StageOperation keyDurable completion check
Run startRUN_START:{run_id}A row exists in transfer_runs
PlanningPLAN_PARTITIONS:{run_id}Manifest rows exist for the run
ExportBQ_EXPORT:{run_id}:{n}Partition status ≥ GCS_EXPORTED
UploadSFTP_UPLOAD:{run_id}:{n}Status UPLOADED, or the remote file matches expected metadata
ReportEMAIL_REPORT:{run_id}:{type}Report event completed
Cloud Task → stage handlerSTAGE:{run_id}:{partition_number}row for operation_key?COMPLETEDreturn 200no work repeatedno rowINSERT … ON CONFLICT DO NOTHINGIN_PROGRESS + owner_id + leaseIN_PROGRESSlease expired?noretryable responseowner still aliveyesatomic takeoverUPDATE … WHERE lease_expires_at < NOW()do the stage workexport · or · stream uploadone transaction: state + outbox eventthen COMPLETED WHERE owner_id = :meexactly one callerreaches this columnthe key is the operation,never the task attempt
One protocol, reused by every stage. Only the operation_key prefix changes.
-- 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.

say it like this

“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.

one transaction — both or neitherUPDATE partition statusINSERT outbox event (next stage)outbox relayclaims under a leaseexternal side effectBQ export · SFTP upload· emailmark eventCOMPLETEDmax attemptstransfer_failures rowstage · error · attemptsemail reportwhat did not landan outbox does not make an external call exactly-once — it makes the intent impossible to losethe retry is made safe separately, by deterministic GCS URIs and deterministic remote paths
The outbox guarantees the intent survives. Deterministic paths guarantee the retry does not duplicate output.
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)
the nuance to say before you are asked

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 pointRiskProtection
Scheduler fires twiceDuplicate runDerived run_id + ON CONFLICT DO NOTHING
Orchestrator dies after creating the runRun exists, never plannedRetry sees the run and continues planning
Temp table made, DB not updatedPlanning looks incompleteDeterministic table name; CREATE OR REPLACE is safe to repeat
Cloud Task created, DB update failsDuplicate task for one partitionWorker idempotency on operation_key
Export succeeds, worker crashes before status updateRe-enters the export stageDeterministic GCS object; re-export is byte-identical
SFTP transfer partially writesClient ingests a truncated file.tmp path + rename only on completion
Upload succeeds, crash before DB updateRe-uploads the same partitionFinal path deterministic; metadata match ⇒ mark done
Worker killed mid-partitionPartition stuck IN_PROGRESSLease expiry + atomic takeover
Retries exhaustedPartition never deliveredFailure 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 fasterCost
Raise concurrencyMore BigQuery slot pressure, more GCS requests, more SFTP connections — capped by the client.
Smaller partitionsMore waves and more per-task overhead; more, smaller files for the client to handle.
Larger partitionsFewer waves, but a failed partition costs more to redo and export duration grows.
Better compressionLess 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.

AreaWhat to watchWhat it catches
Run healthRuns started / completed / failed, duration p95Schedule drift, runs that never finish
Partition progressdiscovered / exported / uploaded / failed / pendingWhether the run will make its window
Cloud TasksQueue depth, retry count, oldest task ageA stage failing repeatedly and silently
BigQueryExport job duration, bytes processed, quota errorsSlot contention with other workloads
SFTPThroughput, connection and auth failures, rename failuresThe client's box throttling or expiring credentials
IdempotencyLease takeovers, stuck IN_PROGRESS rowsWorkers dying mid-partition
OutboxPending events, event age, relay lagIntents 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

DecisionWhyAlternative & trade-off
Cloud Tasks fan-outHTTP retry, backoff and dispatch limits for freeKafka / Pub-Sub worker pool — higher streaming scale, much more consumer infrastructure
Per-run BigQuery temp tableDeterministic, cheap retriesRecompute partitions per worker — cheaper to write, wrong under retry
EXPORT DATA to GCSBytes never enter the applicationPull rows and write the file — simple, and it OOMs
Chunked streaming to SFTPBounded memory regardless of file sizeDownload then upload — 35 GB at concurrency 50
Temp path + renameA partial transfer is never mistaken for a complete fileWrite to the final path — faster, and it corrupts the client's ingestion
Postgres for idempotencyDurable, transactional, next to the business stateRedis lock — faster, not sufficient as the only completion record
Derived run_id / operation_keyRetries can find their own prior workServer-generated IDs — a lost response becomes a duplicate run
Outbox on every handoffIntent survives a crash between stagesCall the next stage directly — an unprotected dual-write window
Failure table + emailAuditable list of what did not landLogs and alerts only — nobody can answer “what did the client miss?”
Config-driven everythingA new tenant is a rowPer-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.
the story to have ready

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.
the one story to prepare

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

MetricNaive approachDelivered design
Peak worker memory~35 GB — will not run~1.6 GB on a 16 GB box
Data through the applicationEvery byteOne 32 MB chunk at a time
Unit of retryThe whole runOne partition
Partial file exposurePossible on any crashImpossible — temp path + rename
Duplicate delivery on retryLikelyDeterministic paths + durable state
Missed partitionsSilentFailure row + email, always
Onboarding a new tenantA code changeA config row
get these exact

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 saySay 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.
closing line

“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