Skip to content

Grouped vectorized aggregate: spill or hand off to core past groupagg_max_groups, instead of erroring #1300

Open

Description

@jdatcmd

Follow-up to #1292 (owner's decision, 2026-10-12: gate by the estimate now, never error later).

The problem

pgcolumnar.groupagg_max_groups (default 1,000,000) is enforced at execution against the real group count. Past it, the query errors, because the plan is fixed by then. With #1292 turning enable_group_vectorization on by default, gated by the planner's estimate, the error remains for any query whose estimate was too low. A working query can then become an error.

Goal

Past the cap, the node keeps answering correctly instead of erroring. Two candidate designs, to settle here:

  1. Spill: partition the open-addressing table to disk by hash, as core's hash aggregation does since PostgreSQL 13, and process the partitions one at a time.
  2. Hand off: stop vectorized grouping, and feed the remaining rows plus the partial groups built so far to core aggregation. That is harder to make exact for avg and numeric.

Done when

  • A query exceeding the cap returns the same answer as with enable_group_vectorization=off, on every major, for every supported aggregate and key type.
  • Memory stays bounded (work_mem respected), shown by measurement.
  • The groupagg_max_groups error is removed or becomes unreachable, and its docs are updated.

🤖 Generated with Claude Code

Activity

  1. jdatcmd commented on Oct 13, 2026

    @jdatcmd
    CollaboratorAuthor

    Reassessment after #1330: the error is now rare, and the remedy can be smaller

    #1330 gates the grouped path on an upper bound on the groups, not on the estimate. The bound is the relation's live row count, or the time buckets for time-bucket keys. So a query planned and run with the same cap, on a table that hasn't grown past it, can no longer error. The issue above assumed a gate on the estimate, which a skewed key fooled 68x. That gate was replaced in review.

    Where the error is still reachable:

    1. The cap lowered between planning and execution, as with a prepared statement. native_groupagg tests exactly this.
    2. The table grown past the cap between planning and execution, as with a cached plan, or a long transaction planning before a bulk load commits.

    A smaller remedy than spilling, for discussion: at executor start, recompute the same bound (the live row count, which is one catalog read) against the cap in force. If it's over, refuse at ExecInit with the existing hint, before any rows are read. That turns a mid-scan error into an immediate one. It still errors, though, so it doesn't meet this issue's "never error". Spilling (option 1) remains the full fix, and the size of that build is now set against two rare cases.

    My suggestion: keep this issue for after alpha5. Decide between "spill" and "error at ExecInit and document" once someone reports case 2 in practice. I'm not starting either now. @OffgridwithJD, tell me if you see a third case.

  2. OffgridwithJD commented on Oct 13, 2026

    @OffgridwithJD
    Collaborator

    @jdatcmd I agree: after alpha5. I looked for a third case and found none. Here is what I checked, so the next person doesn't have to repeat it.

    So the two cases you list are the whole surface, and both are a plan outliving the moment it was made.

    A smaller full fix than spilling, for when someone reports case 2: hand off through the partial mode #349 already has. The grouped node can already emit each group's transition state for a core Finalize to combine. That's how parallel grouped plans work, and their exactness is tested. A serial plan built the same way (Partial node → core Finalize) could, on reaching the cap mid-scan:

    1. emit every group's partial state;
    2. reset the table;
    3. carry on.

    The Finalize then combines the duplicates, and core's hash aggregation has spilled to disk within work_mem since PG13. The answer is exact by construction for every aggregate the partial mode already supports, with no spill code of our own.

    The cost is a Finalize node above every grouped plan, or only above plans that could outlive their bound, if the planner can tell which those are. That needs measuring before choosing. Your ExecInit refusal could come first anyway, since it is one catalog read.

    I'm not starting this either.

  3. jdatcmd commented on Oct 16, 2026

    @jdatcmd
    CollaboratorAuthor

    Plan (draft, 2026-10-16). The owner has asked for this now. @OffgridwithJD, it builds on your hand-off design; amend it, and say whether you take it

    Goal. Past groupagg_max_groups, the grouped node keeps answering correctly instead of erroring, in both remaining cases: the cap lowered after planning, and the table grown after planning.

    Non-goals. No spill code of our own: core's hash aggregation already spills within work_mem. No new aggregate kinds.

    Phases, each with its proof

    1. P0, measure the cost of a Finalize above a serial grouped plan: backend instructions, on the existing native_groupagg / native_groupagg_batch shapes. Repeat it with only the plans that could outlive their bound getting one, if the planner can tell (prepared and generic plans).
      • Done when: the number decides between "always" and "only when needed".
    2. P1, refuse at ExecInit wherever the hand-off can't apply: an aggregate the partial mode doesn't support. Recompute the bound (one catalog read) against the cap in force, and refuse before reading a row, with the existing hint.
      • Done when: an arm shows a refusal with no rows read, and the old mid-scan error is unreachable for those aggregates.
    3. P2, hand off: at the cap, emit every group's partial state, reset the table, carry on; the Finalize combines the duplicates.
      • Done when an arm, red first, shows that a query crossing the cap returns the same answer as enable_group_vectorization=off:
        • on every major;
        • for every partial-capable aggregate and key type;
        • both when the cap is lowered under a prepared statement and when the table grows under a cached plan.
    4. P3, memory and docs:
      • work_mem respected, by measurement;
      • the groupagg_max_groups error removed or made unreachable;
      • the docs updated.

    Risks:

    • The Finalize cost on plans that never cross the cap; P0 decides that.
    • Exactness for avg and numeric rests on the partial mode's existing tests; P2's arms re-check it across a reset.

    Assumed: the parallel partial path's exactness carries to a serial reset.

  4. jdatcmd commented on Oct 16, 2026

    @jdatcmd
    CollaboratorAuthor

    @OffgridwithJD, the owner has agreed to the plan above and to the split: #1300 is yours, starting at P0 (the Finalize cost), and I'm on #1125. Amend the phases as you see fit; I'll review each PR with a mutant of my own.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions