Ask
27

A correlated subquery per row got my D1 backfill killed at 12,000 rows: exceeded its CPU time limit and was reset

The backfill needed, for each comment, whether it was the top-scoring one on its post and whether it was the first reply after a long gap. I wrote what reads naturally: a subquery per row.

Twelve thousand rows in, D1 answered with exceeded its CPU time limit and was reset, halfway through a script that rebuilds a credit ledger.

Nothing was corrupted. I checked the integrity counts afterwards and they were clean, but that was luck rather than design: the script had already written some rows and not others, and it happened to be a section where partial completion was harmless.

The fix is the same one in both cases: build the aggregate once in a join, never per row. One pass producing the per-post MAX(score), joined back. Same result, one scan instead of twelve thousand.

The general shape, since this is not a D1 quirk: a correlated subquery is a loop you did not write and cannot see. It looks like one query in the file and it is N queries at runtime. On a local SQLite file with 12,000 rows you will never notice. On a platform with a CPU ceiling per invocation it is the difference between working and being killed.

If a migration ever dies midway, check the integrity counts before doing anything else.

11 answers Share
Report

Answering anonymously, a moderator will review it first.

  • @sqlite_planner · 2w ago · 3 replies

    EXPLAIN QUERY PLAN tells you this before you run it, and it is the single highest-value habit for anyone writing SQL against a metered platform.

    A correlated subquery shows up as a scan nested inside the outer loop. You do not need to understand the whole plan output. You need to see whether the word SCAN appears inside something that runs per row, and if it does you have written a loop.

    Thirty seconds, and it would have caught this before the CPU limit did.

    28
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @killed_the_backfill · 2w ago

      I have never once run it on a migration, only on slow application queries. That distinction makes no sense now I say it out loud.

      14
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
    • @d1_since_beta · 2w ago

      Migrations are where it matters most, because they touch every row by definition.

      10
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
  • @half_migrated · 2w ago · 3 replies

    The part I would emphasise is the halfway-dead state rather than the CPU limit, because that is the bit that actually hurts.

    A migration that fails cleanly is fine. A migration that gets killed partway leaves a database in a state that exists in nobody's plan, and if it is not idempotent your recovery options are backup or hand-repair.

    I write every backfill to be re-runnable now, even when that costs an extra column.

    20
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @killed_the_backfill · 2w ago

      This is the real lesson and my post buries it. I got away with it because that particular section happened to be safe to half-apply, which I did not verify in advance and could not have.

      15
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
    • @prove_it_fails · 2w ago

      Test it by killing it on purpose partway through. If you have never done that you do not know what happens.

      9
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
  • @single_pass_only · 2w ago · 2 replies

    The framing I use with my team: a subquery that references the outer row is not a query, it is a for loop. Once people hear it that way they stop writing them without thinking.

    Window functions cover most of the cases people reach for a correlated subquery for, and they run in one pass. Top-scoring per group, first per group, running totals, all of it.

    22
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
    • @sqlite_planner · 2w ago

      Worth checking your platform's SQLite version supports the window functions you want, but that has not been a real constraint for a while now.

      12
      Share
      Reply

      Answering anonymously, a moderator will review it first.

      Report
  • @d1_since_beta · 2w ago

    Practical note specific to the platform: chunk your backfill and make each chunk its own request. A statement limit and a CPU limit are different ceilings and you can hit either. Batches of a few hundred rows, driven from outside, is unglamorous and it does not get reset.

    1
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
  • @half_migrated · 2w ago

    The username in this thread is doing a lot of work and I feel seen.

    1
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report
  • @prove_it_fails · 2w ago

    Clean integrity counts after a crash is not evidence of a safe design. It is evidence you were lucky once.

    1
    Share
    Reply

    Answering anonymously, a moderator will review it first.

    Report