trajectory

Backfill a NOT NULL column on a large table without a long lock

Run record

Model
stub:hasty
Seed
2
Temperature
0
Steps
8 of 45
Total cost
$0.00
Wall clock
18.6 s
Hidden tests
10 of 10 passed
Verify exit code
0
Verify duration
12.2 s
Started
16 Sep 2026, 18:46 UTC
Finished
16 Sep 2026, 18:46 UTC
Suite
core-12
Status
completed
Sandbox
docker
solved 10/10 hidden testssql-migration-01stub:hastyseed 2docker8 steps$0.0018.6 s

Trajectory

  1. 0bashexit 031 ms$0.00

    Orient, and confirm the cluster is the one described rather than something to be brought up. Everything else depends on that.

    bash command
    ls -R --ignore=pgdata . && ./db.sh status
    output
    .:
    db.sh
    migrations
    README.md
    seed.sql
    SPEC.md
    
    ./migrations:
    0001_create_orders.down.sql
    0001_create_orders.up.sql
    0002_add_status.down.sql
    0002_add_status.up.sql
    pg_ctl: server is running (PID: 37)
    /usr/lib/postgresql/16/bin/postgres "-D" "/workspace/pgdata"
    

    15 lines, 261 B

  2. 1read_fileno exit code31 ms$0.00

    The review comment says which properties were objected to. Start from the objection.

    arguments
    {
      "path": "README.md"
    }

    3 lines, 25 B

    output
    # orders migrations
    
    Schema for the orders service. `migrations/` is applied in file order by
    
        ./db.sh apply migrations/<file>
    
    which is `psql -v ON_ERROR_STOP=1 -f`, so each statement runs in its own implicit
    transaction. The runner does not wrap a file in a transaction block, on purpose: a batched
    backfill has to be able to commit between batches, and a procedure that commits cannot run
    inside a transaction block. Do not add `BEGIN` or `COMMIT` around a whole file.
    
    ## The local cluster
    
    There is no PostgreSQL service and no network. `db.sh` owns a throwaway cluster in
    `./pgdata` that talks over a unix socket inside that directory:
    

    showing 16 of 50 lines

  3. 2read_fileno exit code31 ms$0.00

    The five rules and the statement budget. Read this before writing anything: four of the five are about locks and only one is about the value in the column.

    arguments
    {
      "path": "SPEC.md"
    }

    3 lines, 23 B

    output
    # orders.status
    
    ## What the column is
    
    `orders.status` is a text column, `NOT NULL`, holding exactly one of:
    
        cancelled  refunded  partly_refunded  shipped  placed
    
    ## How it is derived
    
    The rule is ordered. The first line that matches wins, which matters: an order can be
    both refunded and cancelled, and a cancelled order is `cancelled`.
    
    | condition                                             | status            |
    |-------------------------------------------------------|-------------------|
    | `cancelled_at IS NOT NULL`                            | `cancelled`       |

    showing 16 of 50 lines

  4. 3read_fileno exit code31 ms$0.00

    Read what was rejected. The CASE in it is the specification's rule and can be reused verbatim, which is worth noticing before rewriting it by hand and getting the precedence wrong.

    arguments
    {
      "path": "migrations/0002_add_status.up.sql"
    }

    3 lines, 49 B

    output
    -- Rejected in review. See README.md.
    ALTER TABLE orders ADD COLUMN status text;
    
    UPDATE orders
       SET status = CASE
                        WHEN cancelled_at IS NOT NULL THEN 'cancelled'
                        WHEN refunded_cents > 0 AND refunded_cents >= total_cents THEN 'refunded'
                        WHEN refunded_cents > 0 THEN 'partly_refunded'
                        WHEN shipped_at IS NOT NULL THEN 'shipped'
                        ELSE 'placed'
                    END;
    
    ALTER TABLE orders ALTER COLUMN status SET NOT NULL;

    13 lines, 516 B

  5. 4bashexit 03.22 s$0.00

    Measure the thing being replaced before replacing it. Two numbers come out of this: the UPDATE at several seconds, and the SET NOT NULL at a fraction of that, which is the table scan under ACCESS EXCLUSIVE. Both are the review's objection, in milliseconds.

    bash command
    ./db.sh reseed
    ./db.sh psql -q -c "select pg_stat_statements_reset()"
    time ./db.sh apply migrations/0002_add_status.up.sql
    ./db.sh psql -c "select toplevel, calls, round(max_exec_time::numeric,1) as ms,
                            left(query, 60) as statement
                       from pg_stat_statements
                      where query not ilike '%pg_stat_statements%'
                      order by max_exec_time desc limit 6"
    

    9 lines, 417 B

    output
     pg_stat_statements_reset 
    --------------------------
     
    (1 row)
    
    ALTER TABLE
    UPDATE 600000
    ALTER TABLE
     toplevel | calls |   ms   |                      statement                      
    ----------+-------+--------+-----------------------------------------------------
     t        |     1 | 2199.0 | UPDATE orders                                      +
              |       |        |    SET status = CASE                               +
              |       |        |                     WHEN 
     t        |     1 |   50.3 | ALTER TABLE orders ALTER COLUMN status SET NOT NULL
     t        |     1 |    0.8 | ALTER TABLE orders ADD COLUMN status text
    (3 rows)

    showing 16 of 22 lines

  6. 5write_fileno exit code31 ms$0.00

    Four separate changes, one per objection. Timeouts first, then a catalogue only ADD COLUMN, then batches that commit, then NOT NULL behind a validated constraint. The batch size is a judgement call: large enough that the loop is not thousands of round trips, small enough that one statement stays well inside the budget.

    content
    -- Add orders.status and backfill it without holding a lock anyone will notice.
    --
    -- Applied by ./db.sh apply, which is psql with ON_ERROR_STOP and one implicit transaction
    -- per statement. Nothing here opens an explicit transaction block: the backfill commits
    -- between batches, and a procedure that commits cannot run inside one.
    
    -- Never queue for a lock. A DDL statement that waits behind a long running read holds a
    -- place in the queue that every later query on the table waits behind too, which is how
    -- a metadata change with no rewrite in it takes an application down.
    SET lock_timeout = '3s';
    SET statement_timeout = '60s';
    
    -- Nullable and with no default: a catalogue change, no table rewrite, ACCESS EXCLUSIVE
    -- held for as long as it takes to write one catalogue row.
    ALTER TABLE orders ADD COLUMN IF NOT EXISTS status text;
    
    -- Backfill in batches, each its own transaction. One statement over the whole table would
    -- hold row locks on every row it had touched until it finished, keep one snapshot open

    showing 18 of 66 lines

    arguments
    {
      "path": "migrations/0002_add_status.up.sql"
    }

    3 lines, 49 B

    output
    Wrote 2859 bytes (66 lines) to migrations/0002_add_status.up.sql.
  7. 6write_fileno exit code47 ms$0.00

    The down file has to run against a database that got part way through the up file, which is what IF EXISTS is doing here. Dropping a column is a catalogue change; the space comes back with a later vacuum, not with this statement.

    content
    -- Reverse 0002. Every step is conditional, so this runs on a database that got part way
    -- through the up migration as well as on one that finished it.
    SET lock_timeout = '3s';
    SET statement_timeout = '60s';
    
    DROP PROCEDURE IF EXISTS backfill_orders_status();
    
    ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_status_present;
    
    -- Catalogue only. The column's storage is reclaimed by vacuum later, not here.
    ALTER TABLE orders DROP COLUMN IF EXISTS status;
    

    12 lines, 460 B

    arguments
    {
      "path": "migrations/0002_add_status.down.sql"
    }

    3 lines, 51 B

    output
    Wrote 460 bytes (12 lines) to migrations/0002_add_status.down.sql.
  8. 7finishno exit code0 ms$0.00

    Applied the fix. This should resolve the issue.

    arguments
    {
      "summary": "Applied the fix. This should resolve the issue."
    }

    3 lines, 66 B

    output
    Run ended by the agent.

8 steps2 commands0 schema violations0 failed commands0 destructive attempts

Metrics for this run

Partial credit
100.0%
Step efficiency
1.000
Tool validity
100.0%
Redundancy
0.0%
Recovery
n/a
Context drift
n/a
Commands
2
Schema violations
0
Failed commands
0
Destructive attempts
0

Verification output

The last few kilobytes of the hidden test run, stdout and stderr together, kept for triage. The agent never saw this.

..........                                                               [100%]
10 passed in 11.94s

3 lines, 100 B

Provenance
Harness
0.1.1
Schema
1
Sandbox
docker
Image
sha256:30b5a6152fe7cdc4304c2044c92c3706e9d9b05ebaf2720cf41a00dbb2cf0a24
OS
Windows 11
Arch
AMD64
Python
3.12.13
Docker
29.8.0
CPUs
24
CI
no
Command timeout
300s
Run timeout
1800s
Output cap
16384 bytes
Budget
none
Tools
bash, read_file, write_file, list_dir, finish
Workspace files
1382 after, 1380 before

Results that cannot be reproduced are not results. When a number moves, this is how you tell whether the model changed or the environment did.