cswork / libraryOpen practice workspace

Interview guides · attempto

attempto Software Engineer Interview Questions & Guide 2026

attempto software engineer prep on partial failure: a timed-out request has an unknown outcome, not a failed one.

The 60-second brief

A Software Engineer at attempto serves as a vital bridge between high-level business requirements and robust technical execution. Because attempto operates both in-house and through client-facing projects, you will be expected to deliver high-quality code that meets diverse architectural needs. This role is not just about writing syntax; it is about understanding the specific operational challenges of clients and engineering scalable solutions that provide long-term value.

Allocate preparation against your weakest link rather than your favourite topic. A strong algorithm habit usually comes with weak out-loud explanation of tradeoffs, and years of shipping usually come with rusty from-scratch implementation under a clock.

Focus areas

  • Make integration retries idempotent without target-side idempotency keys
  • Bound credential lifetime by the engagement end date
  • Evolve schemas expand-contract across un-upgradable client deployments

Overview

You will contribute to significant projects that demand a high degree of technical versatility. Whether you are working as a Java Developer or a Full Stack Developer, your work directly impacts the efficiency and reliability of systems used by attempto's partners. Success in this role requires a blend of deep technical expertise and the ability to communicate complex concepts clearly, as you will often be the face of technical delivery in a professional services environment.

The attempto interview process

attempto candidates report 4 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.

Initial Screening

The title covers product work, platform work, infrastructure, mobile and frontend, and those are different jobs with different loops behind them. A screening call is the cheapest place to find out which one the seat is, and asking reads as experienced rather than fussy. The questions that separate them: what the team is on call for, what the last three projects were, and whether any round happens inside an existing repository instead of a blank file. Then say which of that you have done and which you have not. Claiming the whole posting is the fastest way to be found out one round later.

What to demonstrate

  • Whether you can locate your experience inside one flavour of the role honestly instead of claiming the entire requirements list
  • Whether you name what you have not done, which an experienced screener reads as a level signal and can plan the loop around
  • Whether what you want next matches what the seat is: someone who wants greenfield work landing on a team that mostly operates an existing system is a hire that leaves within the year

How to prepare

  • Mark every line of the posting as done, adjacent or new, and write one sentence for each adjacent line naming the closest thing you actually built
  • Split your last two years into rough percentages across feature work, operating and debugging live systems, and design or review, so a question about scope gets numbers rather than adjectives
  • Bring three questions that discriminate between seats: what the team is paged for, how much of the work is changing existing code versus standing up something new, and what shipped in the last quarter

Technical Discussion

Input bounds are the part of the prompt most often skimmed, and they usually contain the answer. They tell you which complexity class is admissible, which narrows the search before you have thought about the problem itself. As a rough planning figure, a compiled language does on the order of 10^8 simple operations per second and an interpreted one roughly an order of magnitude less. So n up to about twenty admits enumerating subsets, a few thousand admits a quadratic pass, and a million admits neither: you need near-linear, or linear with a log factor. If the bounds are missing, ask for them.

What to demonstrate

  • Whether the approach is justified by the stated input size rather than by whichever pattern you recognised first
  • Whether you ask about the properties that change the algorithm: whether the input arrives sorted, whether duplicates occur, whether values are bounded integers, whether it all fits in memory
  • Whether you can name the bottleneck in your own solution and what would remove it, even when you deliberately leave it in place
  • Whether a claimed speedup is real, since memoising a recursion only helps when subproblems genuinely overlap and the state can be keyed cheaply

How to prepare

  • For each algorithm you rely on, write down the largest n it handles in roughly a second, then check two of those figures by timing them in the language you will actually type in
  • For two weeks, write one line naming your target complexity and the bound that justifies it before you write any code, then compare that line with what you ended up submitting
  • Practise the conversion backwards: given a required O(n log n), list the mechanisms that get you there (sorting, a heap, an ordered map, divide and conquer) and choose by what the problem needs to query, not by what you used last

Two-Way Conversation

You cannot drill a format you do not know, so put the preparation into material that travels. Three pieces of your own work, each rehearsed until you can take a follow-up you did not anticipate, will carry a conversation or a code walkthrough equally well. Specificity is what separates that from filler. A number needs its definition before it means anything: a p99 is over some window and measured at some hop, and a server-side figure excludes the queueing and network time a client would see. The number you cannot qualify is the one to leave out.

What to demonstrate

  • Whether your examples carry detail only someone who did the work would hold, such as what the binding constraint actually was, which alternative you rejected and why it was worse, and what you measured on each side of the change
  • Whether a number survives one follow-up, meaning you can say what it was measured over and whether it moved because of your change or merely alongside it
  • Whether a failure is described with the specific change that followed it, rather than a lesson stated in general terms
  • Whether your part in a team effort is stated accurately, including what other people did

How to prepare

  • Write a page on each of three projects covering the constraint, the option you rejected, the measurement before and after, and what went wrong. Cut any line you cannot take a follow-up on, since you are writing the parts you will be pressed on rather than a summary.
  • Recover the real figures while you still have access: request volume, data size, latency with its percentile and window, team size, timeline. Note where each came from, whether a dashboard, a design document or memory, and mark the estimates so you can say which they are out loud.
  • Take your weakest project story to someone who works in a different area and have them ask why four times in succession. The point where you run out of answer is the part to go and re-read before the round.

Final Interview

Nobody in the room with you decides this. Interviewers typically write their rounds up separately, often before seeing anyone else's, and the outcome is settled later from those write-ups. A split panel gets resolved by whichever note carries specific evidence, so what you want out of each room is one concrete thing that person could write down: a bug you caught yourself, a trade-off you named, a decision you owned. The rest is arithmetic. The project you describe in a behavioural conversation is often the same system you sketched an hour earlier, and the two accounts have to agree.

What to demonstrate

  • Whether the scale, team size and timeline you attach to a project hold steady when that project resurfaces in a different round
  • Whether each interviewer leaves with a specific thing to cite rather than a general impression of competence
  • Whether a trade-off you defended in one round survives a challenge in another, instead of being quietly swapped for the answer the new interviewer seemed to want
  • Whether a question you have already answered earlier in the day gets the same answer at the same depth, without visible impatience

How to prepare

  • Write a one-page sheet per project fixing the figures you will quote — request volume, data size, team size, elapsed time, what broke — and say them aloud from the sheet until they come out identical every time
  • For each round on the schedule, decide in advance the one sentence you want in that person's notes, then check in a mock that you said it outright instead of leaving it to be inferred
  • Have someone ask you the same project question twice, an hour apart, and diff the two answers for numbers that moved or a trade-off that reversed

Where candidates lose points

Treating an ambiguous failure as a definite one

A timeout, a 502 from an intermediary, or a connection reset after the request bytes were sent all leave the target's state unknown. Classifying those as failures and retrying duplicates the effect; classifying them as successes and advancing the watermark loses data silently. Both wrong answers are common because the ambiguous case is rare in a sandbox and routine in production. The run needs a distinct ambiguous state, a dedupe key that makes the retry safe, and a reconciliation read against the target when the key alone cannot settle it.

Long-lived shared credentials for client systems

One static key used by the connector runtime, a debugging script and three engineers cannot be attributed to a person in an audit, cannot be expired at engagement close, and cannot be rotated without breaking an unknown number of consumers at once. The expensive part of the eventual incident is not the leak; it is that rotation requires finding every holder under time pressure, and nothing recorded who they were. Issue per-principal, per-target, short-lived grants from a broker so rotation is a no-op, attribution is automatic, and closure is a TTL rather than a search.

Check-then-act on shared state

Read, decide, write is not safe under concurrency unless the decision and the write are one atomic step: a unique constraint with conflict handling, a compare-and-set, or a row lock held for the whole transaction. Two requests can both pass the existence check before either inserts, which shows up as duplicate rows under load and never in a single-threaded test.

Comparing floating-point values for equality, or holding money in them

Binary floating point cannot represent 0.1 exactly, so repeated addition drifts and an equality check fails on values that are mathematically equal. Store currency as integer minor units or a decimal type, and compare floats against a tolerance you chose for a stated reason.

Questions & practice

Collapse connector retry attempts into logical jobs and failure counts

Easy · hash aggregation · idempotency · retries

You are given one UTC day of connector_run rows: (connector_id, idempotency_key, attempt_no, status, failure_class, records_read, records_applied), unique on (connector_id, idempotency_key, attempt_no), up to 5,000,000 rows in arbitrary order. Retries of one logical job share the idempotency_key; the job's outcome is the status of its highest attempt_no. Return, per connector_id: the number of logical jobs, a count of terminally failed jobs by failure_class, and the dedupe hit total, meaning sum(records_read - records_applied) over terminal-succeeded attempts only. One pass over the input; state your memory bound.

Approach

  1. Key a hash map on (connector_id, idempotency_key) and keep only the highest attempt_no seen so far plus that row's status, failure_class and record counts. One pass, O(n) time, O(d) space where d is the number of distinct logical jobs, not O(n).
  2. Treat a repeated (key, attempt_no) as corrupt input and raise, since the table's uniqueness constraint says it cannot happen; silently overwriting hides a double-insert in the runtime.
  3. Fold the d surviving entries into per-connector counters in a second pass over the map, not over the rows. The failure_class histogram is a small fixed-width map per connector because failure_class is an enum.
  4. Compute dedupe hits only from terminal-succeeded attempts. A failed attempt's records_read is work attempted, not work deduplicated, and adding it counts the same source records once per retry.
  5. If d does not fit in memory, partition the input by hash(connector_id, idempotency_key) into p files and aggregate each partition independently. Same O(n) total work, p sequential passes, memory traded for I/O; the partition function must use the full key or a job's attempts split across files.

Worked solution (20 min)

  1. Take six rows: (c=7,K=A,1,failed,client_429,0,0), (c=7,K=A,2,succeeded,null,500,480), (c=7,K=B,1,ambiguous,client_timeout,300,300), (c=7,K=B,2,succeeded,null,300,0), (c=9,K=C,1,failed,schema_mismatch,0,0), (c=9,K=C,2,failed,schema_mismatch,0,0).
  2. Build the map: A -> attempt 2 succeeded, B -> attempt 2 succeeded, C -> attempt 2 failed/schema_mismatch. Three entries from six rows.
  3. Fold per connector: connector 7 has 2 logical jobs, 0 terminal failures; connector 9 has 1 logical job, 1 terminal failure classed schema_mismatch.
  4. Dedupe hits from terminal-succeeded attempts only: (500-480) + (300-0) = 320 for connector 7, 0 for connector 9.
  5. Compute the wrong answer deliberately by counting attempt rows: 6 jobs and 3 failures, which is what a naive GROUP BY on run rows returns.

Expected result: connector 7: 2 logical jobs, no terminal failures, 320 dedupe hits. connector 9: 1 logical job, 1 terminal failure with failure_class='schema_mismatch', 0 dedupe hits. Attempt-level counting would have reported 6 jobs and 3 failures.

Verify it yourself

  • The sum of terminal statuses across all connectors equals the number of distinct (connector_id, idempotency_key) pairs, here 3.
  • records_applied <= records_read holds on every row, so the dedupe total is non-negative.
  • Rerunning the aggregation over a shuffled copy of the input gives identical output, since the highest attempt_no wins regardless of arrival order.
  • Map size equals 3, strictly less than the 6 input rows, confirming collapse actually happened.

Follow-up

  • A job's highest attempt is 'cancelled' but an earlier attempt succeeded. What is the job's outcome, and what does that say about who writes the cancel?
  • Produce this incrementally as runs land instead of as a daily batch: what state do you keep per key, and what happens when an attempt arrives out of order?
  • records_applied is written by your runtime, not by the client. What would you reconcile it against before anyone trusts the dedupe-hit number?

Compute the minimal re-run set that preserves idempotency keys

Hard · interval complement · idempotency keys · watermarks

One connector binding must cover [span_start, span_end). You have all its connector_run rows: (idempotency_key, attempt_no, status, watermark_from, watermark_to), with half-open ranges, where idempotency_key is derived deterministically from the connector and the range, so re-running a range reproduces its key. Only status='succeeded' counts as covered. Produce the minimal re-run plan: every instant in the span ends up covered, any sub-interval some prior run already claimed is re-run at exactly that claim's range, and genuinely unclaimed sub-intervals become new claims cut at the binding's chunk size. Explain what merging ranges would cost.

Approach

  1. Merge the succeeded ranges into a maximal disjoint union: sort by start, sweep extending the open segment while the next start is not beyond the current end. O(n log n) time, O(n) space, and this is the only status that contributes coverage.
  2. Take the complement of that union inside [span_start, span_end) to get the uncovered gaps, in O(u) over the merged segments. Clip at both ends so the plan never reaches outside the span.
  3. Sweep the non-succeeded claims (ambiguous, failed, abandoned) against the gap list with a two-pointer over both sorted lists. Any claim intersecting a gap is emitted at its original watermark_from and watermark_to, byte for byte, so the derived idempotency_key reproduces and the retry rejoins the same ledger row. O(n) after sorting.
  4. Subtract the emitted claim ranges from the gaps; whatever survives was never claimed by any attempt and becomes fresh ranges cut at the chunk size. Only these get new keys. O(u + c).
  5. Accept that a re-emitted claim may overlap time that is already succeeded. Range bookkeeping is not what makes the retry safe; the record-level dedupe key is, and re-reading a covered window costs one extra fetch against the client's quota, which is far cheaper than a key you can no longer reproduce.
  6. Exclude anything still in status 'running' with a live lease. Re-claiming it races the in-flight attempt, doubles the quota spend against the client endpoint, and produces two ledger writers for one key. Total plan cost is O(n log n) time and O(n) space.

Follow-up

  • An ambiguous run may have applied every record before the timeout. What do you read to settle it before re-running, and what is left if the client's schema offers no natural key?
  • Retention pruned the ledger rows for one of these keys. What does the re-run do now, and what bounds how long the ledger must be kept?
  • Two planners run concurrently against the same binding. What stops them emitting and executing the same plan twice?

Resolve effective-dated engagement versions for two million access checks

Hard · binary search · effective dating · complexity budget

The engagement table holds 500,000 effective-dated rows: (engagement_id, version, effective_from, effective_to which is NULL for the open version). A database exclusion constraint guarantees that per engagement_id the tstzrange(effective_from, effective_to) values never overlap, and the ranges are half-open. You must answer 2,000,000 access checks of the form (engagement_id, at_time) in one process with no database, returning the version open at that instant or none. Also report, per engagement, any coverage gap between adjacent versions. Give the complexity of the scan-per-check approach and say why it is not an option.

Approach

  1. State the naive cost first: scanning the table per check is 2e6 x 5e5 = 1e12 range comparisons. Even at 1e9 simple comparisons per second that is roughly fifteen minutes of pure compare work, against a budget measured in seconds. It is correct, just unaffordable, and the fix is an index built once rather than a faster comparison.
  2. Build a hash map engagement_id -> array of versions sorted by effective_from, once. O(V log V) to sort, O(V) memory. The build is amortised over 2e6 lookups, which is what makes it worth doing at all.
  3. Answer each check by binary-searching for the last effective_from <= at_time, then confirming effective_to IS NULL OR at_time < effective_to. If the confirmation fails, the instant lies in a gap and the answer is none, not the neighbouring version. O(log v) per check, O(V log V + Q log v) total.
  4. Name the precondition: predecessor search is only valid because the ranges per engagement do not overlap, which the exclusion constraint enforces with btree_gist for the equality operator. Without that guarantee you need an interval tree returning all stabbed intervals, at O(log n + k). Use the same half-open convention as the database, where at_time equal to effective_to belongs to the next version.
  5. Find gaps with one O(V) pass over each sorted array comparing prev.effective_to against next.effective_from. The exclusion constraint forbids overlaps, not holes, so a hole is entirely possible and is where an access check fails closed for a reason nobody can see in the contract.
  6. If the checks can be buffered, sort them by (engagement_id, at_time) and merge against the version arrays: O(Q log Q + V log V), usually a large constant-factor win over 2e6 random binary searches because the merge walks memory in order.

Follow-up

  • The open version has effective_to NULL. How do you keep both the sort and the comparison total without a sentinel that overflows your timestamp type?
  • A gap exists for one engagement and a check lands in it. What should the access path return, and what should it emit so someone fixes the amendment?
  • Amendments are being written while you answer checks. What read model keeps an answer self-consistent, and what does a check mean if the snapshot is a second stale?

Generate invoice lines once under concurrent month-end runs

Hard · isolation levels · unique constraints · idempotency

Month-end generation reads approved work_record rows for one engagement and period, sums minutes, inserts one invoice_line whose generation_key is UNIQUE over (engagement_id, period_start, period_end, line_type, generator_version), then sets those work records to invoiced with locked_at and invoice_line_id. Two runs execute concurrently for the same engagement while late approvals are still committing. Working in PostgreSQL, name the anomaly at READ COMMITTED and at REPEATABLE READ, and specify the controls that make a retry a no-op rather than a second charge.

Approach

  1. At READ COMMITTED each statement takes a fresh snapshot, so the SELECT that computed the sum and the later UPDATE see different data: an approval committed between them is a phantom the sum missed but the UPDATE can still mark invoiced. Worse, an UPDATE that blocks on a row another transaction is changing re-evaluates its WHERE against the new row version once the lock releases, so WHERE status = 'approved' silently skips rows the other run already moved — fewer rows than you counted, and no error anywhere.
  2. At REPEATABLE READ, which in PostgreSQL is snapshot isolation, the sum and the update agree because the whole transaction shares one snapshot, but a concurrent update to the same row aborts you with serialization_failure, SQLSTATE 40001, which the application must catch and retry. Snapshot isolation still permits write skew across different rows; only SERIALIZABLE with SSI excludes it, at the cost of more 40001s and predicate-lock memory bounded by max_pred_locks_per_transaction.
  3. Make the second run collide instead of race: take a transaction-scoped advisory lock on a hash of (engagement_id, period_start, line_type) at the top of the run, so one generates while the other waits and then finds finished work. Keep the UNIQUE generation_key as the backstop, because the advisory lock is per-database-connection state that a failover or a second database node does not carry.
  4. Treat the unique violation as success. Catch SQLSTATE 23505, re-select the existing invoice_line by generation_key, and return it. INSERT ... ON CONFLICT DO NOTHING ... RETURNING returns zero rows on conflict, so a handler that trusts RETURNING writes a NULL invoice_line_id and reports a failure for work that in fact completed; ON CONFLICT DO UPDATE is worse, because it overwrites a line that has already been issued.
  5. Bound the read deterministically rather than by timing: select the work records FOR UPDATE ordered by work_record_id so both runs acquire row locks in the same order and cannot deadlock, and filter on approved_at < the run's start timestamp so a late approval is out of scope by definition and lands in the next period.
  6. Enforce immutability in the database, not in the service. A BEFORE UPDATE trigger raising when locked_at IS NOT NULL and a billed column changes, plus CHECK (status <> 'invoiced' OR invoice_line_id IS NOT NULL), means the constant and legitimate business pressure to edit an invoiced entry resolves into an appended credit_note row instead of a quiet update.

Worked solution (40 min)

  1. Open two psql sessions and interleave them by hand: A begins and inserts the invoice_line without committing; B begins and attempts the same insert.
  2. Observe B blocking on the unique index, commit A, and record the exact error B receives.
  3. Implement the handler path: catch 23505, re-select by generation_key, return that row, and confirm both sessions report the same invoice_line_id.
  4. Repeat at REPEATABLE READ with both sessions updating the same work_record to force 40001.
  5. Approve a work record after A's start timestamp and follow where it ends up.

Expected result: One invoice_line per (engagement, period, line_type, generator_version); the losing run returns the winner's row as success rather than an error; the late approval remains status approved, unlocked, and is picked up by the next period's run; the 40001 path is exercised by a retry rather than surfaced to the caller.

Verify it yourself

  • Both sessions report the same invoice_line_id, and the table holds exactly one row for that generation_key.
  • The late-approved work record still has status approved and locked_at NULL after both runs finish.
  • An UPDATE against a work record with locked_at set is rejected by the trigger, not merely by the service layer that happened not to issue it.
  • SELECT generation_key, count() FROM invoice_line GROUP BY 1 HAVING count() > 1 returns nothing — trivially, because the constraint and not the code is what guarantees it.

Follow-up

  • A line has been issued and then an approval behind it is reversed. What rows do you write, and what does the original line look like afterwards?
  • How many times does the run retry before giving up, and what state does it leave behind for the operator who picks it up?
  • After the fact, how do you prove no engagement-period was billed twice, without trusting the application code that wrote it?

Denormalise the entitlement snapshot without freezing the clock

Medium · denormalisation · read patterns · caching

Authorisation currently joins engagement (effective-dated, with status, ends_on, access_grace_days, effective_from, effective_to), entitlement and principal_assignment on every request, at the platform's full read rate, against tables taking a few hundred writes a day. You are asked to publish a denormalised snapshot that callers cache with a few seconds of staleness and invalidate on change events. Specify the snapshot's key and shape, say which columns you denormalise, name the one thing that must not be precomputed, and say what a cache miss does.

Approach

  1. Justify the shape from the ratio rather than from taste: a few hundred writes a day against the whole estate's read rate is the case for a published read model. The normalised tables stay the system of record; the snapshot is derived, versioned and disposable, and can be rebuilt from source at any time.
  2. Key it on (client_id, principal_id) with a monotonic snapshot_version and a generated_at. Consumers cache by version, refresh on a change event, and reject anything older than the staleness bound, which turns the bound into an enforced property instead of an assumption about how fast events usually arrive.
  3. Denormalise only what changes on a write: the open engagement version's engagement_id, status, isolation_tier, data_region, ends_on, access_grace_days, and the resolved scope set. Resolving the effective-dated row once at publish time — selecting the version WHERE effective_to IS NULL — is the entire saving, because that resolution is what the per-request join was paying for.
  4. Do not precompute is_active or any boolean that folds now() into the stored value. A write event invalidates the snapshot; the passage of time does not, so an engagement that ended an hour ago keeps authorising until something unrelated triggers a republish. Ship ends_on and access_grace_days as data and compare them to the current time at read, so time-based expiry needs no invalidation at all.
  5. Fail closed on a miss or on over-staleness: deny, and do not fall through to the source tables. A fallback read path means the first mass cache miss converts an availability incident into either an authorisation bypass or a stampede onto the one service whose availability target already exceeds everyone else's.
  6. State the cost you accepted: a revoked entitlement is still honoured for up to the staleness bound. That is defensible for widening access and not for narrowing it, so pair it with an out-of-band deny signal for revocation and closure, and write the residual window into the runbook instead of leaving it for an auditor to discover.

Follow-up

  • How do you detect the snapshot quietly diverging from the source tables, given that a wrong snapshot is silently permissive?
  • An amendment creates a new engagement version mid-request. Which version does the in-flight request use, and can you defend that?
  • The snapshot carries data_region. What stops the snapshot itself from being replicated somewhere its own residency rule forbids?

Apply records to a client system exactly once under retries

Medium · idempotency · exactly-once · ambiguous failures · transactions

A connector run claims a watermark range, reads records from a client endpoint and writes them to a client-owned target that offers no idempotency key of its own. connector_run stores (connector_id, idempotency_key, attempt_no, status, failure_class, watermark_from, watermark_to, records_read, records_applied), and status includes 'ambiguous'. A write can time out after the request bytes were sent, leaving the target's state unknown. Design the apply loop so that any number of retries leaves the target exactly as one clean run would, and say precisely what advances the watermark and when.

Approach

  1. Derive the dedupe key deterministically from stable source fields - the source's own identifier plus a hash over exactly the fields you write - and store the input field list and a derivation version beside the hash. The client will eventually rename or retype one of those fields, and every key computed before that day must stay reproducible; without the version, the first schema change re-applies the entire history.
  2. Order the local writes so a crash is recoverable: insert the ledger row (connector_id, dedupe_key, unique) as a claim, write to the target, then mark it applied and advance the watermark in one local transaction. The target write cannot join that transaction - no client grants you a distributed one - so the claim row exists precisely to tell the retry that a write may have happened.
  3. Give ambiguity its own state. A timeout, a 502 from an intermediary and a reset connection after the bytes went out all leave the outcome unknown, and collapsing them into 'failed' duplicates on retry while collapsing them into 'succeeded' loses data silently. On retrying an ambiguous run, resolve per record: read back by natural key if the target has one, and if it does not, negotiate one writable reference field at integration time so the read-back is possible at all. That negotiation is cheaper than every reconciliation you would otherwise run forever.
  4. Advance the watermark only across a range whose ledger rows are all applied or all deduped, and advance it with deliberate overlap. Many source systems stamp updated_at at transaction start, so a record committed after your read can carry a timestamp inside a range you already consumed; re-claim a bounded lag window each time and let the dedupe key absorb the duplicates, which is what it is for.
  5. Bound the ledger's growth against that same window. Retention must exceed the maximum overlap plus the longest plausible late arrival, because pruning earlier converts a late record into a silent re-apply. Partition the ledger by watermark date so pruning is a partition drop rather than a mass delete.

Follow-up

  • The target exposes no natural key and no field you are allowed to write a reference into. What is your next move, and what does it cost per run?
  • A succeeded run reports records_read 10,000 and records_applied 9,997. Is that a bug? What single query settles it?
  • The client restores their source database to a point two hours earlier. What does your next run do, and what should it do?

Share one client's rate budget across every job you run

Medium · rate-limiting · backoff · concurrency-control · connectors

A connector_binding carries an endpoint, an auth method and a rate budget. Several bindings, an ad-hoc backfill and an operator's debugging script all hit the same client-owned system, whose quota is per client account and shared across every one of them. Your workers are horizontally scaled and stateless. The client returns 429 with Retry-After, and sometimes 503 with nothing. Design the internal contract governing outbound calls: where the limit is enforced, what a worker asks for and gets back, how a 429 propagates, and what a run does when it cannot get budget.

Approach

  1. Key the limiter on the resource actually being consumed: (client_id, target_system_id), never on worker, process or binding. A per-worker limit multiplies by the worker count, which is exactly wrong when the quota is one shared number, and it loosens silently the moment someone scales the deployment.
  2. Make budget an explicit acquisition with a deadline rather than an implicit sleep. A worker calls acquire(key, cost, deadline) against a shared token bucket — a Redis Lua script keeps the read-modify-write atomic — and receives granted, a wait duration, or refused. Return the refusal rather than blocking a worker for minutes; a blocked worker is a worker not doing another client's work.
  3. Add a concurrency bound alongside the rate bound, because they catch different failures. A token bucket happily permits a burst of fifty simultaneous in-flight requests that satisfies the per-second rate and still exhausts the client's connection pool. Hold a bounded lease per (client_id, target_system_id) with a TTL so a crashed worker's lease expires instead of wedging the binding.
  4. Treat the client's own signal as authoritative and shared. A 429 with Retry-After must pause every caller on that key, not just the run that received it, so the limiter takes a cooldown on the key itself. A 503 with no Retry-After gets exponential backoff with full jitter — sleep = random(0, min(cap, base·2^attempt)) — because synchronised retries from a dozen bindings after a client outage are a self-inflicted herd against the quota you are about to need.
  5. Adapt downward on evidence and upward only slowly. Cut the effective rate on repeated 429s, recover gradually, and record the measured sustained rate per binding: the documented limit and the enforced limit differ often enough that treating the client's documentation as fact is the specific error here.
  6. Define what a refused run is. It is not a failure: record it as queued or abandoned with its own reason, leave the watermark untouched, and let the scheduler re-claim the same range later. Counting budget exhaustion as a failure pollutes the per-binding failure rate you alert on and buries the client-owned problems underneath it.

Worked solution (30 min)

  1. Write the limiter's key and list every caller that shares it; name one that is easy to forget.
  2. Write acquire()'s signature and its three outcomes, and say what the worker does with each.
  3. Compute the worst-case in-flight request count under a rate limit alone at your burst size, then add the concurrency bound that fixes it.
  4. Trace a 429 arriving at one of five concurrent runs on the same key and say what the other four do next.
  5. Choose the run status and failure_class for budget exhaustion, then check your alerting query still isolates client-owned failures.

Expected result: A shared token bucket keyed on (client_id, target_system_id) with an atomic acquire, a separate leased concurrency bound with a TTL, a key-wide cooldown driven by the client's Retry-After, full-jitter backoff where no Retry-After is supplied, an empirically measured sustained rate per binding, and budget exhaustion recorded as a non-failure that leaves the watermark where it was.

Verify it yourself

  • Doubling the worker count leaves the observed request rate against the client unchanged.
  • One run receiving 429 stops the other runs on the same key within one acquisition interval.
  • A worker killed mid-request releases its concurrency lease with no operator involvement.

Follow-up

  • An operator's debugging script drains the whole budget at 09:00 and the scheduled runs starve. What in your design prevents that, and what priority scheme would you accept?
  • How do you measure the client's real limit without tripping it during their business hours?

Credential minting stalls estate-wide from one client's provider

Hard · partial outage · timeouts · bulkheads · queueing

At 14:05 the access broker's p99 rises from 28 ms to 9.4 s. Error rate is only 3 percent. Connector runs and operator sessions for every client begin failing, yet only one client's identity provider is degraded: it accepts TCP connections and never replies. The broker handles five mint calls per second, of which one targets that provider, through a shared outbound worker pool of 50. Handler service time, measured from the start of each call, is unchanged. Diagnose in order and give the changes that bound the blast radius.

Approach

  1. Separate slow from queued before anything else, because they need opposite responses. Split the broker's latency into time waiting for an outbound slot and time executing the call. Unchanged service time alongside a 300-fold p99 means the whole regression is queueing, so the target of the investigation is a saturated resource, not slow code.
  2. Split every metric by target_system_id next. One client's provider owns all the non-completing calls; everyone else's calls are fast once they start. Combined with step one this gives the shape immediately: one dependency's calls occupy the shared pool and every other client waits behind them.
  3. Do the arithmetic and check it against the timeline. One call per second that never completes fills all 50 slots in 50 seconds, which matches an onset measured in under a minute rather than gradual decay. If the numbers had not fit, saturation would not be the whole explanation and you would keep looking.
  4. Confirm at the socket, since a hang and a failure are indistinguishable in an error rate. Connections in ESTABLISHED with bytes sent and nothing received distinguish a silent peer from a refused connection or a reset. Note explicitly that TCP keepalive will not rescue you here: the Linux default sends its first probe after two hours, so only an application-level read timeout bounds this at all, and its absence is the root defect.
  5. Fix by bounding, in this order: a connect timeout and a read timeout on every outbound call, both set below the caller's own latency budget; a per-target concurrency limit so a single provider can occupy at most a small share of the pool; a circuit breaker keyed by target_system_id that opens on consecutive timeouts and half-opens with one probe; and a retry budget with full jitter, because three retries against a hanging peer triple the occupancy rather than improving the odds.
  6. Then reconsider the blast radius rather than only the incident. A fail-closed broker on every request path is a shared dependency whose outbound concurrency must be partitioned per client, and a refusal for one client's target must surface as a typed result the caller can classify, not as a timeout that has already consumed a slot to produce.

Follow-up

  • Choose the read timeout and justify it with evidence. What breaks for a provider that is legitimately slow, and how do you tell the two apart?
  • The breaker is open for that client. What does an in-flight connector run observe, and which failure_class should it record?
  • Could the broker have kept serving every other client with no code change at all? What would that configuration have cost you on an ordinary day?

Suggested practice checklist

For someone who has spent the last few years shipping features and reading other people's code, and who has not solved a timed problem from a blank file in a long time. Five days rebuild the primitives and the patterns that sit on them, working from invariants rather than remembered solutions, and the last two attach that back to the rest of the loop.

Day 1 — Rebuild the primitives by implementing them

  • Implement a dynamic array with doubling growth and an operation counter, then change the growth rule to add a fixed sixteen slots instead, and time both for n of ten thousand, a hundred thousand and a million. The fixed-increment version resizes n/16 times at O(n) each, so its total work is quadratic; doubling is what makes append amortised constant.
  • Implement a hash map with separate chaining and a load-factor resize, then insert ten thousand keys engineered to land in one bucket and record what happens to lookup time, so that average-case O(1) becomes a claim with a stated precondition rather than a reflex.
  • For dynamic-array append and hash-map insert, write down which cost is amortised rather than worst-case, which single operation pays the whole bill, and what a system with a hard per-operation deadline would have to do instead.

Deliverable: Two working implementations plus a timing table showing the input at which each structure's advertised complexity stops holding.

Day 2 — Arrays under an invariant: two pointers, sliding window, binary search

  • Solve longest-subarray-with-sum-at-most-K using a sliding window, then run it on an input containing negative numbers and watch it return the wrong answer: extending the window only moves the sum monotonically when every element is non-negative, and that precondition is the whole reason the technique works.
  • Write the binary search that finds the first index satisfying a predicate rather than an exact value, put the loop invariant above the loop in a comment, and verify termination on the two inputs that break careless versions: the empty range, and a range where every element satisfies the predicate.
  • Compute the midpoint as lo + (hi - lo) / 2 and write one line on why the obvious (lo + hi) / 2 is a genuine defect in a fixed-width integer type and a non-issue in a language with arbitrary-precision integers.

Deliverable: Three solved problems, each with its invariant written above the loop, plus one recorded input on which the sliding window is provably wrong.

Day 3 — Sorting, heaps, and the greedy argument that has to be proved

  • Solve one top-k problem three ways, by full sort, by a size-k heap, and by quickselect, then write the values of n and k at which each becomes the right choice, along with quickselect's quadratic worst case and why a randomised pivot makes that unlikely rather than impossible.
  • Implement bottom-up heapify and count sift-down steps to confirm it does linear work rather than n log n, because most nodes sit near the bottom of the tree and therefore move only a short distance.
  • Take interval scheduling by earliest finishing time and write the exchange argument out in full: given any optimal schedule, swapping in the earliest-finishing interval keeps it feasible and no smaller. Then construct the weighted variant where that same greedy fails and name what has to replace it.

Deliverable: A three-way top-k comparison with measured crossover points, one written exchange argument, and one counterexample to a greedy rule that looks almost identical.

Day 4 — Recursion, memoisation, and the step to a table

  • Take one problem with overlapping subproblems, such as edit distance or coin change, instrument the plain recursion with a call counter to show the blow-up, then add memoisation and re-count.
  • Convert the memoised version to a bottom-up table and state the two properties you relied on: each subproblem's result depends only on its arguments, and the dependencies form a DAG you can enumerate in order.
  • Rewrite one deep recursion with an explicit stack, then find the input length at which the original hits the interpreter's frame limit, which defaults to about a thousand frames in CPython, so you know when the rewrite is required rather than decorative.

Deliverable: One problem in three forms, naive, memoised and tabulated, with call counts for each and the input length at which recursion depth becomes the binding constraint.

Day 5 — Graphs, where most of the work is choosing the traversal

  • Implement BFS and DFS over one adjacency list, then answer for each which finds a shortest path in an unweighted graph and which you would use to detect a cycle in a directed graph, including why the in-progress versus finished distinction matters for the second.
  • Implement topological sort by in-degree, feed it a graph containing a cycle, and confirm the failure signature is that fewer than V nodes come out rather than an exception, then note that the order it produces is one of several valid ones.
  • Run a shortest-path search on a graph with a single negative edge weight and show the wrong answer, then write the precondition Dijkstra actually needs, non-negative weights, because it finalises a node's distance the first time that node is popped, and name the algorithm you would switch to and its own limit.

Deliverable: A small graph library with BFS, DFS and topological sort, plus two inputs that produce documented wrong answers under the wrong algorithm choice.

Day 6 — One day for everything that is not an algorithm

  • Sketch one system only to the depth a coding-heavy loop tends to reach: the endpoints, what the service stores, and the single query pattern that decides the schema. Stop at twenty-five minutes.
  • Prepare the project answer for an interviewer who codes, which means rehearsing the two levels they push to: the specific thing you built, and why you chose that approach over the alternative they will name. Open with a number and be ready to say what it excludes.
  • Prepare the answer to what you would do differently, choosing a real technical mistake with a specific fix rather than a complaint about process or staffing.

Deliverable: One design sketch at endpoint-and-schema depth, plus a project answer rehearsed to two levels of follow-up.

Day 7 — Solve out loud, under time

  • Do three timed problems at twenty-five minutes each in a plain editor with no autocomplete and no execution until the end, then tally separately the failures that were syntax and the ones that were approach, because those two numbers call for different fixes.
  • Narrate one solution from the first sentence, stating the approach and its complexity before writing any code, and rehearse the sentence you will use when you realise mid-solution that the approach is wrong.
  • Re-solve from blank the two problems you were slowest on this week and compare the times against the day they first appeared.

Deliverable: A recording of one fully narrated solution and a tally that separates syntax failures from approach failures.

Scope is read from what you decided

Team size, service count and tickets closed say very little. Seniority shows in the decision you owned: what you chose not to build, which constraint you traded away, whose objection you had to resolve before anything could move. A large project where you executed someone else's plan is a small story.

Can you describe a time you had to adapt to a new technology stack quickly?

Medium · behavioural and engineering judgement

Approach

  1. Pick a story where you made the decision, not one where you watched it.
  2. Close with what you would do differently, concretely.
  3. Name the disagreement and how you resolved it with evidence.

Follow-up

  • What did you decide not to do, and why?
  • What would you do differently if you ran that again?

What motivates you to take on new technical challenges in a client-facing environment?

Medium · behavioural and engineering judgement

Approach

  1. Pick a story where you made the decision, not one where you watched it.
  2. Close with what you would do differently, concretely.
  3. Give the blast radius: what could have broken, and what you measured.

Follow-up

  • How did you know your change caused the improvement?
  • What would you do differently if you ran that again?

Where do you see yourself after a couple of years?

Medium · behavioural and engineering judgement

Approach

  1. Give the blast radius: what could have broken, and what you measured.
  2. Close with what you would do differently, concretely.
  3. Pick a story where you made the decision, not one where you watched it.

Follow-up

  • What did you decide not to do, and why?
  • How did you know your change caused the improvement?