cswork / libraryOpen practice workspace

Interview guides · Gaming Laboratories International

Gaming Laboratories International Software Engineer Interview Questions & Guide 2026

System design practice for Gaming Laboratories International software engineer candidates: reach a data model and an API contract, not a box diagram.

The 60-second brief

A Software Engineer at Gaming Laboratories International (GLI) plays a critical role in the integrity and reliability of the global gaming industry. As a world-leading testing and certification laboratory, Gaming Laboratories International relies on its engineering teams to build sophisticated tools that evaluate software, hardware, and systems to ensure they meet rigorous regulatory and technical standards.

The title spans product, platform and infrastructure work, and which of those the seat actually is decides whether design or algorithms carries more weight in your preparation. The posting rarely settles it; what the team is on call for usually does.

Focus areas

  • Separate client-predicted state from durable ownership
  • Make cross-service trades atomic through escrow sagas
  • Derive idempotency keys from server-authoritative state

Overview

In this role, you are not just writing code; you are contributing to a framework of trust that underpins the gaming sector. You will likely work on projects related to automated testing, data analysis, or internal tools that support the certification process. Because the company operates in a highly regulated environment, your work requires a high degree of precision, a strong grasp of software quality assurance, and the ability to balance technical innovation with strict compliance requirements.

The Gaming Laboratories International interview process

Gaming Laboratories International candidates report 5 rounds · ≈ 4-6 weeks. The stages below are what candidates describe, not a published process.

Resume Review

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

Initial Screening

Half of this call is the part candidates treat as small talk: start date, notice period, work authorisation and its timing, location and time zone, on-call, and the number. Those are what kill offers late, after several engineers have each spent a day. Surfacing a hard constraint now costs you nothing and occasionally buys you something, since a loop compressed to fit a competing deadline can usually only be arranged if it is asked for early. The common failure is deflecting the compensation question twice, then discovering at offer stage that the band never reached your number.

What to demonstrate

  • Whether your hard constraints are compatible with the role before a loop gets booked: earliest start, notice period, what authorisation you hold and when it needs action, days on site, willingness to carry a pager
  • Whether you give a compensation range with something behind it, such as current total compensation or a competing timeline, rather than leaving the band untested
  • Whether your stated timeline is real, since a competing deadline raised now is something scheduling can sometimes work around and the same deadline raised at offer stage usually is not

How to prepare

  • Write each constraint down in one line before the call and state them as facts rather than negotiating them live under a question you were not expecting
  • Set your range from two or three current data points for that level and location, and name the structure you are quoting in, so the number is comparable to the one they are holding
  • If another process is running, say where it stands and by when, and ask directly whether this loop can be scheduled inside that window

Interviews with Managers

When a round has no standard shape, it is often there because something is still open: an area no earlier conversation reached, a round where the signal came out mixed, or a decision someone is not ready to make alone. Work out which by going back over what each earlier round actually covered rather than how it felt, and arrive able to give evidence on that point without being asked twice. Weak answers replay the loop's earlier material at the same depth. Strong ones go a level deeper and stay consistent with what you already said.

What to demonstrate

  • Whether your account of a project matches the one you gave earlier in the loop, since what you said before may be available to whoever runs this round
  • Whether you can go a level deeper on something already covered, reaching the decision and its alternatives rather than repeating the summary
  • Whether you state your own uncertainty accurately, including parts of a system you did not build and decisions you inherited, instead of claiming even ownership across all of it
  • Whether you can answer a question you handled poorly earlier by naming what you missed, rather than delivering a polished second version as if the first had not happened

How to prepare

  • Reconstruct the loop on one page: for each round, the questions you were asked and the answer you actually gave, not the better one you thought of afterwards. The gaps on that page are your best available guess at why this round exists.
  • Take the two claims you made earlier that carry the most weight and assemble the backing for each: the measurement, the date, what broke, the decision you would make differently now.
  • Write down the three facts about your work that must not drift between tellings, such as team size, timeline and your own role, and check your stories against that list rather than trusting recall under pressure

Technical Assessment

What this round decides is narrow: whether you can produce code that runs and is correct on inputs nobody showed you. An elegant solution that does not compile scores below a plain one that does, so write a correct brute force first, say out loud that you know its cost, and improve it with the working version still on screen. What separates strong answers is who finds the broken case. Trace your own code against an empty input, a single element, and duplicate keys before you say you are finished, because being told is far more expensive than noticing.

What to demonstrate

  • Whether degenerate inputs get checked without being asked for: an empty collection, one element, every element equal, and the extreme value the input type allows
  • Whether the complexity you state matches the code you actually wrote, including a sort or a copy sitting inside a loop
  • Whether the finished answer is verified against the worked examples before you call it done, rather than assumed correct because the code reads correctly

How to prepare

  • Take five problems you have already solved and, without running anything, write down what each returns for empty input, a single element, and all-duplicates. Then run them and count how many you predicted wrong.
  • Drill the brute force as its own skill: on ten problems, write only the obviously-correct slow version and time how long it takes to get it passing. If that is more than a few minutes, that is what to practise, not the optimal version.
  • Add a fixed last step before you submit anything, reading only the loop bounds and the initial value of each accumulator, which is where most off-by-one errors live

Final Interviews

A day like this is several different games in a row, and the expensive mistake is carrying the previous one into the next room. Coding rewards narrow precision and finishing inside a timer. Design rewards breadth, stated assumptions and naming what you are deliberately not building. Behavioural rewards specificity about people and decisions. Candidates who over-engineer a coding problem they were supposed to finish, or who start sketching class hierarchies before anyone has agreed what the system has to do, are usually still playing the last round. Between rooms, name out loud which game the next one is.

What to demonstrate

  • Whether the coding round ends with something that runs and has been traced against a degenerate input, rather than an extensible design that was never finished
  • Whether a design discussion opens by agreeing on traffic shape, read-to-write ratio and what is allowed to be stale, instead of proceeding from an architecture you arrived with
  • Whether a behavioural answer names a person, a disagreement and what you did about it, rather than describing the system the story happened inside
  • Whether the opening habits still appear late in the day: restating the problem, asking for constraints, saying the plan before typing

How to prepare

  • Book three mocks of different types back to back on one afternoon and ask each interviewer afterwards which round you answered in the wrong mode
  • Write a three-line opening script per round type — coding: restate, name the approach and its cost, then type; design: ask for scale, read-write mix and what must not break; behavioural: name the person, the stakes and the decision — and run it off a card so the switch is mechanical rather than remembered
  • Practise coding with a timer you do not extend, stopping when it stops, so the trained reflex is to finish a correct solution rather than to keep improving one

Where candidates lose points

Building the idempotency key from client-supplied data: a request id the client generated, a client timestamp, or a hash of the request body the client controls.

An honest client regenerates that id on retry, so the dedupe silently never fires and retries mint duplicates. A dishonest one perturbs a single byte and farms the reward deliberately. The key must be derived from state only the server can produce - match id plus signed result plus slot index, or the store receipt identifier - and the table must carry a payload hash beside it so a key replayed with different contents is rejected rather than quietly honoured.

Read-modify-write on a balance or a stack quantity - SELECT the current value, check it in application code, then UPDATE to the computed new value.

The gap between the read and the write is a lost-update window of a few milliseconds. It survives every code review and every load test run at baseline concurrency, then fires reliably when a seasonal event puts two requests on the same wallet inside that window, or when one player has two clients open. The fix is one statement - UPDATE ... SET qty = qty - :n WHERE qty >= :n - plus an affected-row check; the trap is that the broken version returns the correct answer in every single-threaded test you will write.

Issuing one query per row of a result set

Fetch related rows in a single batched query keyed by the ids you already hold, or join them into the original query. A per-row round trip multiplies network latency by the row count, and it looks perfectly fine against the ten rows in your development database.

Trusting input because it came from your own front end

Anything crossing a trust boundary is hostile: parameterise queries instead of building SQL by concatenation, validate against an allow-list rather than a deny-list, and bound the size of anything you allocate from a request. Raising this unprompted in an API or design question is a cheap and unusually strong signal.

Questions & practice

Audit ledger legs for unbalanced transactions in one pass

Easy · ledger · hashing · reconciliation

You are handed one day's currency_ledger partition as a stream of legs: txn_id UUID, wallet_id, currency_code, delta_minor BIGINT (signed), reason. Legs arrive in arbitrary order, not grouped by txn_id. Reasons in MINT_SINK = {match_reward, loot_roll, purchase_fulfil, sink_craft, sink_repair, refund_reversal, operator_adjustment} deliberately create or destroy value. Every other txn_id must have legs summing to zero and must use a single currency_code. The partition holds 2.7 million legs. Return each offending txn_id with its residual, in one pass, and state your memory bound.

Approach

  1. One hash map keyed by txn_id holding (running sum, first currency_code seen, mint_sink flag, leg count). Single pass, O(n) time, O(T) space for T distinct txn_ids.
  2. Set the exempt flag the moment any leg of a txn carries a MINT_SINK reason, but keep accumulating the sum anyway so the residual is still reportable when an operator asks why a mint was that size.
  3. Do not evict a txn when its running sum reaches zero. Legs are unordered, so a later leg can unbalance a txn that momentarily netted out; eviction is only safe at end of partition, or behind a watermark plus a known expected leg count.
  4. Memory: 2.7 million txn_ids at roughly 16 B of UUID plus 8 B of sum plus 8 B of flags is about 86 MB before hash overhead. If that is too much, hash the UUID down to a 64-bit key and accept collision risk, or run k range-partitioned passes at k times the I/O.
  5. Report two distinct defect classes, not one: residual != 0 for a non-exempt txn, and more than one currency_code under a single txn_id. The second can occur with residual exactly 0 and is invisible to a pure sum check.

Worked solution (20 min)

  1. Build an eight-leg fixture: a clean trade (buyer -10000 market_purchase, seller +9500 market_sale, house wallet +500 market_fee), a fee-less trade (buyer -10000, seller +9500, no third leg), a mint (+250 match_reward), and a mixed-currency txn (+100 in one currency, -100 in another).
  2. Shuffle the eight legs so no txn's legs are adjacent, then run the single pass.
  3. Record the map contents after every leg and confirm the fee-less trade's running sum passes through a non-final value before settling.
  4. Re-run with an eviction-on-zero optimisation added and diff the output.

Expected result: Two txn_ids reported. The fee-less trade with residual -500 minor units, and the mixed-currency txn flagged on the currency rule with residual 0. The mint is exempt and the clean trade nets to 0, so neither appears.

Verify it yourself

  • The sum of all reported residuals equals the sum of every non-exempt leg in the fixture, because balanced transactions contribute exactly 0.
  • Shuffling the input order again changes nothing in the output set.
  • The eviction-on-zero variant loses the mixed-currency txn, which is the proof that the optimisation is wrong rather than merely unnecessary.

Follow-up

  • Run the same audit continuously over an unbounded stream with no partition boundary. What bounds the map now?
  • Two legs of one txn land in different daily partitions because the writer straddled midnight. What does your job report, and what do you change so it stops lying?
  • How do you tell a genuinely unbalanced txn from one whose compensating leg was written and later reversed by a new row?

Reconcile a billion-row ledger without rescanning its full history

Hard · reconciliation · checkpointing · append-only

currency_ledger holds 1.0 billion rows for the trailing year, about 2.7 million per day, with entry_id BIGSERIAL, wallet_id, delta_minor, balance_after_minor and created_at. Nightly reconciliation must prove that for every wallet the maintained balance agrees with the ledger. The obvious job, SELECT wallet_id, SUM(delta_minor) GROUP BY wallet_id over all history, is correct but scans a billion rows and grows every night. Design a run that touches only the last day plus a bounded checkpoint, argue it catches the same defects, and name the defect class it stops catching.

Approach

  1. Split it into two independent checks. The chain check asserts, within a wallet ordered by entry_id, that balance_after_minor[i] equals balance_after_minor[i-1] + delta_minor[i]; that is a LAG window over PARTITION BY wallet_id ORDER BY entry_id, one ordered pass, O(n) given a (wallet_id, entry_id) index and O(n log n) if the planner has to sort. The endpoint check asserts the day's last balance_after_minor per wallet equals the maintained aggregate.
  2. Checkpoint only the wallets that moved. A day's 2.7 million legs touch at most 2.7 million wallets and in practice far fewer, so store (wallet_id, last_entry_id, balance_after_minor) for those and carry untouched wallets forward by reference. A full nightly snapshot of every wallet would cost more rows than the legs it is verifying.
  3. State the precondition out loud: entry_id is a sequence value assigned at insert time, not at commit time, so a lower entry_id can become visible after a higher one and can be absent entirely after a rollback. Ordering by entry_id is sound only over a closed past partition in which every transaction has settled. Run this over the live tail and it manufactures breaks that are not there.
  4. Quantify the win: 2.7 million rows plus the checkpoint every night, flat over time, against 1 billion and rising. On day 365 that is roughly 370 times less I/O, and the gap widens every day the naive job survives.
  5. Name what you give up. A day-scoped run cannot see corruption of a historical row that nothing since has touched, because nothing in the window reads it. Recover that with a periodic full re-sum, monthly or quarterly. A per-wallet hash chain or Merkle tree makes that pass tamper-evident and localises the edit, but it does not remove the need to run it.

Follow-up

  • Reconciliation reports one wallet whose maintained balance is 250 minor units above the ledger. Walk through everything you do before changing either number.
  • The day's partition is still being written when the job starts. How do you choose the cut, and what does a transaction straddling that cut do to the endpoint check?

Merge cheapest active listings across shards without full scans

Medium · k-way merge · heap · sharding

market_listing is sharded by seller_account_id across 64 shards. A buyer opens the page for one item_def_id and wants the K = 50 cheapest status='active' listings by ask_price_minor, ties broken by created_at. Each shard can return its own listings for that def already sorted, in pages. Catalog load is concentrated: a popular def may have a million active rows shard-wide. Return the global cheapest K while reading as few rows as possible, state the number of rows you read, and name the index that makes each shard's page cheap.

Approach

  1. k-way merge driven by a min-heap holding one entry per shard, keyed on the full tuple (ask_price_minor, created_at, listing_id) so the ordering is total and the same query returns the same page twice. Heapify 64 heads in O(S), then K pops at O(log S) each: O(S + K log S), roughly 64 plus 50 times 6 comparisons.
  2. Rows read is at most S + K, here 114, against the million active rows for that def. Page each shard lazily, requesting its next row only when its current entry is popped, and batch the request into pages of roughly K/S plus margin so the round trips amortise.
  3. The per-shard query needs a partial composite index on (item_def_id, ask_price_minor, created_at) WHERE status = 'active'. Without it, each shard sorts a million rows to hand you one, and the partial predicate keeps the index off the sold, cancelled and expired rows that dominate the table over time.
  4. Name the other shape too: over a single unsorted stream of n rows, use a bounded max-heap of size K for O(n log K) time and O(K) space, rejecting an incoming row in O(1) when its price is worse than the heap root. Quickselect is O(n) average but needs all n resident and gives up streaming.
  5. State the concurrency caveat rather than pretending the result is a snapshot: the shards are read at slightly different instants, so a listing can sell between two shard reads. Revalidate the chosen listing's status transition at purchase time; the merge result is a view, not a lock.

Follow-up

  • The buyer asks for page 20 rather than page 1. What does deep offset cost in a k-way merge, and what do you change to make it flat?
  • One shard times out. Do you return 49 listings, serve a cached page, or fail the request, and how does the answer differ for a browse page versus a purchase confirmation?

Add a receipt uniqueness constraint to a live orders table

Hard · online migration · unique index · audit history · ddl locking

purchase_order holds 40 million rows and shipped without any uniqueness constraint on (platform, receipt_id). A sweep finds roughly 3,000 receipt groups with duplicate orders; several hundred of those groups have two rows at fulfil_status='fulfilled', each pointing at a distinct fulfil_grant_id. New duplicates are still arriving at about two per hour. Add the constraint with no write downtime and no loss of audit history. Give the ordered plan, the exact DDL, what you do with the already-fulfilled duplicates, and what makes your index build fail or converge.

Approach

  1. Order the plan around convergence first: duplicates arrive at two an hour and no ON CONFLICT guard exists until the index does, so the write-side change that serializes receipt handling ships before the dedupe pass. Dedupe first and new duplicates appear behind you; the backfill never finishes.
  2. Enumerate precisely: SELECT platform, receipt_id, count(*), array_agg(order_id ORDER BY created_at) FROM purchase_order GROUP BY 1,2 HAVING count(*) > 1; and bucket the groups by how many rows are fulfil_status='fulfilled' — the unfulfilled duplicates are cheap, the doubly-fulfilled ones are the real work.
  3. Resolve the doubly-fulfilled groups without deleting anything. Deleting an order orphans its fulfil_grant_id and destroys the evidence a payment provider will ask for during a chargeback. The loser gets a reversal — a new grant_receipt row with reversal_of_grant_id set — and the order moves to fulfil_status='reversed'.
  4. Choose an index shape those retained rows do not break: CREATE UNIQUE INDEX CONCURRENTLY uq_order_receipt_live ON purchase_order (platform, receipt_id) WHERE fulfil_status <> 'reversed';. A full unique index cannot coexist with retained duplicate history, so the predicate is what lets you keep both.
  5. Know what CONCURRENTLY costs and how it fails: it takes SHARE UPDATE EXCLUSIVE rather than ACCESS EXCLUSIVE so writes continue, it makes two table passes and waits on every transaction that can see the table, and it cannot run inside a transaction block — so the migration framework's implicit BEGIN must be disabled for that step. A failure leaves an INVALID index that must be dropped with DROP INDEX CONCURRENTLY before retrying.
  6. State what you give up: ALTER TABLE ... ADD CONSTRAINT ... UNIQUE USING INDEX rejects partial indexes, so the invariant lives in an index rather than a named table constraint, and anything auditing information_schema.table_constraints will not find it.

Follow-up

  • One long-running analytics transaction is open against this table when you start the build. What happens, and what do you do about it?
  • A duplicate arrives in the instant the build reaches validation. Which write wins and what does the client see?
  • How does the plan change at 4 billion rows instead of 40 million?

Detect ledger drift with a window over consecutive entries

Medium · window functions · reconciliation · deterministic ordering

currency_ledger(entry_id BIGSERIAL PK, txn_id UUID, wallet_id BIGINT, currency_code TEXT, delta_minor BIGINT, balance_after_minor BIGINT, reason TEXT, created_at TIMESTAMPTZ) records both the signed delta and the balance the writer computed after applying it. Every write to one wallet is serialized by a row lock on that wallet. The table takes roughly 10^9 rows a year and is partitioned by month. Write a query that finds entries where balance_after_minor disagrees with the deltas while scanning only yesterday's rows rather than replaying all history, and say what the first entry per wallet inside that window does to your check.

Approach

  1. Reject the cumulative form for a bounded scan: SUM(delta_minor) OVER (PARTITION BY wallet_id ORDER BY entry_id) restarts at zero at the window's first row, so every row in yesterday's slice disagrees with balance_after_minor by that wallet's opening balance. It is only correct over all history, which at 10^9 rows a year is not a scheduled job.
  2. Use the consecutive-difference form instead: balance_after_minor - LAG(balance_after_minor) OVER (PARTITION BY wallet_id ORDER BY entry_id) must equal delta_minor. It only ever compares neighbours, so it is correct on any contiguous slice.
  3. Handle the boundary row explicitly. LAG is NULL for the first entry of each wallet in the window, the comparison evaluates to NULL, and the row silently passes — either exclude it with ROW_NUMBER() = 1 or seed the window with the last entry before the partition boundary so the join to prior history is exact.
  4. Make the ordering total, and do not reach for the frame clause to do it: LAG ignores the window frame entirely in PostgreSQL — only first_value, last_value and nth_value are frame-sensitive — so adding ROWS BETWEEN 1 PRECEDING AND CURRENT ROW changes nothing. Ordering by a non-unique created_at breaks the check because the order among tied rows is arbitrary: LAG returns whichever peer the sort happened to place before the current row, which need not be the entry that applied before it, so correct rows are reported as drift and the same query can disagree with itself across plans. Order by entry_id, or by (created_at, entry_id) if you want time order. Peer inclusion under the default RANGE frame is a real hazard for the cumulative-SUM form above, because window aggregates do respect the frame — it just has no bearing on LAG.
  5. Name the precondition that makes entry_id order meaningful: writes to a single wallet serialize on that wallet's row lock, so per-wallet id order matches apply order. Global entry_id order does not match global commit order and the sequence is not gapless, but neither matters because the window is partitioned by wallet.
  6. Cost it: the window needs input ordered by (wallet_id, entry_id), so a btree on those columns lets the scan feed the window directly and skip an O(n log n) sort. Bound the scan to one partition and one currency.

Worked solution (30 min)

  1. Write both the cumulative-sum and the LAG version against a single day's partition.
  2. Corrupt exactly one row's balance_after_minor by a known amount.
  3. Run both queries and count the rows each returns.
  4. Give one wallet two entries with identical created_at, switch the ORDER BY to created_at alone, and add lag(entry_id) OVER (same window) to the select list to see which neighbour each row was actually given; toggle enable_indexscan and enable_seqscan to change the plan and watch that neighbour, and the drift verdict, move while the data does not.
  5. EXPLAIN the LAG version with and without a (wallet_id, entry_id) index and compare for the presence of a Sort node.

Expected result: The LAG query returns exactly the injected row; every other row satisfies balance_after_minor - LAG(balance_after_minor) = delta_minor. The cumulative-sum form returns every row in the window, because the running total starts at zero rather than at each wallet's opening balance.

Verify it yourself

  • One injected corruption produces exactly one returned row, not a wallet's worth.
  • The first entry per wallet in the window is excluded explicitly rather than passing on a NULL comparison.
  • EXPLAIN shows the window fed by an index scan with no Sort above it.
  • Under ORDER BY created_at alone, lag(entry_id) on a tied pair returns something other than the immediately preceding entry_id and those rows are reported as drift; adding entry_id to the ORDER BY removes them, while adding an explicit ROWS frame does not.

Follow-up

  • The check fires on 40 wallets. Is the ledger wrong or the maintained balance wrong, and what distinguishes them?
  • Make this cheap enough to run every five minutes instead of daily. What do you give up?
  • A row is inserted into yesterday's partition after the check ran. How do you notice?

Do you have experience with software testing methodologies?

Medium

Approach

  1. Work from the requirement backwards to the design.
  2. State your assumptions explicitly before working the problem.
  3. Say what you would check first and why it is the highest-information step.

Follow-up

  • What assumption would you test first?
  • How would you know your answer was wrong?

Can you complete a coding program within a one-week timeframe?

Medium

Approach

  1. Work from the requirement backwards to the design.
  2. Clarify what is being asked and what a complete answer contains.
  3. State your assumptions explicitly before working the problem.

Follow-up

  • What assumption would you test first?
  • How would you know your answer was wrong?

Set rate limits across browse, write, and internal grant paths

Medium · rate limiting · backpressure · token bucket

Three callers share one API: a game client browsing marketplace listings (read-heavy, concentrated on roughly the top 1% of item_def_ids), the same client placing and buying listings (each write takes an escrow lock and runs a cross-service saga), and the session service submitting match results and reward grants. Define the rate-limiting contract for each: the algorithm, the key the budget is charged to, the response when the budget is exhausted, and the headers the caller reads. State what each caller does on rejection, and justify any path you decide must never be rejected.

Approach

  1. Separate budgets by cost of service, not by endpoint count. A listing read is a partial-index range scan; a buy takes an escrow lock and drives a multi-service saga with compensations. One shared bucket lets a browse burst consume the budget the buy path needs, and the symptom reads as a marketplace outage rather than as a limiter working.
  2. Choose token bucket over fixed window and say why: a fixed window admits roughly double the intended rate across a boundary, which is exactly the burst shape you get when the hot head of the catalog is contended.
  3. Charge the budget to the account id for authenticated calls, with a secondary bucket on IP or device to bound one actor driving many accounts, and state the precedence when both apply - the stricter remaining budget wins, and the response says which one was hit so the client is not debugging blind.
  4. Reject with 429 plus Retry-After and a RateLimit-style header family (limit, remaining, reset). Require jittered backoff in the contract; without jitter every rejected client returns in the same millisecond and you have built a synchronised retry storm.
  5. Do not rate-limit the reward grant path. A rejected grant is permanently lost value and the session service has nowhere to hold it once the instance is gone. Apply backpressure with a durable queue, per-shard quota and queue-depth alerting instead - the difference between shedding load and destroying value.
  6. Decide how a replayed idempotency key is charged. Answering it from the dedupe table is a read, so charging it at the write rate turns a correct retry into a rejection loop that looks exactly like the failure the retry was recovering from.

Worked solution (30 min)

  1. Tabulate the three callers against cost per call, the key the budget is charged to, and the consequence of rejecting that call.
  2. Pick a rate and a burst size for each bucket and show the arithmetic that ties them to the cost estimate, not to a round number.
  3. Write the 429 response - status, Retry-After, header family, body code - and the exact client behaviour it mandates.
  4. Construct the fixed-window boundary case numerically and show the burst a token bucket would have smoothed.
  5. Write the one paragraph justifying the unlimited path, including what replaces the limiter as its safety mechanism.

Expected result: Three budgets: browse, token bucket keyed on account id with a permissive rate and a burst allowance, secondary IP bucket; write (list, buy, cancel), token bucket keyed on account id at a far lower rate because each call takes an escrow lock; internal grants, not rate-limited at all - backpressure is a durable queue with per-shard quota and queue-depth alerts. Rejections return 429 with Retry-After and limit/remaining/reset headers, and the contract mandates jittered backoff. A replayed idempotency key is charged as a read.

Verify it yourself

  • Saturating the browse budget leaves the write budget untouched for the same account.
  • No path whose rejection permanently destroys value is behind a limiter.
  • A client that obeys Retry-After with jitter never sees a second 429 caused by its own retry pattern.
  • The response tells the client which bucket rejected it and when it may retry, without a second call.

Follow-up

  • A live event pushes legitimate write volume 30x over baseline for two hours. What in your design bends rather than breaks?
  • How do you rate-limit the search path when a single item_def_id absorbs most of the traffic and per-account limits are all under budget?
  • The client ignores Retry-After. What does your server do that does not also punish well-behaved clients?

Marketplace settles fine except on the hottest item definitions

Medium · deadlock · lock contention · lock ordering · sagas

Trade settlement succeeds at 99.9% overall, but for the top 1% of item_def_id values the failure rate is 4%, with PostgreSQL SQLSTATE 40P01 (deadlock detected) in the logs alongside lock waits over one second. The settlement transaction locks the seller's item_instance row, debits the buyer wallet, transfers the instance, credits the seller net of fee, then releases. Buyers commonly have several listings in flight at once. Produce the ordered checklist, the precise interleaving that deadlocks, and two fixes with their costs.

Approach

  1. Pull the deadlock report itself rather than theorising. PostgreSQL logs both waiting processes, both statements and the cycle when the detector fires after deadlock_timeout, which defaults to 1 s. That report hands you the two lock-acquisition orders directly.
  2. Separate the two signals in the same incident: 40P01 is a cycle that the detector resolves by aborting one transaction, while a multi-second wait with no abort is ordinary contention on a hot row. Check pg_stat_activity for wait_event_type = 'Lock' to see which dominates, and turn on log_lock_waits if the waits are not already logged.
  3. Reconstruct the cycle. Two settlements touch overlapping wallet and instance rows and acquire them in whatever order the saga's step sequence produced. Transaction A holds the buyer wallet row and waits on a seller wallet row held by B, while B waits on A's. The hot head of the catalog concentrates the same counterparties onto the same rows, which is exactly why only 1% of def ids show it.
  4. First fix, cheap and local: impose a total order on row acquisition within any single transaction — sort wallet ids and instance ids and lock ascending. A cycle requires two transactions to disagree about order, so a global order makes the cycle impossible. The cost is that the code no longer acquires rows in business order.
  5. Second fix, structural: shorten the lock hold. The escrow lock already lives on the listing row via item_instance.escrow_id, so the debit and the credit can be separate short transactions in the saga rather than one long one. Fewer locks are held at once, but intermediate states become observable and every step then needs an idempotent compensation.
  6. Either way the caller must retry on 40P01 with jittered backoff: under contention a deadlock abort is a normal outcome the application owns, not in itself a defect.

Follow-up

  • Under REPEATABLE READ you would see SQLSTATE 40001 serialization failures instead of, or alongside, 40P01. Which of the two tells you the lock order is wrong?
  • How does partitioning listings by item_def_id change the contention profile, and which part of this problem does it not help at all?

Suggested practice checklist

For someone fluent in a dynamic language who has shipped real work but has never had to say what the runtime is doing underneath. The week is built on measuring and deliberately breaking things, because the questions that expose this background are the ones where the interviewer asks why a second time.

Day 1 — Measure before reasoning

  • Take a slow piece of your own code, write down in advance where you believe the time goes, then profile it and record how wrong the guess was. The cost is usually an allocation you did not notice or an accidental quadratic membership test.
  • Replace one list membership test inside a loop with a set and measure at a thousand, ten thousand and a hundred thousand elements, confirming the shape of the curve rather than only that it got faster.
  • Write down the three quantities you can now measure instead of assert: wall time, peak memory, and call count for the function you suspected.

Deliverable: A before-and-after profile of real code plus a written note on the size of the gap between the guess and the measurement.

Day 2 — References, copies, and the bugs they produce

  • Write the function with a mutable default argument, call it three times, and explain the accumulating result: the default is evaluated once when the function is defined, so every call shares one object.
  • Build a nested structure, take a shallow copy, mutate an inner element, and show that both views changed, because a shallow copy duplicates the container and not the elements. Then fix it with a deep copy and state the cost you just accepted.
  • Write two functions, one mutating its argument in place and one rebinding the local name, and predict the caller's view of each before running it. That single distinction produces most of the bugs that pass their tests.

Deliverable: Three small programs whose output you predicted correctly before running, each with a one-line statement of the rule underneath.

Day 3 — Types, once, in a language that checks them

  • Port one module you have already written, roughly a hundred lines, into a statically typed language, and record every place the compiler demanded an answer your original had left implicit: a value that can be absent, a numeric width, a case never handled.
  • Write the same signature in both languages and state what the static one guarantees before the program runs and what it does not, since it will not save you from a wrong algorithm or an index out of range.
  • Write the difference between an interface satisfied by declaration and one satisfied structurally, with one case each where the other approach would miss the mistake.

Deliverable: One module in two languages plus a list of the questions the type checker forced you to answer.

Day 4 — Concurrency, starting with what actually runs at the same time

  • Run the same CPU-bound function across four threads and four processes and measure both. Under the default CPython build the threaded version will not speed up, because only one thread executes bytecode at a time; the process version will. Check which build you are on first, since free-threaded builds remove that lock and change the result.
  • Then run a blocking I/O workload across four threads and measure it speeding up, because the interpreter releases that lock around blocking calls, which is why treating threads as useless is wrong as a general claim.
  • Build the lost update: two threads each incrementing a shared counter a hundred thousand times, and show a final value below the expected sum, because an increment is a load, an add and a store and the thread can be suspended between them. Fix it with a lock and then measure what the lock costs.

Deliverable: Three measurements, threads against processes on CPU work, threads on I/O work, and a demonstrated lost update, each with the mechanism written underneath.

Day 5 — Debugging as a procedure rather than an instinct

  • Work one real failure as a bisection: find a revision or an input size where it is good and one where it is bad, halve repeatedly, and state the two assumptions bisection needs, that the property changes exactly once across the range and that the test is reliable.
  • Minimise one failing input to the smallest version that still fails, and record how many rounds it took.
  • Keep a hypothesis log for one bug in three columns, what I believe, what would disprove it, what I observed, and stop yourself the first time you are about to change two things at once.

Deliverable: One bug worked to root cause with a written hypothesis log and a minimised reproducing input.

Day 6 — Tests that catch the bug you are about to write

  • Implement an LRU cache with a capacity bound, then write the three test cases that would catch an off-by-one in eviction: insert exactly capacity items and assert nothing was evicted, insert one more and assert the least recently used key is the one gone, and read an old key just before that insert so the eviction victim changes.
  • Add a property test comparing your implementation against a deliberately slow reference, an ordered list scanned linearly, over a few thousand random operation sequences, because a slow reference finds the cases you would not have thought to write.
  • Write one numeric test that fails under exact equality and passes with a tolerance, and state why the tolerance has to be relative rather than absolute once the magnitudes grow.

Deliverable: An LRU implementation with three boundary tests, one property test against a slow reference, and one tolerance-based numeric test.

Day 7 — Debug something broken, out loud

  • Have someone plant three defects in a two-hundred-line program, an off-by-one, a shared mutable state bug, and a wrong error-handling path, then find them while narrating, under a fixed rule: state the hypothesis before touching anything.
  • Time each one and record which tool found it, reading, a printed value, a debugger, or a test, because the question asked in interviews is how you would find it rather than what it was.
  • Write the sentence you will use when you do not yet know the cause, one that names the next measurement instead of offering a guess.

Deliverable: A recorded debugging session with time-to-find per defect and the method that found each.

What the behavioural round is actually measuring

Every story you tell gets read for blast radius and judgement: what could have broken, who else it touched, what you knew at the moment you decided. Nobody can audit your code in an hour, so they audit your reasoning instead. Pick work where the call was genuinely yours and the consequences were real enough to remember.

If you were able to create a dream job position, what would it be and why?

Medium · behavioural and engineering judgement

Approach

  1. State the situation in two sentences and spend the rest on the reasoning.
  2. Give the blast radius: what could have broken, and what you measured.
  3. Pick a story where you made the decision, not one where you watched it.

Follow-up

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

What kind of games do you like?

Medium · behavioural and engineering judgement

Approach

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

Follow-up

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

What is your experience with software testing?

Medium · behavioural and engineering judgement

Approach

  1. Pick a story where you made the decision, not one where you watched it.
  2. Name the disagreement and how you resolved it with evidence.
  3. State the situation in two sentences and spend the rest on the reasoning.

Follow-up

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