Chapter 08 · Transactions, Atomicity, Journaling, and Savepoints
Transaction Design, Idempotency, and Failure Injection
Design short, retry-aware, idempotent SQLite transactions by mapping business invariants to atomic boundaries and injecting failures before trusting the workflow.
Learning outcomes
Correct SQL does not automatically produce a correct transactional application. A production transaction should map to a business invariant, be short enough to reduce contention, behave predictably when retried, and acknowledge that SQLite cannot roll back side effects in the filesystem or network.
Map FieldNotes business invariants to transaction boundaries.
Explain why user interaction and slow network calls should not sit inside database transactions.
Design an idempotent request pattern using a stable request identifier.
Inject failures before, during, and after writes and predict final state.
Distinguish SQLite-controlled state from external side effects.
Apply a reusable transaction-design checklist for later application chapters.
Start with the invariant, not with BEGIN
A transaction boundary should surround the smallest set of database state changes that must be observed together. For FieldNotes, consider “consume one spare part and record the maintenance action exactly once.” The invariant is stronger than “both INSERT statements are syntactically valid.”
DROP TABLE IF EXISTS command_receipt;DROP TABLE IF EXISTS part_consumption;DROP TABLE IF EXISTS service_log;DROP TABLE IF EXISTS spare_part;CREATE TABLE spare_part( device_code TEXT NOT NULL, sku TEXT NOT NULL, qty INTEGER NOT NULL CHECK(qty >= 0), PRIMARY KEY(device_code, sku)) WITHOUT ROWID;CREATE TABLE service_log( service_id INTEGER PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, device_code TEXT NOT NULL, summary TEXT NOT NULL);CREATE TABLE part_consumption( service_id INTEGER NOT NULL REFERENCES service_log(service_id) ON DELETE CASCADE, sku TEXT NOT NULL, qty INTEGER NOT NULL CHECK(qty > 0), PRIMARY KEY(service_id, sku)) WITHOUT ROWID;CREATE TABLE command_receipt( request_id TEXT PRIMARY KEY, status TEXT NOT NULL CHECK(status IN ('committed')), service_id INTEGER NOT NULL UNIQUE REFERENCES service_log(service_id));INSERT INTO spare_part VALUES('PUMP-007','SEAL-KIT',2);Transaction duration is an operational decision
Open transactions retain snapshots, locks, dirty state, journal/WAL resources, and application assumptions. Long transactions increase the window in which other work can conflict or be delayed. Keep user think-time and unpredictable external latency outside the database transaction whenever the invariant allows it.
| Inside transaction? | Example | Reason |
|---|---|---|
| Usually yes | Check stock precondition, decrement stock, write service log, write command receipt. | These database facts define one atomic command. |
| Usually no | Wait for a technician to click Confirm. | Human latency can be seconds or minutes. |
| Usually no | Call a third-party HTTP API. | Network latency/failure holds database resources while SQLite cannot roll back the remote system anyway. |
| Usually no | Send email directly. | Email delivery is external side effect; use a durable database intent/outbox pattern if needed. |
| Depends | Large batch transformation. | May need chunking/savepoints and a documented partial-success contract. |
Idempotency: retrying the same request should not duplicate the business effect
Failures are ambiguous. A client can lose its connection after the database committed but before it received the success response. If it retries blindly with a new operation identity, stock may be consumed twice. An idempotency key or stable request ID lets the database recognize the same logical command.
BEGIN IMMEDIATE;-- 1. If this request already committed, application should return the saved result.SELECT service_idFROM command_receiptWHERE request_id='REQ-9001';-- 2. Only if no receipt exists, verify and consume stock.UPDATE spare_partSET qty = qty - 1WHERE device_code='PUMP-007' AND sku='SEAL-KIT' AND qty >= 1;SELECT changes() AS stock_rows; -- must be exactly 1-- 3. Record the service command using the same stable request ID.INSERT INTO service_log(request_id,device_code,summary)VALUES('REQ-9001','PUMP-007','Replace mechanical seal');-- application captures service_id using RETURNING or driver generated-key API-- suppose the returned service_id is 1 for this disposable labINSERT INTO part_consumption(service_id,sku,qty)VALUES(1,'SEAL-KIT',1);INSERT INTO command_receipt(request_id,status,service_id)VALUES('REQ-9001','committed',1);COMMIT;In production, do not hard-code service_id=1; capture it with RETURNING or your driver API as Chapter 6 taught. The schema’s UNIQUE/PRIMARY KEY constraints make duplicate request IDs observable instead of silently duplicating state.
Failure injection: test the points where assumptions break
Create a fresh disposable database for each scenario. Before running it, predict the final stock quantity, service row count, consumption row count, and receipt row count.
| Injection point | Action | Expected database outcome if transaction handling is correct |
|---|---|---|
| Before BEGIN | Simulate validation failure. | No database transaction; no changes. |
| After stock decrement, before service INSERT | Raise application exception and ROLLBACK. | Stock returns to original value; no service/receipt. |
| After service INSERT, before receipt | Raise exception and ROLLBACK. | All in-transaction database writes disappear. |
| After COMMIT, before HTTP response reaches client | Simulate lost response. | Database is committed; retry must detect request_id and avoid duplicate effect. |
| After COMMIT, email send fails | External side effect failed. | Database cannot roll back the already committed transaction; application needs separate recovery/outbox logic. |
Executable failure-injection harness
This compact Python harness uses explicit autocommit mode so every transaction boundary is visible in SQL. Run it only against the disposable lab database.
import sqlite3con = sqlite3.connect("txn-design-lab.db", isolation_level=None)con.execute("PRAGMA foreign_keys=ON")try: con.execute("BEGIN IMMEDIATE") cur = con.execute( """UPDATE spare_part SET qty=qty-1 WHERE device_code=? AND sku=? AND qty>=1""", ("PUMP-007", "SEAL-KIT"), ) if cur.rowcount != 1: raise RuntimeError("stock precondition failed") # Failure injection point: database change exists only inside this transaction. raise RuntimeError("simulated application failure after decrement") # Unreachable writes would follow here. con.execute("COMMIT")except Exception: con.execute("ROLLBACK")qty = con.execute( "SELECT qty FROM spare_part WHERE device_code=? AND sku=?", ("PUMP-007", "SEAL-KIT"),).fetchone()[0]print(qty) # expected original quantity: 2con.close()SQLite atomicity ends at the database boundary
one SQLite transaction
+-------------------------------------+
| stock row |
| service_log row |
| part_consumption row |
| command_receipt row |
+-------------------------------------+
all commit / rollback
file rename HTTP request email/SMS
| | |
+------------------+----------------+
NOT automatically rolled back by SQLiteIf a workflow needs a database change and an external action to behave reliably together, store a durable intent inside the database transaction, commit, then let another component perform/retry the external action. This is the basic motivation for an outbox pattern; full distributed consistency is beyond this SQLite chapter.
Large batches: atomicity versus contention versus recoverability
“One enormous transaction is fastest” is not a universal design rule. Batching can reduce per-transaction overhead, but very large transactions can hold writer status longer, grow rollback/WAL resources, increase recovery work, and make retry granularity coarse. Measure realistic workloads later in Chapter 18.
| Batch strategy | Benefit | Cost / question |
|---|---|---|
| One row per transaction | Small retry unit. | High commit overhead; poor throughput for bulk ingestion. |
| One bounded batch per transaction | Balances amortized commit cost with manageable retry scope. | Need a batch-size policy based on measurements and contention. |
| Entire import in one transaction | Strong all-or-nothing semantics. | Potentially long writer hold and expensive restart if huge. |
| Outer transaction + savepoints | Supports controlled per-item recovery. | Only correct if partial success is acceptable. |
Retry design: retry conditions, not arbitrary SQL
Chapter 9 will cover SQLITE_BUSY policies. Even before that, distinguish a retryable transport/contention event from a failed business precondition. Retrying “stock quantity was already zero” does not make it valid. Retrying an ambiguous post-COMMIT network response should use the same request ID and read the receipt.
A retry should repeat the same logical request identity. New random request IDs turn a retry into a second business command.
Transaction-design checklist
Use this checklist in Chapters 9, 15, 17, 18, and the capstone:
- Invariant: What must never be observed half-done?
- Scope: Which database rows/tables must change together?
- Preconditions: Which predicates/counts prove the command is still valid?
- BEGIN mode: Is DEFERRED acceptable, or should writer contention be discovered with IMMEDIATE?
- Duration: Can user input, network I/O, sleeps, or expensive computation move outside?
- Error path: For every failed statement/exception, what explicit rollback/recovery path runs?
- Idempotency: What stable request identity prevents duplicate effects after ambiguous failures?
- External effects: Which files/messages/API calls are outside SQLite atomicity?
- Verification: What row counts/RETURNING values/invariants are checked before commit?
- Failure injection: Have you tested before commit, during the unit, after commit-before-response, and restart/retry?
End-of-chapter verification
Chapter 08 checkpoint
Explain each answer as an operational consequence.
- Why is autocommit not the absence of transactions?
- What design problem does BEGIN IMMEDIATE solve compared with DEFERRED?
- What information does a rollback journal preserve?
- Why does RELEASE of an inner savepoint not equal durable commit?
- Why does a stable request ID matter after a lost response?
- Name two side effects SQLite cannot roll back.
Review the answers
SQLite automatically wraps database access in transactions; autocommit describes automatic boundaries. IMMEDIATE moves writer acquisition/contention toward transaction start. A rollback journal preserves original page content/metadata needed to restore pre-transaction state. Inner RELEASE removes a rollback boundary but the outer transaction still controls durability. Stable request IDs let a retry recognize an already-committed command. HTTP calls, emails, filesystem operations, and other external systems are outside SQLite rollback.
Production judgment and Chapter 9 bridge
You now have the transaction model needed for concurrency: explicit atomic boundaries, write-intent timing, rollback-mode crash safety, savepoint recovery, idempotent retry identity, and bounded transaction duration. Chapter 9 adds multiple processes, lock contention, busy handling, WAL readers/writers, checkpoints, and the production history behind modern WAL reliability.