Skip to content

Typed materialization runs for hours once its statements switch to a generic plan #1298

Closed

Description

@gedaiu

On our deployment a materialize_typed job has been running its source cleanup (step 1 of materialize_in_tx, the DELETE FROM typed_fact_sources … NOT EXISTS (…)) for over three hours on a base of about 438,000 facts and 200,000 typed-fact sources, holding the database at its CPU limit. A second materialize_typed for the same base waits on the advisory lock behind it.

The statement itself is fast. On a copy of that base's tables (same rows, same indexes, work_mem 16MB):

Plan Time
Planned for the base's id (kb_id = '…', or a custom plan of the prepared statement) 2.2 s
Generic plan of the prepared statement (plan_cache_mode = force_generic_plan) cancelled at 10 min

sqlx prepares statements, and after a few runs on a connection Postgres switches to a generic plan. The generic plan can't see how many rows t.kb_id = $1 selects. It estimates about four statements on the inner side and picks a nested-loop anti-join over a materialized inner result. That result actually holds about 356,000 rows and is rescanned for each of the base's 200,000 sources. With the id known, it hashes both sides once.

Proposal: the recompute plans every statement for the base it is given, with SET LOCAL plan_cache_mode = force_custom_plan at the start of materialize_in_tx. One statement, scoped to the transaction, so it covers the worker and the human-review entry points and nothing else. The test would check that the recompute's transaction plans that way; a timing test would be flaky.

A query rewrite would make the cleanup robust to any plan, for example adding s.kb_id = $1 (0.8 s on the same copy, since a source shares its fact's base) or storing the normalized phrase instead of computing a regex per fact per run. That is a bigger change I'd rather propose separately.

Happy to change the shape if you'd prefer a different approach. Thank you!

Activity

  1. WaylandYang commented on Oct 18, 2026

    @WaylandYang
    Contributor

    Thank you for the careful diagnosis and the plan comparison. This matches what we've seen before: 0035 records the same five-executions switch for vector search.

    A PR is welcome. Two notes on shape:

    1. SET LOCAL plan_cache_mode = force_custom_plan works and stays inside the transaction, but it also replans every per-row statement in step 3's loop on each execution. Marking just the whole-base statements in materialize_in_tx (steps 0–3 and the implication/attestation scans) with .persistent(false) gets them planned with the real values every time and leaves the per-row statements cached. I'd lean that way, but either is fine if you note the tradeoff.
    2. Please include s.kb_id = $1 in step 1 in the same PR. Migration 0070's typed_fact_sources_same_kb trigger guarantees a source statement is in its fact's base, so the predicate changes no result, and it keeps the cleanup fast if a generic plan ever comes back. Storing the normalised phrase can be its own proposal, as you suggest.

    For the test, asserting the planning mode (or that those statements aren't cached) rather than timing is right.

  2. gedaiu commented on Oct 18, 2026

    @gedaiu
    ContributorAuthor

    Thank you! #1307 does it your way: .persistent(false) on the eight whole-base statements, with the per-row ones still cached, and s.kb_id = $1 in step 1. The test checks pg_prepared_statements on a one-connection pool rather than timing.

  3. WaylandYang commented on Oct 19, 2026

    @WaylandYang
    Contributor

    Fixed by #1307, now in dev. Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions