A billing run holds one DB-API connection open for hours and silently writes only some of its rows — how do you diagnose and fix it?
answer
- Two failures here, and reporting is one
- Ask where the commit boundaries are
- An idle socket is not a live one
- A swallowed driver error becomes a success line
- Re-runnable beats correct-first-time
basics
~20 sSuspect transaction scope and connection liveness: one giant transaction lost to a mid-run failure, a swallowed driver error after a per-batch commit, or a dropped idle connection. Fix with bounded per-batch commits, errors that propagate, and idempotent re-runs.
solid answer
~50 sThree causes account for most of these. One: the run wraps everything in a single implicit transaction, so any failure — including the process being killed — rolls the lot back and a run that "finished" wrote nothing. Two: it commits per batch but swallows the driver's exception, so the job logs success after stopping early. Three: the connection sat idle long enough for the server or a proxy to drop it, and the next statement either raises or lands on a silently re-established connection whose uncommitted work is gone. Diagnose by comparing rows written against rows expected and logging every commit boundary with a count. Fix by taking a connection per bounded unit of work, committing per batch, re-raising driver errors, and making writes idempotent on a natural key so a re-run repairs the gap.
code
python · 26 linesimport sqlite3
from contextlib import closing
con = sqlite3.connect(":memory:")
with closing(con.cursor()) as setup:
setup.execute("CREATE TABLE subscription(id INTEGER PRIMARY KEY, cents INTEGER)")
setup.execute("CREATE TABLE charge(sub_id INTEGER PRIMARY KEY, cents INTEGER)")
setup.executemany(
"INSERT INTO subscription(cents) VALUES (?)",
[(n * 100,) for n in range(1, 12)], # an 11-person team
)
con.commit()
with closing(con.cursor()) as read, closing(con.cursor()) as write:
read.execute("SELECT id, cents FROM subscription ORDER BY id")
while rows := read.fetchmany(4):
write.executemany(
"INSERT OR REPLACE INTO charge(sub_id, cents) VALUES (?, ?)",
rows,
)
assert write.rowcount == len(rows), "batch truncated"
con.commit()
print(con.cursor().execute("SELECT count(*), sum(cents) FROM charge").fetchone())
# (11, 6600)
con.close()go deeper
Know that nothing is written until commit() is called and that a job doing all its work in one transaction loses everything on a crash. Be able to add a commit inside the loop.
Explain per-batch commits, why fetchmany() beats fetchall() for a long read, and why swallowing the driver's exception turns a failure into a false success line in the log.
Diagnose from evidence: compare intended against committed counts, correlate commit markers with server disconnect logs, and redesign the run into bounded, idempotent, re-runnable units with connections acquired per unit of work.
Own the operating standard for batch jobs — checkpointing, idempotency keys, connection lifetime and pool policy, and the rule that a run reporting success must have proved its counts. Decide when a job should move to a queue-driven design instead.
## Start by making the truncation visible A run that writes "only some" rows and reports success has two failures, and the reporting one is the more serious. So the first fix is instrumentation, not architecture: log the number of rows the run *intended* to process, log a count and a commit marker at every transaction boundary, and assert the totals at the end. `cursor.rowcount` after an `executemany()` gives you the driver's own count of affected rows, and comparing it against the length of the batch you passed turns a silent truncation into a loud one. Until that exists, every theory below is unfalsifiable. ## Cause one: one transaction for the whole run A PEP 249 connection is transactional by default and opens its transaction implicitly. A job that loops for hours and calls `commit()` once at the end is holding one transaction the entire time. Any failure before that call — an exception, an out-of-memory kill, a deployment rolling the pod — discards everything, because closing a connection with an open transaction is a specified *implicit rollback*. The symptom is often not "nothing written" but "a prefix written": the run was restarted, the second attempt got further, and what you are looking at is the last successful partial attempt. A long-open transaction is also expensive on the server side in ways that are the database's story rather than Python's, and your operators will notice before you do. ## Cause two: the error was swallowed The second pattern is a run that *does* commit per batch, wrapped in a broad `except Exception:` that logs and continues — or worse, `pass`. Now a failure at batch nine of twenty leaves nine batches committed, eleven missing, and a success line in the log. This is the shape that most deserves the name silent truncation, and it is a code-review finding, not a debugging one: catch the driver's error class, add context, re-raise, and let the scheduler decide about retries. ## Cause three: the connection did not survive the idle time Long-running jobs frequently do a slow non-database step between database steps. Meanwhile a server-side idle timeout, a connection proxy, a load-balancer idle reaper or a failover can drop the socket. On the next `execute()` you get the driver's `OperationalError` or `InterfaceError` — if you are lucky. If a pooling layer or a reconnect helper is in play, it may hand you a *new* connection transparently, and any uncommitted work from before the drop is simply gone while the code carries on. That is the case that produces truncation with no exception anywhere. The diagnosis is correlation: line up the run's commit markers against the database's own log of terminated connections and against the idle windows in your job. The fix is not a longer timeout. It is to stop holding a connection across the idle stretch — acquire one per unit of work, from a pool that validates liveness before handing it over, and release it before the slow non-database phase. ## Cause four: the connection is being shared If the run was parallelised, check the driver module's `threadsafety` global before assuming a connection may cross threads: PEP 249 defines levels 0 to 3, and only the highest says connections and cursors may be shared freely. Sharing one connection below that level produces interleaved statements in one transaction and corrupted result sets — which, again, looks like missing rows. Give each worker its own connection. ## The design that makes it stop happening Structure the run as bounded units of work: read a batch, write it, commit, repeat. The batch size is a tradeoff — larger means fewer commits and less overhead, smaller means less to redo after a failure and shorter transactions — and a few hundred to a few thousand rows is the usual landing zone. Keep the reading cursor streaming with `fetchmany()` rather than materialising the whole set, and be aware that on some drivers committing invalidates an open result set on the same connection, so read the ids first or use a separate connection for the read. Then make the run **re-runnable**. Idempotent writes keyed on a natural key — an upsert on `(subscription_id, period)` rather than a blind insert — turn recovery into "run it again" instead of "reconcile by hand", and mean a partial run is a delay rather than an incident. Record progress in the same transaction as the work, so the checkpoint and the rows can never disagree. Finally, close deterministically: `contextlib.closing` around cursors and connections releases resources at a known point instead of at finalisation time. ## What a strong answer sounds like The candidate who has lived through this does not jump to a cause. They ask what the run's commit cadence is, whether the error path is swallowed, how long the connection sits idle, and whether the writes can be replayed — and they say plainly that a job which cannot be safely re-run is the real defect, whatever dropped the connection.
- How do you choose the commit batch size for a run like this?Trade redo cost against overhead. Each commit costs a durable write and ends a transaction, so very small batches are slow; very large ones mean a long-open transaction, more to redo after a failure, and more server-side cost. A few hundred to a few thousand rows is the usual landing zone, tuned by measuring throughput at two or three sizes. The other input is recovery: pick a size whose redo you are willing to pay after a mid-run failure.
- The pool hands the job a connection that the server closed while it was idle. How do you keep that from corrupting a run?Do not hold a connection across idle stretches: acquire per unit of work and release before the slow non-database phase. Use a pool that validates liveness before handing a connection out and recycles connections older than the server's idle timeout. Crucially, never let a reconnect happen silently mid-transaction — a transparent replacement connection has none of your uncommitted work — so treat a disconnect as a failed unit of work and replay it, which only works if the writes are idempotent.
- The team wants to parallelise the run across workers. What do you check first?The driver module's `threadsafety` global, which PEP 249 defines from 0 to 3; only level 3 permits sharing connections and cursors freely across threads. Below that, give every worker its own connection — sharing one produces interleaved statements inside a single transaction and mangled result sets, which reads as missing rows. Then partition the work so two workers cannot write the same key, and keep each worker's commits scoped to its own partition.
- Why do you insist on idempotent writes before you insist on a fix for the disconnect?Because you cannot prevent every disconnect, deployment or kill, but you can make the response to one boring. If a re-run repairs a partial result rather than duplicating it, a truncated run becomes a delay instead of a manual reconciliation. Keyed upserts on a natural key such as subscription and period, plus a progress checkpoint written in the same transaction as the work, are what buy that. The connection fix then improves the failure *rate* rather than the failure *cost*.
It is the difference between saving a long document once at the end and saving each chapter as you finish it: the second loses at most a chapter, and it tells you which one.
saying these in an interview costs you the question
- Blames the database without checking the commit cadence
- Proposes raising the idle timeout as the whole fix
- Keeps one connection open for the entire multi-hour run
- Catches Exception around the database block and logs success
- Assumes a silent reconnect preserves uncommitted work
- Shares one connection across workers without checking threadsafety