Skill: quochungto/bookforge-concurrency-anomaly-detector (version 1.0.0)

--- name: concurrency-anomaly-detector description: | Scan application code, SQL queries, or ORM code for exposure to the 6 database concurrency anomalies and produce a findings report with severity, affected locations, and fix recommendations. Use when: debugging a nondeterministic data corruption or race condition bug under concurrent load; auditing transaction code before deployment or after switching databases (isolation defaults differ across engines); a read-modify-write cycle or check-then-act pattern may be exposed to lost updates or write skew; an aggregate query (COUNT, SUM) guards an INSERT or UPDATE (phantom read exposure); or multiple tables are updated in one transaction without serializable isolation. Distinct from transaction-isolation-selector (which chooses the isolation level) — this skill scans code to find which anomalies existing code is already exposed to. Covers Python, Java, Go, JavaScript, Ruby; raw SQL; ORM code (SQLAlchemy, Hibernate, ActiveRecord, GORM); PostgreSQL, MySQL InnoDB, Oracle, SQL Server, and distributed databases. Maps code patterns (read-modify-write, SELECT/INSERT pairs, cross-table boundaries, snapshot boundary reads) to anomaly type, trigger conditions, and minimum fix (isolation upgrade vs. application-level mitigation). version: 1.0.0 homepage: https://github.com/bookforge-ai/bookforge-skills/tree/main/books/designing-data-intensive-applications/skills/concurrency-anomaly-detector metadata: {"openclaw":{"emoji":"📚","homepage":"https://github.com/bookforge-ai/bookforge-skills"}} status: draft depends-on: - transaction-isolation-selector source-books: - id: designing-data-intensive-applications title: "Designing Data-Intensive Applications" authors: ["Martin Kleppmann"] chapters: [7] tags: [transactions, concurrency, race-conditions, dirty-read, dirty-write, read-skew, lost-update, write-skew, phantom-read, isolation-levels, snapshot-isolation, serializable, mvcc, postgresql, mysql, oracle, sql-server, code-review, audit] execution: tier: 2 mode: full inputs: - type: codebase description: "Application source code containing transaction logic, SQL queries, or ORM calls — the primary input" - type: document description: "Transaction description or architecture summary if no codebase is available" tools-required: [Read, Grep, Write] tools-optional: [Bash] mcps-required: [] environment: "Run inside a project directory. Grepping for SQL keywords and transaction patterns is the primary analysis method." discovery: goal: "Identify all code locations exposed to at least one of the 6 concurrency anomalies; classify each finding by anomaly type and severity; produce actionable fix recommendations" tasks: - "Determine the database in use and its default isolation level" - "Grep for transaction boundaries and SQL patterns that indicate anomaly exposure" - "Classify each finding into one of the 6 anomaly types" - "Assign severity based on the anomaly type and its business impact" - "Produce fix recommendations — isolation upgrade or application-level mitigation — per finding" audience: roles: ["backend-engineer", "software-architect", "data-engineer", "tech-lead", "site-reliability-engineer"] experience: "intermediate-to-advanced — assumes familiarity with relational databases and SQL transactions" triggers: - "A data corruption or inconsistency bug is suspected but hard to reproduce" - "Code review for a new service or feature that uses database transactions" - "Migration to a database with a different default isolation level" - "Audit of existing codebase for concurrency safety" - "Post-incident analysis of a race condition" not_for: - "Choosing an isolation level from scratch without existing code — use transaction-isolation-selector instead" - "Distributed transaction coordination across multiple databases — use distributed-failure-analyzer" - "Replication-level consistency issues — use replication-strategy-selector" --- # Concurrency Anomaly Detector ## When to Use You have existing application code that interacts with a database and you need to know whether it is safe under concurrent execution. This skill applies when: - A bug only manifests under concurrent load and is hard to reproduce in tests - Code is being reviewed before deployment to a high-concurrency environment - The application recently migrated to a database with a different default isolation level - The codebase accesses multiple tables in a single transaction - Any code follows the pattern: read a value, make a decision, write a result **The core insight from Kleppmann:** Concurrency bugs caused by weak transaction isolation are not just theoretical. They cause real financial losses and data corruption. They are triggered only by unfortunate timing, making them nearly impossible to catch by testing. The only reliable approach is to analyze the code structure — not the test results — and identify which patterns are structurally vulnerable. **Companion skill:** `transaction-isolation-selector` — once anomalies are identified, that skill selects the minimum safe isolation level. This skill identifies what anomalies exist in current code; the companion skill recommends what to do about them. --- ## Context and Input Gathering ### Required Context (must have — ask if missing) - **Database in use and version.** Why: the same isolation level name has different behaviors across databases. MySQL's "repeatable read" does not automatically detect lost updates; PostgreSQL's does. Oracle's "serializable" is actually snapshot isolation. Without knowing the database, severity assessments cannot be calibrated to actual risk. - Check: `docker-compose.yml`, `requirements.txt` / `pom.xml` / `go.mod` / `package.json` for database drivers, schema file syntax - If missing, ask: "What database are you using, and what version?" - **Current isolation level.** Why: the same code pattern has different severity depending on the isolation level in effect. A lost update pattern is high severity at read committed but handled automatically at PostgreSQL's repeatable read. If the isolation level is unknown, assume the database default (usually read committed). - Check: ORM configuration, database session setup, application config files, environment variables - If missing, assume the database's default and note the assumption explicitly - **Application code or transaction descriptions.** Why: this is the primary input. The scan requires reading transaction logic to identify patterns. - Gather: entry points for significant transactions, files containing SQL or ORM calls, service layer code ### Observable Context (gather from environment) Before asking anything, scan the environment: ``` Grep targets: - Transaction boundaries: BEGIN, START TRANSACTION, @Transactional, with_transaction, session.begin(), db.transaction(), conn.cursor() - Read-modify-write: SELECT ... followed by UPDATE in same function scope - Check-then-act: SELECT COUNT(*), SELECT SUM(), SELECT EXISTS() followed by INSERT/UPDATE - Explicit locking: FOR UPDATE, FOR SHARE, LOCK TABLE, SELECT ... WITH (UPDLOCK) - Isolation settings: ISOLATION LEVEL, transaction_isolation, SET SESSION - ORM patterns: find_by, where().first(), session.query(), Model.where() followed by .save(), .update(), .create() in the same scope ``` ### Default Assumptions When context cannot be observed and asking would be excessive: - Isolation level unknown → assume read committed (PostgreSQL/Oracle/SQL Server default) - Transaction boundaries unknown → look for natural HTTP request/response boundaries - Concurrency level unknown → assume multiple concurrent users access the same data (conservative) --- ## Process ### Step 1: Identify the Database and Its Actual Isolation Guarantees **ACTION:** Determine the database, its default isolation level, and any overrides in the codebase. **WHY:** Every subsequent severity assessment depends on what the database actually prevents at its current isolation level. "We use PostgreSQL so we're fine" is a common and dangerous assumption. PostgreSQL's default is read committed — it does not prevent read skew, lost updates, write skew, or phantom reads. Establishing the ground truth of what the database currently prevents is the prerequisite for everything else. Record: ``` Database: [PostgreSQL | MySQL InnoDB | Oracle | SQL Server | other] Default isolation level: [read committed | repeatable read | serializable] Configured isolation: [from code scan or config — override if found] Effective isolation: [the level actually in use] ``` **Isolation level defaults (critical to get right):** | Database | Default | What it prevents | What it allows | |----------|---------|-----------------|----------------| | PostgreSQL | Read committed | Dirty reads, dirty writes | Read skew, lost updates, write skew, phantoms | | MySQL InnoDB | Repeatable read | Dirty reads, dirty writes, read skew | Lost updates (silently!), write skew, phantoms | | Oracle 11g | Read committed | Dirty reads, dirty writes | Everything else. Oracle "SERIALIZABLE" = snapshot isolation — write skew still possible | | SQL Server | Read committed | Dirty reads, dirty writes | Read skew, lost updates, write skew, phantoms | --- ### Step 2: Grep for Anomaly-Indicating Code Patterns **ACTION:** Search the codebase systematically for the structural patterns that expose each of the 6 anomaly types. **WHY:** Concurrency anomalies cannot be found by running the code — the bugs only manifest when multiple transactions interleave at precisely the wrong time. What can be found reliably is the code structure that makes the anomaly possible. Each anomaly type has a distinct structural fingerprint. Grepping for these patterns is more reliable than reading every file manually and is the same approach a static analysis tool would take. **Pattern catalog — what to grep for and what it indicates:** **Dirty reads and dirty writes — Pattern: missing transaction boundary or read uncommitted** ``` Signal: Explicit SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED, or autocommit enabled across multi-step write operations with no BEGIN/COMMIT wrapper. Grep: READ UNCOMMITTED, autocommit=1 (MySQL) with multi-step writes Note: Read committed and above prevent dirty reads automatically at all practical databases. Flag only if read uncommitted is explicitly configured. ``` **Read skew — Pattern: multi-read transaction without snapshot isolation** ``` Signal: Multiple SELECTs in the same transaction over related tables where combined results must be consistent (balance totals, integrity checks, backup scans). Grep: Multiple SELECT statements in the same transaction scope accessing related tables (e.g., accounts + transfers, orders + order_items) Risk: At read committed, each SELECT reads a fresh snapshot. Concurrent commits between reads produce internally inconsistent results. ``` **Lost updates — Pattern: read-modify-write cycle** ``` Signal: A transaction reads a value, computes a new value in application code, then writes the new value back. Grep: SELECT followed by UPDATE in the same transaction scope where the UPDATE value depends on the SELECT result (look for the variable being used in both) ORM: find() / find_by() followed by .update() or .save() Python: result = session.query(...).one() ... result.field += delta ... session.commit() Java: Entity e = em.find(...) ... e.setCount(e.getCount() + 1) ... em.merge(e) Risk: Two concurrent read-modify-write cycles both read the old value, both compute an update, and the second write overwrites the first without incorporating it. At read committed: always vulnerable. At PostgreSQL/Oracle snapshot isolation: automatically detected and aborted. At MySQL InnoDB repeatable read: NOT detected — second write silently overwrites first. Severity: HIGH at read committed or MySQL repeatable read. MEDIUM at PostgreSQL/Oracle snapshot isolation (aborts, requires retry). NOT PRESENT at serializable. ``` **Write skew — Pattern: check-then-act (the most commonly missed anomaly)** ``` Signal: A transaction reads an aggregate or existence condition, makes a decision based on the result, then writes to the database. The write changes the state that the condition was checking. Grep: SELECT COUNT(*) / SELECT SUM() / SELECT EXISTS() / SELECT MAX() followed by INSERT, UPDATE, or DELETE in the same transaction scope. Also: any SELECT that is used as a guard condition ("if the query returns X, proceed with the write") ORM: Model.where(...).count > 0 followed by Model.create() or model.update() Model.where(...).exists? followed by record.save() Risk: Two concurrent transactions both pass the guard check because each reads from its own snapshot. Both write. The combined state violates the invariant the guard was enforcing. Critical: Write skew is NOT prevented by snapshot isolation. Oracle's "serializable" is snapshot isolation. If the database is at any level below true serializable, ALL check-then-act patterns are vulnerable. Severity: CRITICAL at read committed, snapshot isolation, or Oracle "serializable". NOT PRESENT at true serializable (PostgreSQL SERIALIZABLE, MySQL SERIALIZABLE). ``` **Phantom reads — Pattern: check-for-absence then insert** ``` Signal: SELECT COUNT(*) = 0 or empty-result check, followed by INSERT into the same table. Booking conflict checks, username existence checks. Grep: COUNT.*= 0 / .count() == 0 followed by INSERT or .create() in same scope Risk: Both transactions see zero matching rows. Both insert. Constraint violated. FOR UPDATE does not help — no rows exist to lock when SELECT returns empty. Severity: CRITICAL at any level below serializable (including snapshot isolation). Note: Phantom variant of write skew. Fix: serializable isolation or materializing conflicts. UNIQUE constraint sufficient only for single-column uniqueness. ``` --- ### Step 3: Classify Each Finding **ACTION:** For each code location identified in Step 2, produce a structured finding entry. **WHY:** Unclassified findings — "there might be a race condition somewhere" — do not produce action. A structured finding with anomaly type, trigger conditions, severity, and a concrete fix recommendation is actionable. The classification also determines severity precisely: the same read-modify-write pattern is critical at read committed but harmless at serializable. **Finding structure:** ``` Finding #N File: [path/to/file.py, line N] Anomaly type: [dirty read | dirty write | read skew | lost update | write skew | phantom] Code pattern: [brief description of what the code does] Trigger: [concurrency condition required to produce the anomaly] Severity: [CRITICAL | HIGH | MEDIUM | LOW] (see severity table below) Affected data: [which tables/entities are involved] Fix: [isolation upgrade | SELECT FOR UPDATE | atomic operation | unique constraint | materializing conflicts] Fix detail: [specific change to make] ``` **Severity classification:** | Anomaly | Severity | Rationale | |---------|----------|-----------| | Dirty read | HIGH | Reads data that may never have existed (rolled-back write). Direct data integrity violation. | | Dirty write | HIGH | Mixes writes from concurrent transactions into a single object. Produces corrupted state. | | Read skew | MEDIUM | Transaction sees internally inconsistent state. Dangerous for analytics, backups, multi-step reads. | | Lost update | HIGH | Silent data loss — one write disappears without error. Counter increments, balance updates. | | Write skew | CRITICAL | Invariant violation that no weaker isolation level prevents. Often produces constraint violations in business logic (zero doctors on call, double-booked rooms, negative balances). | | Phantom (write skew variant) | CRITICAL | Same as write skew but additionally cannot be mitigated by SELECT FOR UPDATE — only serializable isolation or materializing conflicts works. | **Downgrade severity if:** - The anomaly is prevented by the effective isolation level (e.g., lost update at PostgreSQL repeatable read → NOT PRESENT) - The code path is read-only and cannot cause the write side of the pattern - The affected data has a compensating unique constraint in the schema --- ### Step 4: Produce the Findings Report **ACTION:** Write a structured anomaly findings report with all classified findings, a summary table, and prioritized fix recommendations. **WHY:** The findings report is the deliverable. It must be readable by an engineer without re-reading this skill, actionable as a review ticket or backlog item, and precise enough to be used as evidence in an incident post-mortem. The summary table gives a quick severity overview; the detailed findings give the information needed to implement a fix. **Output format:** ```markdown # Concurrency Anomaly Scan — [Project Name] ## Scan Context Database: [database + version] Effective isolation: [actual isolation level in use] Files scanned: [count or list] Findings: [N total — X CRITICAL, Y HIGH, Z MEDIUM, W LOW] --- ## Summary Table | # | File | Anomaly Type | Severity | Fix Type | |---|------|-------------|----------|----------| | 1 | payments/transfer.py:47 | Write skew | CRITICAL | Upgrade to serializable | | 2 | scheduling/shift.py:112 | Write skew | CRITICAL | SELECT FOR UPDATE or serializable | | 3 | accounts/balance.py:33 | Lost update | HIGH | Atomic operation or serializable | ... --- ## Findings ### Finding #1 — [Anomaly Type] — [Severity] **File:** [path:line] **Pattern:** [what the code does] **Trigger:** [concurrency scenario that produces the anomaly] **Affected data:** [tables/entities] **Code excerpt:** [relevant code snippet] **Fix:** [fix type] [specific change description] --- [repeat for each finding] --- ## Recommendations ### Immediate (CRITICAL findings) [List of changes required before the next production deployment] ### Short-term (HIGH findings) [List of changes to address in the current sprint] ### For review (MEDIUM findings) [List of changes to assess — may depend on workload characteristics] ### Related skills - `transaction-isolation-selector` — select the minimum safe isolation level for this codebase - `replication-failure-analyzer` — if findings include distributed transaction concerns ``` --- ## The 6 Anomalies — Quick Reference | Anomaly | What happens | Code signal | Minimum fix | |---------|-------------|-------------|-------------| | **Dirty read** | Transaction reads uncommitted data that later rolls back — decision based on data that never existed | Read uncommitted isolation explicitly set | Enable read committed (database default) | | **Dirty write** | Two uncommitted writes to the same object mix results — listing shows one buyer, invoice shows another | Multi-step writes with no transaction boundary; autocommit enabled | Wrap in transaction (read committed prevents) | | **Read skew** | Long-running read sees two tables at different points in time — Alice's $1000 appears as $900 mid-transfer | Multiple SELECTs over related tables in one transaction | Upgrade to snapshot isolation (REPEATABLE READ) | | **Lost update** | Read-modify-write cycle: both transactions read 42, both write 43, result is 43 not 44 — one update silently lost | SELECT followed by UPDATE where new value computed in app code | Atomic SQL (`value = value + 1`) or SELECT FOR UPDATE | | **Write skew** | Check-then-act: two transactions both pass a guard check (count >= 2), both write to different rows, combined result violates the invariant (count = 0). The 5 forms: at-least-one, no-overlap, unique claim, budget, game state | SELECT COUNT/SUM/EXISTS followed by INSERT/UPDATE/DELETE in same transaction | SELECT FOR UPDATE (rows exist) or serializable isolation | | **Phantom (write skew)** | Check-for-absence then insert: both see zero conflicts, both insert — double-booking, duplicate username | COUNT = 0 check followed by INSERT matching same condition | Serializable isolation; UNIQUE constraint for single-column uniqueness; materializing conflicts as last resort | Full detail with SQL and ORM patterns per language is in `references/anomaly-detection-patterns.md`. --- ## What Can Go Wrong **The most dangerous gap:** Write skew is the anomaly teams most commonly miss. It looks like safe code — each transaction individually is correct. The invariant violation only appears in the combined outcome of two concurrent transactions. At any isolation level below true serializable (including PostgreSQL's snapshot isolation, Oracle's "serializable"), write skew is possible. **The naming trap:** Oracle's `SERIALIZABLE` is snapshot isolation. Teams that set Oracle isolation to SERIALIZABLE and assume they have full protection against write skew do not. MySQL's `REPEATABLE READ` does not detect lost updates. These naming mismatches have caused real production data corruption. **Testing cannot catch these:** Concurrency anomalies only manifest when two transactions interleave at precisely the wrong time. This is nondeterministic and depends on load. Unit tests run single-threaded. Load tests may not hit the exact timing window. The only reliable method is structural code analysis — which is what this skill does. --- ## Examples ### Example 1: E-Commerce Inventory Deduction (Lost Update) **Scenario:** A Python Flask service handles concurrent purchase requests. When an order is placed, the code reads inventory, checks if stock > 0, deducts the quantity, and saves. **Trigger:** "We occasionally oversell products — orders go through for items that are actually out of stock. It only happens during flash sales." **Process:** Step 1: Database is PostgreSQL, default isolation is read committed. No isolation override found in the codebase. Step 2: Grep finds this pattern in `orders/inventory.py`: ```python # orders/inventory.py:34 with db.session.begin(): item = db.session.query(InventoryItem).filter_by(sku=sku).one() if item.quantity < requested_qty: raise InsufficientStock() item.quantity -= requested_qty db.session.commit() ``` This is a read-modify-write cycle: read `item.quantity`, compute `item.quantity - requested_qty` in application code, write back. Step 3 classification: ``` Finding #1 File: orders/inventory.py:34 Anomaly type: Lost update Code pattern: Read quantity → check > 0 → deduct in application code → save Trigger: Two concurrent purchase requests for the same SKU both read quantity=5, both check 5 >= 2 (requested), both compute 5-2=3, both write 3. Result: quantity=3 instead of quantity=1. One purchase's deduction is lost. Severity: HIGH Affected data: InventoryItem.quantity Fix: Atomic SQL operation (no read-modify-write cycle needed) Fix detail: Replace with: UPDATE inventory_items SET quantity = quantity - :requested_qty WHERE sku = :sku AND quantity >= :requested_qty Check rows_affected == 1 to detect insufficient stock. This removes the application-layer read-modify-write cycle entirely. ``` **Output excerpt:** ```markdown # Concurrency Anomaly Scan — E-Commerce Service Database: PostgreSQL 14 Effective isolation: Read committed (default, no override found) Files scanned: 47 Findings: 1 total — 0 CRITICAL, 1 HIGH, 0 MEDIUM, 0 LOW ## Summary Table | # | File | Anomaly Type | Severity | Fix Type | |---|------|-------------|----------|----------| | 1 | orders/inventory.py:34 | Lost update | HIGH | Atomic SQL operation | ## Finding #1 — Lost Update — HIGH ... Fix: Replace Python read-modify-write with atomic SQL UPDATE with WHERE guard. The atomic UPDATE eliminates the window for concurrent reads of the same value. No isolation level change required for this fix. ``` --- ### Example 2: Scheduling Service (Write Skew — Classic Pattern) **Scenario:** A Java Spring Boot service manages staff shift assignments. Staff can voluntarily release a shift if at least two others are assigned to cover it. **Trigger:** "A shift ended up with zero assigned staff after two people simultaneously clicked 'release' on the app. Both got a confirmation that their release was accepted." **Process:** Step 1: Database is MySQL InnoDB, isolation is repeatable read (default). No override configured. Step 2: Grep finds this pattern in `ShiftService.java`: ```java // ShiftService.java:87 @Transactional public void releaseShift(long staffId, long shiftId) { long coverCount = shiftRepository.countAssigned(shiftId); // SELECT COUNT(*) if (coverCount < 2) { throw new InsufficientCoverageException(); } staffShiftRepository.markReleased(staffId, shiftId); // UPDATE staff_shifts } ``` Step 3 classification: ``` Finding #1 File: ShiftService.java:87 Anomaly type: Write skew Code pattern: COUNT assigned staff → if >= 2 → mark self as released Trigger: Alice and Bob are both assigned, both click Release simultaneously. Both transactions read COUNT = 2 (their own snapshot). Both pass the check. Alice's UPDATE sets her record to released. Bob's UPDATE sets his record to released. Result: COUNT = 0. Zero staff on shift. Severity: CRITICAL Affected data: staff_shifts table Fix: SELECT FOR UPDATE on the precondition query, or upgrade to SERIALIZABLE Fix detail: Option A — SELECT FOR UPDATE: Replace countAssigned() with a locking query: SELECT COUNT(*) FROM staff_shifts WHERE shift_id = ? AND status = 'assigned' FOR UPDATE This locks all assigned rows for the shift. Bob's transaction must wait for Alice's to commit. After Alice commits (count becomes 1), Bob reads count = 1 and throws InsufficientCoverageException. Correct behavior. Option B — Serializable isolation: Add @Transactional(isolation = Isolation.SERIALIZABLE) to the method. MySQL SERIALIZABLE uses two-phase locking — true serializable. Note: adds lock contention overhead on the staff_shifts table. ``` --- ### Example 3: Multi-Tenant SaaS Subscription Billing (Read Skew + Write Skew) **Scenario:** A Node.js billing service runs a monthly billing job. The job reads each account's subscription plan and usage, computes the invoice total, and inserts an invoice record. The job runs while normal usage continues. **Trigger:** "Our monthly billing report shows totals that don't match usage records. Some invoices have usage figures that don't correspond to any committed state we can find." **Process:** Step 1: Database is PostgreSQL, isolation is read committed (default). The billing job uses a long-running transaction. Step 2: Grep finds two issues: Issue A — `billing/job.js:23`: The billing job reads usage in a loop over accounts. Each loop iteration issues a new SELECT. Between iterations, usage records for already-billed accounts may be updated by concurrent write transactions. The job reads accounts at different points in time. Issue B — `billing/proration.js:67`: A proration credit calculation reads the current plan price and the days remaining, computes a credit, then inserts a credit record. A concurrent plan upgrade transaction can commit between the two reads, leaving the proration based on the old plan price while the new plan is active. Step 3 classification: ``` Finding #1 File: billing/job.js:23 Anomaly type: Read skew Code pattern: Long-running job reads multiple tables across loop iterations with separate SELECT queries per account — no consistent snapshot Trigger: Concurrent write transactions commit usage updates between account reads. Job sees Account A's usage before the update and Account B's usage after. Severity: MEDIUM Affected data: usage_records, subscriptions, accounts tables Fix: Set transaction isolation to REPEATABLE READ or SERIALIZABLE for the billing job transaction. PostgreSQL REPEATABLE READ = snapshot isolation. All reads within the transaction see the database at the transaction's start time. Concurrent writes are invisible until the transaction commits. Finding #2 File: billing/proration.js:67 Anomaly type: Write skew Code pattern: Read current plan price → read days remaining → compute credit → insert credit record. Write changes the precondition (plan price). Trigger: Plan upgrade commits between the two reads. Credit is computed at old plan price but inserted while new plan is active. Severity: CRITICAL Fix: SELECT plan_price FROM subscriptions WHERE id = ? FOR UPDATE before reading days remaining. This locks the subscription row for the duration of the transaction. A concurrent plan upgrade must wait. ``` --- ## References | File | Contents | When to read | |------|----------|--------------| | `references/anomaly-detection-patterns.md` | SQL and ORM grep patterns for all 6 anomaly types; per-language examples (Python/SQLAlchemy, Java/Hibernate, Go/sqlx, Ruby/ActiveRecord, Node.js/Sequelize); false positive filters | Step 2 — systematic grep sweep | | `references/severity-and-fix-matrix.md` | Full severity table per anomaly type per isolation level; fix decision tree (isolation upgrade vs FOR UPDATE vs atomic op vs unique constraint vs materializing conflicts); fix applicability conditions | Step 3 — classifying each finding and selecting a fix | **Cross-reference:** `transaction-isolation-selector` — use after this skill produces its findings report. That skill takes the anomaly exposure as input and produces the minimum safe isolation level recommendation with database-specific configuration. ## License This skill is licensed under [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/). Source: [BookForge](https://github.com/bookforge-ai/bookforge-skills) — Designing Data-Intensive Applications by Martin Kleppmann. ## Related BookForge Skills Install related skills from ClawhHub: - `clawhub install bookforge-transaction-isolation-selector` Or install the full book set from GitHub: [bookforge-skills](https://github.com/bookforge-ai/bookforge-skills)

path: references/anomaly-detection-patterns.md
content: # Anomaly Detection Patterns

Reference for Step 2 of the concurrency-anomaly-detector process. SQL and ORM grep patterns for all 6 concurrency anomaly types, with per-language examples and false positive filters.

---

## How to Use This Reference

Run the grep patterns below in the codebase to find candidate code locations. Each pattern produces a list of files and line numbers. For each hit, apply the false positive filter to determine whether it is an actual exposure. Then classify into a finding using Step 3 of the skill.

---

## Pattern 1: Lost Update Detection

### What to find
A transaction that reads a value, computes a new value in application code, and writes it back. The key marker is application-layer arithmetic between read and write — the new value depends on the old value.

### SQL grep patterns
```
SELECT.*FROM.*\n.*UPDATE
UPDATE.*SET.*=.*\+
UPDATE.*SET.*=.*-
SELECT.*FOR UPDATE.*\n.*UPDATE    ← explicit lock present; lower risk but verify
```

### ORM grep patterns (Python/SQLAlchemy)
```python
# High risk: find then modify in loop or immediate scope
session.query(Model).filter(...).one()
session.query(Model).filter(...).first()
# Followed by: obj.field += or obj.field -= or obj.field = obj.field +
```

### ORM grep patterns (Java/Hibernate)
```java
em.find(Entity.class, id)
repository.findById(id)
// Followed by: entity.setField(entity.getField() + delta)
//              or entity.setCount(entity.getCount() + 1)
```

### ORM grep patterns (Ruby/ActiveRecord)
```ruby
record = Model.find(id)
record = Model.find_by(...)
# Followed by: record.field += delta, record.save
# Also: Model.increment(:column, ...)  ← atomic — NOT a lost update
```

### ORM grep patterns (Go/sqlx or GORM)
```go
db.First(&entity, id)
db.Where(...).First(&entity)
// Followed by: entity.Field = entity.Field + delta
//              db.Save(&entity)
```

### ORM grep patterns (Node.js/Sequelize)
```javascript
await Model.findOne({ where: ... })
await Model.findByPk(id)
// Followed by: instance.field += delta, instance.save()
```

### False positives (NOT lost updates)
```sql
-- Pure SQL atomic expression: no application-layer read needed
UPDATE counters SET value = value + 1 WHERE key = 'x';
UPDATE accounts SET balance = balance - :amount WHERE id = :id AND balance >= :amount;
```
```python
# SQLAlchemy atomic update: no Python-layer arithmetic
session.execute(update(Counter).where(...).values(value=Counter.value + 1))
```
```ruby
# ActiveRecord atomic: no Ruby-layer arithmetic
Account.where(id: id).update_all("balance = balance - #{amount}")
Model.increment_counter(:count, id)
```

These are safe — the arithmetic happens inside the database atomically, with no window for a concurrent read to see the old value.

---

## Pattern 2: Write Skew Detection

### What to find
A transaction that reads an aggregate or existence condition (COUNT, SUM, EXISTS, MAX, MIN), makes an application-level decision based on the result, then performs a write that changes the state of the condition.

This is the single most commonly missed pattern. It looks like safe code — each transaction is individually correct. The race condition only appears when two transactions run simultaneously.

### SQL grep patterns
```sql
SELECT COUNT(*)                -- check count, then write based on result
SELECT SUM(                    -- check aggregate, then insert/update
SELECT EXISTS(                 -- check existence, then write
SELECT MAX(                    -- check bound, then write
```
All followed (in the same transaction scope) by INSERT, UPDATE, or DELETE.

### ORM grep patterns
```python
# Python/SQLAlchemy
session.query(Model).filter(...).count()      # check count
session.query(func.sum(Model.field)).scalar() # check sum
session.query(Model).filter(...).first()      # check existence
# All followed by session.add(...) or session.execute(update(...))
```

```java
// Java/Hibernate
repository.countBy...()
repository.existsBy...()
entityManager.createQuery("SELECT SUM(...)").getSingleResult()
// Followed by: entityManager.persist(new Entity(...)) or repository.save(new Entity(...))
```

```ruby
# Ruby/ActiveRecord
Model.where(...).count
Model.where(...).exists?
Model.where(...).sum(:field)
# Followed by: Model.create(...) or record.update(...)
```

```javascript
// Node.js/Sequelize
await Model.count({ where: ... })
await Model.sum('field', { where: ... })
await Model.findOne({ where: ... })  // guard for uniqueness
// Followed by: await Model.create(...)
```

### The 5 write skew patterns — specific code signatures

**At-least-one constraint (doctor on-call):**
```sql
SELECT COUNT(*) FROM resources WHERE status = 'active' AND group_id = $1;
-- app: if count >= 2, proceed
UPDATE resources SET status = 'inactive' WHERE id = $2;
```
Signal: COUNT check with threshold, followed by UPDATE on a different row than was counted.

**No-overlap constraint (booking system):**
```sql
SELECT COUNT(*) FROM bookings
  WHERE resource_id = $1 AND end_time > $2 AND start_time < $3;
-- app: if count = 0, proceed
INSERT INTO bookings (resource_id, start_time, end_time, user_id)
  VALUES ($1, $2, $3, $4);
```
Signal: Time-range overlap check followed by INSERT into same table.

**Unique claim (username registration):**
```sql
SELECT COUNT(*) FROM users WHERE username = $1;
-- app: if count = 0, proceed
INSERT INTO users (username, ...) VALUES ($1, ...);
```
Signal: Existence check followed by INSERT with the checked value.
Note: A UNIQUE constraint on `username` is sufficient here — no serializable isolation needed.

**Budget / sum constraint (spending limit):**
```sql
SELECT SUM(amount) FROM spending WHERE account_id = $1;
-- app: if sum + new_amount <= limit, proceed
INSERT INTO spending (account_id, amount, ...) VALUES ($1, $2, ...);
```
Signal: SUM aggregate check followed by INSERT that would increase the sum.

**Game state validity:**
```sql
SELECT position FROM game_pieces WHERE piece_id = $1 AND game_id = $2;
-- app: validate move legality based on position
UPDATE game_pieces SET position = $3 WHERE piece_id = $1;
```
Signal: State read for validity check, followed by UPDATE that changes other game state.

### False positives (NOT write skew)
- Read-only queries with no subsequent write in the same transaction
- Writes that do not change the condition checked by the SELECT (e.g., SELECT from table A, write to unrelated table B with no logical dependency)
- Cases where a UNIQUE database constraint catches the conflict at the insert level (duplicate username pattern)

---

## Pattern 3: Phantom Read Detection

### What to find
A transaction checks for zero matching rows, then inserts a row that matches the same condition. This is the phantom variant of write skew. Unlike write skew over existing rows, `SELECT FOR UPDATE` cannot help — there are no rows to lock when the SELECT returns empty.

### Key distinguishing marker
The SELECT returns zero rows (or checks for absence), and the INSERT creates a row that would match the SELECT's WHERE condition.

### SQL grep patterns
```sql
-- Absence check followed by insert
SELECT COUNT(*) FROM table WHERE condition = $1  -- returning 0
INSERT INTO table WHERE ...                       -- values match condition

-- Or: SELECT returns empty, INSERT follows
SELECT id FROM table WHERE unique_key = $1;
-- if no rows: INSERT INTO table (unique_key, ...) VALUES ($1, ...);
```

### ORM grep patterns
```python
# Booking conflict check
existing = session.query(Booking).filter(
    Booking.room_id == room_id,
    Booking.end_time > start_time,
    Booking.start_time < end_time
).count()
if existing == 0:
    session.add(Booking(room_id=room_id, ...))
```

```java
// Room booking
long conflicts = bookingRepo.countConflicting(roomId, startTime, endTime);
if (conflicts == 0) {
    bookingRepo.save(new Booking(roomId, startTime, endTime, userId));
}
```

### Differentiation from general write skew
Write skew over existing rows: SELECT returns rows → FOR UPDATE possible as mitigation
Phantom write skew: SELECT returns no rows → FOR UPDATE does nothing → serializable required

---

## Pattern 4: Read Skew Detection

### What to find
A long-running transaction that reads from multiple tables or reads the same data multiple times, where the combined result must be internally consistent.

### Code signals
- Long-running batch or background jobs inside a transaction
- Multiple SELECT queries over related tables in one transaction scope
- Backup or export operations that read the entire database or large subsets
- Integrity check queries that join or compare multiple tables

### SQL grep patterns
```sql
-- Two or more SELECTs from related tables in the same transaction
BEGIN;
SELECT ... FROM table_a WHERE ...;
-- other processing
SELECT ... FROM table_b WHERE ...;  -- related to table_a result
COMMIT;
```

### ORM grep patterns (Python)
```python
with db.session.begin():
    accounts = session.query(Account).filter(...).all()
    # ... some processing ...
    transfers = session.query(Transfer).filter(...).all()
    # If accounts and transfers must balance, this is read skew exposure
```

### False positives
- Single-table reads (no cross-table consistency requirement)
- Read committed isolation is acceptable when the query is idempotent (re-running gives a useful result even if slightly stale)
- Short transactions where the window for a concurrent write to commit between reads is negligible

---

## Pattern 5: Dirty Read and Dirty Write Detection

### Dirty reads
Only possible at isolation level `READ UNCOMMITTED`. Rare in production — almost no database defaults to this level.

### SQL grep patterns
```sql
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
isolation_level='READ UNCOMMITTED'
```

### Dirty writes
Prevented by all practical isolation levels (read committed and above) through row-level write locks. Flag only if:
- Autocommit is enabled per-statement and transactions are not used
- The application manually manages locking and a lock is missing

### SQL grep patterns for missing transaction boundaries
```sql
-- INSERTs, UPDATEs, DELETEs outside any transaction context
-- Autocommit enabled with no explicit transaction wrapping multi-step operations
SET autocommit = 1   -- MySQL
-- Followed by multi-step write operations with no BEGIN/COMMIT
```

---

## Per-Database Notes on What Is and Is Not Automatically Prevented

### PostgreSQL

| Anomaly | At READ COMMITTED (default) | At REPEATABLE READ | At SERIALIZABLE |
|---------|--------------------------|---------------------|-----------------|
| Dirty read | Prevented | Prevented | Prevented |
| Dirty write | Prevented | Prevented | Prevented |
| Read skew | **EXPOSED** | Prevented | Prevented |
| Lost update | **EXPOSED** | Auto-detected, aborts | Prevented |
| Write skew | **EXPOSED** | **EXPOSED** | Prevented |
| Phantom (write skew) | **EXPOSED** | **EXPOSED** | Prevented |

**PostgreSQL SERIALIZABLE uses SSI (serializable snapshot isolation)** — true serializable with optimistic concurrency. Aborts transactions rather than blocking. Application must implement retry on SQLSTATE 40001.

### MySQL InnoDB

| Anomaly | At READ COMMITTED | At REPEATABLE READ (default) | At SERIALIZABLE |
|---------|------------------|------------------------------|-----------------|
| Dirty read | Prevented | Prevented | Prevented |
| Dirty write | Prevented | Prevented | Prevented |
| Read skew | **EXPOSED** | Prevented | Prevented |
| Lost update | **EXPOSED** | **EXPOSED (NOT auto-detected!)** | Prevented |
| Write skew | **EXPOSED** | **EXPOSED** | Prevented |
| Phantom (write skew) | **EXPOSED** | **EXPOSED** | Prevented |

**Critical MySQL difference:** MySQL InnoDB at REPEATABLE READ does NOT automatically detect lost updates. PostgreSQL does. Two concurrent read-modify-write cycles will silently lose one update in MySQL. PostgreSQL will abort one and require a retry.

**MySQL SERIALIZABLE uses two-phase locking (2PL)** — pessimistic. Readers block writers; writers block readers. No abort/retry needed but lock contention higher.

### Oracle 11g

| Anomaly | At READ COMMITTED (default) | At "SERIALIZABLE" (= snapshot isolation) |
|---------|-----------------------------|------------------------------------------|
| Dirty read | Prevented | Prevented |
| Dirty write | Prevented | Prevented |
| Read skew | **EXPOSED** | Prevented |
| Lost update | **EXPOSED** | Auto-detected |
| Write skew | **EXPOSED** | **EXPOSED** |
| Phantom (write skew) | **EXPOSED** | **EXPOSED** |

**Oracle's SERIALIZABLE is snapshot isolation, not true serializable.** Write skew is still possible. There is no true serializable isolation available in Oracle 11g.

---

## Quick Grep Commands

### Find read-modify-write cycles (lost update candidates)
```bash
# Python: find() followed by attribute modification
grep -n "session.query\|db.session.get\|Model.get\|Model.find" -A 5 \
  $(find . -name "*.py") | grep -B 3 "+="

# Java: find followed by setter
grep -rn "findById\|em.find\|repository.find" --include="*.java" -A 5 | \
  grep -B 3 "\.set[A-Z]"
```

### Find check-then-act write skew candidates
```bash
# SQL files: COUNT or SUM followed by INSERT/UPDATE in same file
grep -n "SELECT COUNT\|SELECT SUM\|SELECT EXISTS" **/*.sql

# Python: count() or exists() in transaction context
grep -rn "\.count()\|\.exists()" --include="*.py" -B 2 -A 5 | \
  grep -B 5 "session.add\|\.save()\|\.create("

# Java: countBy or existsBy followed by save/persist
grep -rn "countBy\|existsBy\|\.count()\|\.exists()" --include="*.java" -A 10 | \
  grep -B 8 "\.save\|\.persist\|\.create"
```

### Find phantom-variant candidates (absence check + insert)
```bash
# SQL: COUNT = 0 check followed by INSERT
grep -n "COUNT.*= 0\|count.*== 0\|count.*=== 0" **/*.{py,java,go,js,rb} -A 5 | \
  grep -B 3 "INSERT\|\.create\|\.add\|\.save"
```

### Find isolation level configuration
```bash
grep -rn "ISOLATION LEVEL\|isolation_level\|transaction_isolation\|@Transactional" \
  --include="*.{py,java,go,js,rb,yml,yaml,properties,xml}"
```


path: references/severity-and-fix-matrix.md
content: # Severity and Fix Matrix

Reference for Step 3 of the concurrency-anomaly-detector process. Severity per anomaly type per isolation level, fix decision tree, and fix applicability conditions.

---

## Severity by Anomaly and Effective Isolation Level

A finding's severity depends on both the anomaly type and the isolation level currently in effect. The same code pattern that is CRITICAL at read committed may be NOT PRESENT at serializable.

| Anomaly | Read Uncommitted | Read Committed | Snapshot Isolation | Serializable |
|---------|:----------------:|:--------------:|:-----------------:|:------------:|
| Dirty read | HIGH | NOT PRESENT | NOT PRESENT | NOT PRESENT |
| Dirty write | HIGH | NOT PRESENT | NOT PRESENT | NOT PRESENT |
| Read skew | MEDIUM | MEDIUM | NOT PRESENT | NOT PRESENT |
| Lost update (PostgreSQL/Oracle SI) | HIGH | HIGH | MEDIUM* | NOT PRESENT |
| Lost update (MySQL InnoDB RR) | HIGH | HIGH | HIGH** | NOT PRESENT |
| Write skew | CRITICAL | CRITICAL | CRITICAL | NOT PRESENT |
| Phantom (write skew variant) | CRITICAL | CRITICAL | CRITICAL | NOT PRESENT |

*PostgreSQL and Oracle automatically detect and abort one of the conflicting transactions at snapshot isolation. The anomaly does not silently corrupt data, but the application must handle the abort and retry. Severity is MEDIUM because it requires application-level retry handling that is often missing.

**MySQL InnoDB repeatable read does NOT automatically detect lost updates. Two concurrent read-modify-write cycles silently lose one update. This is HIGH severity, not MEDIUM — the data corruption is silent.

---

## Severity Definitions

| Severity | Meaning | Action required |
|----------|---------|-----------------|
| CRITICAL | Invariant violation that cannot be prevented without serializable isolation (or specific mitigation). Business logic constraints can be violated silently with no database error. Examples: double-booking, zero staff on shift, negative balance. | Fix before next production deployment. |
| HIGH | Silent data corruption or reads of data that never existed. Direct integrity violation. Examples: lost counter increment, overwritten balance update. | Fix in current sprint. |
| MEDIUM | Transaction sees internally inconsistent state. Dangerous for analytics, backups, integrity checks. May produce incorrect results without error. | Assess impact; fix if long-running reads are involved. |
| LOW | Possible exposure but requires unusual timing or very high conturrency to trigger. Or: the anomaly is possible in theory but the affected data has compensating constraints. | Note in code review; monitor. |
| NOT PRESENT | The effective isolation level prevents this anomaly. No code change required for this specific finding. | No action required. |

---

## Fix Decision Tree

For each finding, select the fix using this tree:

```
Is the effective isolation level already sufficient to prevent this anomaly?
  YES → NOT PRESENT; skip this finding
  NO  → Continue

Is the anomaly a lost update?
  YES →
    Is the write value computed purely in SQL (no application-layer arithmetic)?
      YES → Already safe (atomic SQL operation). Mark as false positive.
      NO  →
        Can the write be expressed as a pure SQL expression? (e.g., value = value + ?)
          YES → Fix A: Replace read-modify-write with atomic SQL UPDATE
          NO  →
            Is the database PostgreSQL or Oracle at snapshot isolation?
              YES → Aborts automatically; add retry logic (Fix B)
              NO  → Fix C: Add SELECT FOR UPDATE before the read, OR
                    Fix D: Upgrade to serializable isolation
  NO  → Continue (not a lost update)

Is the anomaly write skew (non-phantom variant)?
  YES →
    Does the precondition SELECT return existing rows?
      YES → Fix E: Add SELECT FOR UPDATE to the precondition query
      NO  → Fix F: Upgrade to serializable isolation
             (or Fix G: Materialize conflicts — last resort)
  NO  → Continue

Is the anomaly a phantom (check-for-absence then insert)?
  YES →
    Is a UNIQUE database constraint sufficient to enforce the invariant?
      YES → Fix H: Add UNIQUE constraint (no application change needed)
      NO  → Fix F: Upgrade to serializable isolation
             (or Fix G: Materialize conflicts — last resort)
  NO  → Continue

Is the anomaly read skew?
  YES → Fix I: Upgrade the transaction to snapshot isolation (REPEATABLE READ or higher)
  NO  → Continue

Is the anomaly a dirty read or dirty write?
  YES → Fix J: Enable read committed isolation (the database default for most systems)
```

---

## Fix Catalog

### Fix A: Replace Read-Modify-Write with Atomic SQL UPDATE

**Applies to:** Lost update — when the new value can be expressed as a SQL expression.

**What to change:** Remove the SELECT that reads the old value. Replace the application-layer arithmetic with a SQL expression in the UPDATE statement. Add a WHERE guard to check preconditions.

**Before:**
```python
# Python — read-modify-write cycle (vulnerable)
item = session.query(InventoryItem).filter_by(id=item_id).one()
if item.quantity < requested:
    raise InsufficientStock()
item.quantity -= requested
session.commit()
```

**After:**
```python
# Atomic SQL update with guard — safe
result = session.execute(
    update(InventoryItem)
    .where(InventoryItem.id == item_id)
    .where(InventoryItem.quantity >= requested)
    .values(quantity=InventoryItem.quantity - requested)
    .returning(InventoryItem.quantity)
)
if result.rowcount == 0:
    raise InsufficientStock()
session.commit()
```

**SQL equivalent:**
```sql
UPDATE inventory_items
  SET quantity = quantity - :requested
  WHERE id = :item_id AND quantity >= :requested;
-- Check rows_affected == 1; if 0, insufficient stock
```

**Why it works:** The arithmetic happens atomically inside the database. No window exists for a concurrent read to see the old value and compute the same update.

**Limitation:** Requires that the new value can be expressed as a function of the current value in SQL. Not applicable when the update logic requires complex application code (e.g., parsing a JSON document, applying business rules, calling an external service).

---

### Fix B: Add Retry Logic for Snapshot Isolation Aborts (PostgreSQL/Oracle)

**Applies to:** Lost update — PostgreSQL or Oracle at snapshot isolation (automatic detection aborts the transaction; application must retry).

**What to change:** Wrap the transaction in a retry loop. Catch the serialization failure exception and retry from scratch.

```python
# Python / psycopg2
import psycopg2
from psycopg2 import errors

def execute_with_retry(conn, transaction_fn, max_retries=5):
    for attempt in range(max_retries):
        try:
            with conn.cursor() as cur:
                cur.execute("BEGIN")
                result = transaction_fn(cur)
                conn.commit()
                return result
        except errors.SerializationFailure:
            conn.rollback()
            if attempt == max_retries - 1:
                raise
            continue
        except Exception:
            conn.rollback()
            raise
```

```java
// Java / Spring — retry template
@Retryable(value = {DataAccessException.class}, maxAttempts = 5)
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void performUpdate(...) {
    // transaction body
}
```

**SQLSTATE to catch:**
- PostgreSQL: `40001` (serialization_failure)
- Oracle: `ORA-08177` (can't serialize access)

**Why it works:** The database detects the lost update conflict at commit time and aborts one transaction. The retry re-executes the transaction from the beginning, at which point it reads the updated value and proceeds correctly.

---

### Fix C: SELECT FOR UPDATE (Read Lock)

**Applies to:** Lost update — when Fix A is not applicable (complex application logic between read and write).

**What to change:** Add `FOR UPDATE` to the SELECT that reads the value being modified. This acquires an exclusive lock on the row, blocking any concurrent transaction that tries to read the same row with FOR UPDATE.

**Before:**
```sql
SELECT balance FROM accounts WHERE id = $1;
-- application computes new balance
UPDATE accounts SET balance = $new_balance WHERE id = $1;
```

**After:**
```sql
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
-- application computes new balance
UPDATE accounts SET balance = $new_balance WHERE id = $1;
```

**Why it works:** The FOR UPDATE lock prevents a concurrent transaction from reading the same row in its own FOR UPDATE SELECT until the first transaction commits. The second transaction reads the updated value rather than the stale value.

**Limitation:** Requires remembering to add FOR UPDATE everywhere the row is read for modification. Missing one lock anywhere in the codebase creates the vulnerability. Serializable isolation is more robust because it applies automatically.

---

### Fix D: Upgrade Isolation Level

**Applies to:** Lost update — when Fix A, B, and C are not applicable or not sufficient.

Per-database settings:

```sql
-- PostgreSQL
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- or: SET default_transaction_isolation = 'repeatable read';

-- MySQL InnoDB (REPEATABLE READ does NOT detect lost updates)
-- Must upgrade to SERIALIZABLE
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- SQL Server
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
-- Requires READ_COMMITTED_SNAPSHOT=ON at database level

-- Oracle
-- No true serializable available; use SELECT FOR UPDATE (Fix C)
```

---

### Fix E: SELECT FOR UPDATE on Precondition Query (Write Skew over Existing Rows)

**Applies to:** Write skew — where the precondition SELECT returns existing rows that can be locked.

**Applicable when:** The check-then-act pattern reads rows that exist in the database (not checking for absence).

**Not applicable when:** The SELECT returns zero rows (checking for absence) — no rows exist to lock.

**Before:**
```sql
BEGIN;
SELECT COUNT(*) FROM doctors
  WHERE on_call = true AND shift_id = $1;
-- if count >= 2, proceed
UPDATE doctors SET on_call = false WHERE name = $2 AND shift_id = $1;
COMMIT;
```

**After:**
```sql
BEGIN;
SELECT COUNT(*) FROM doctors
  WHERE on_call = true AND shift_id = $1
  FOR UPDATE;                                    -- lock all on-call rows
-- concurrent transactions must wait until this one commits
UPDATE doctors SET on_call = false WHERE name = $2 AND shift_id = $1;
COMMIT;
```

**Why it works:** FOR UPDATE locks every row returned by the SELECT. A second concurrent transaction's FOR UPDATE SELECT must wait until the first transaction commits. After the first commits (count = 1), the second reads count = 1 and fails the check.

**Limitation:** If the shift has many on-call doctors, this locks all of them for the duration of the transaction. Assess whether this lock scope is acceptable for the workload.

**ORM equivalents:**
```python
# SQLAlchemy
session.query(Doctor).filter_by(on_call=True, shift_id=shift_id).with_for_update().count()
```
```java
// Spring Data JPA
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT COUNT(d) FROM Doctor d WHERE d.onCall = true AND d.shiftId = :shiftId")
long countOnCallForUpdate(@Param("shiftId") long shiftId);
```
```ruby
# ActiveRecord
Doctor.where(on_call: true, shift_id: shift_id).lock("FOR UPDATE").count
```

---

### Fix F: Upgrade to Serializable Isolation

**Applies to:** Write skew (phantom variant, or where FOR UPDATE is insufficient or impractical).

**Per-database settings:**

```sql
-- PostgreSQL: SSI (optimistic; aborts conflicting transactions at commit)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- or: SET default_transaction_isolation = 'serializable';

-- MySQL InnoDB: 2PL (pessimistic; blocks conflicting transactions)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- SQL Server: 2PL
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- Oracle: not available as true serializable.
-- Oracle "SERIALIZABLE" = snapshot isolation. Write skew still possible.
-- Use Fix E (FOR UPDATE) or Fix G (materializing conflicts) for Oracle.
```

**Application requirements for PostgreSQL SSI:**
- Implement retry logic for SQLSTATE 40001 (serialization_failure)
- The entire transaction must be re-executed on abort — ORM frameworks do not retry by default
- See fix B code patterns for retry implementation

---

### Fix G: Materialize Conflicts (Last Resort)

**Applies to:** Phantom write skew — where the SELECT returns no rows, FOR UPDATE cannot be used, and upgrading to serializable isolation is not possible (e.g., Oracle).

**What to change:** Create a table of "lock rows" that represent the space of possible conflicts. Lock the appropriate rows before checking for conflicts and inserting.

**Example — booking system:**
```sql
-- Create a time slot lock table ahead of time (all rooms × all 15-minute slots for next 6 months)
CREATE TABLE booking_locks (
    room_id INT,
    slot_start TIMESTAMP,
    PRIMARY KEY (room_id, slot_start)
);

-- In the booking transaction:
BEGIN;
-- Lock all slots that overlap the requested time range
SELECT * FROM booking_locks
  WHERE room_id = $1
    AND slot_start >= $2
    AND slot_start < $3
  FOR UPDATE;

-- Now check for conflicting bookings
SELECT COUNT(*) FROM bookings
  WHERE room_id = $1 AND end_time > $2 AND start_time < $3;

-- If count = 0, insert
INSERT INTO bookings (...) VALUES (...);
COMMIT;
```

**Why it works:** Now there are rows to lock with FOR UPDATE — the lock rows. Two concurrent booking transactions for the same room and time range both try to lock the same lock rows. One must wait. After the first commits, the second reads the count and finds the conflict.

**Why this is a last resort:**
- Requires creating and maintaining an additional table
- Lock rows must be populated ahead of time (cron job or migration)
- Coupling concurrency control mechanics into the data model is ugly
- Harder to reason about correctness than serializable isolation
- Only use when serializable isolation is genuinely unavailable or has unacceptable overhead

---

### Fix H: UNIQUE Database Constraint

**Applies to:** Phantom write skew where the uniqueness can be enforced at the single-row level (username, single-column unique values).

**What to change:** Add a UNIQUE constraint to the table. Let the database enforce the invariant at the storage level rather than at the application level.

```sql
-- Username uniqueness: UNIQUE constraint is the right tool
ALTER TABLE users ADD CONSTRAINT unique_username UNIQUE (username);

-- Seat assignment: UNIQUE constraint on (flight_id, seat_number)
ALTER TABLE seat_assignments ADD CONSTRAINT unique_seat UNIQUE (flight_id, seat_number);
```

**Application change:** Catch the unique constraint violation exception and return the appropriate application error.

```python
try:
    db.session.add(User(username=username, ...))
    db.session.commit()
except IntegrityError:
    db.session.rollback()
    raise UsernameAlreadyTakenError()
```

**Why it works:** The UNIQUE constraint is enforced by the database at the storage layer, regardless of isolation level. Two concurrent inserts for the same username — one will succeed, one will fail with a constraint violation. No race condition possible.

**Limitation:** Only works when the invariant can be expressed as a uniqueness constraint on a single row. Does not apply to multi-row invariants (e.g., "at most one booking per room per overlapping time range" requires range comparison, not row uniqueness).

---

### Fix I: Upgrade to Snapshot Isolation for Long-Running Reads

**Applies to:** Read skew — long-running transactions that read multiple related tables.

**What to change:** Set the transaction isolation to REPEATABLE READ (snapshot isolation) for transactions that perform long-running reads across multiple tables.

```python
# Python: per-transaction isolation
with engine.connect() as conn:
    conn.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"))
    # All reads within this transaction see a consistent snapshot
    accounts = conn.execute(select(accounts_table)).fetchall()
    transfers = conn.execute(select(transfers_table)).fetchall()
    conn.commit()
```

```java
// Java Spring: per-method isolation
@Transactional(isolation = Isolation.REPEATABLE_READ)
public BillingReport generateBillingReport() {
    // All reads see the database state at transaction start
}
```

**Why it works:** Snapshot isolation takes a consistent snapshot of the entire database at the start of the transaction. All reads within the transaction — regardless of how many there are or how long the transaction runs — see the same point-in-time state. Concurrent writes by other transactions are invisible.

---

### Fix J: Enable Read Committed (Baseline Fix)

**Applies to:** Dirty reads — when the database is at read uncommitted.

**What to change:** Set the isolation level to read committed (the default for most production databases).

```sql
-- PostgreSQL
SET default_transaction_isolation = 'read committed';

-- MySQL
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- Application-level: remove any explicit READ UNCOMMITTED settings
```

**Note:** Dirty writes are prevented at read committed and above through row-level write locks — no additional change needed beyond ensuring read uncommitted is not in use.

---

## Fix Applicability Summary

| Anomaly | Fix A | Fix B | Fix C | Fix D | Fix E | Fix F | Fix G | Fix H | Fix I |
|---------|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|
| Dirty read / dirty write | | | | | | | | | Fix J |
| Read skew | | | | | | | | | ✓ |
| Lost update (expressible as SQL) | ✓ | | | | | | | | |
| Lost update (complex logic, PG/Oracle) | | ✓ | ✓ | | | | | | |
| Lost update (complex logic, MySQL) | | | ✓ | ✓ | | | | | |
| Write skew (rows exist to lock) | | | | | ✓ | ✓ | | | |
| Write skew (phantom, unique claim) | | | | | | ✓ | | ✓ | |
| Write skew (phantom, range/overlap) | | | | | | ✓ | ✓ | | |
| Write skew (Oracle — no true serial.) | | | | | ✓ | — | ✓ | | |


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)