Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,9 @@ therefore still be cheaper as a scan.
## Indexes

- Stale index entries left by deletes and updates are filtered on fetch and
reclaimed by `REINDEX`, not removed opportunistically.
reclaimed by `REINDEX`, not removed opportunistically. `REINDEX` keeps the entry
of a deleted row only while a transaction that began before the delete is still
running, as it does on heap.
- `CREATE INDEX CONCURRENTLY` (the concurrent validate path) and partial
block-range index builds are not supported.

Expand Down
37 changes: 37 additions & 0 deletions src/columnar_reader.c
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,15 @@ struct PgColumnarReadState
bool emitDeleted;
bool lastRowDeleted;

/*
* With emitDeleted, a row deleted under THIS snapshot too is dead to every
* transaction, and is not returned (#1407). The index build passes one that
* sees only transactions older than every running snapshot.
*/
Snapshot deadHorizon;
char *deadMask;
uint32 deadMaskLen;

MemoryContext readContext; /* whole scan */
MemoryContext stripeContext; /* reset per stripe */
MemoryContext groupContext; /* reset per chunk group (decompressed) */
Expand Down Expand Up @@ -2833,6 +2842,17 @@ pgcolumnar_native_load_group(PgColumnarReadState *rs)
&rs->nativeDeleteMask,
&rs->nativeDeleteMaskLen);
}
rs->deadMask = NULL;
rs->deadMaskLen = 0;
if (rs->deadHorizon != NULL && rs->nativeDeleteMask != NULL)
{
List *deadList = PgColumnarReadDeleteVectorList(rs->storageId,
rg->groupNumber,
rs->deadHorizon);

pgcolumnar_merge_delete_vectors(deadList, (uint32) ((rg->rowCount + 7) / 8),
&rs->deadMask, &rs->deadMaskLen);
}

/*
* The chunk metadata is read before the data (#338) because it carries the
Expand Down Expand Up @@ -3458,6 +3478,9 @@ pgcolumnar_native_next_row(PgColumnarReadState *rs, Datum *values, bool *nulls,

if (deleted && !rs->emitDeleted)
continue; /* row deleted: cursors advanced, do not emit */
if (deleted && rs->deadMask != NULL &&
dv_row_deleted(rs->deadMask, rs->deadMaskLen, rs->rowInGroup - 1))
continue; /* dead to every transaction: no snapshot needs it */

rs->lastRowDeleted = deleted;
return true;
Expand All @@ -3479,6 +3502,20 @@ PgColumnarReadSetEmitDeleted(PgColumnarReadState *readState, bool emit)
readState->emitDeleted = emit;
}

/*
* PgColumnarReadSetDeadHorizon
* With PgColumnarReadSetEmitDeleted, leave out the deleted rows that are
* deleted under `horizon` too (#1407). The index build passes a snapshot that
* sees only transactions older than every running snapshot, so a row deleted
* there is dead to all and needs no index entry, as heap's build skips a dead
* tuple. Without it every deleted row was indexed, and REINDEX reclaimed none.
*/
void
PgColumnarReadSetDeadHorizon(PgColumnarReadState *readState, Snapshot horizon)
{
readState->deadHorizon = horizon;
}

bool
PgColumnarReadLastRowDeleted(PgColumnarReadState *readState)
{
Expand Down
1 change: 1 addition & 0 deletions src/columnar_reader.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ extern bool PgColumnarReadFoldColumn(PgColumnarReadState *readState, int attidx,
int16 *attlen, const uint32 **vecRawLen);

extern void PgColumnarReadSetEmitDeleted(PgColumnarReadState *readState, bool emit);
extern void PgColumnarReadSetDeadHorizon(PgColumnarReadState *readState, Snapshot horizon);
extern bool PgColumnarReadLastRowDeleted(PgColumnarReadState *readState);
extern void PgColumnarReadSetProjection(PgColumnarReadState *readState,
Bitmapset *projectedColumns);
Expand Down
22 changes: 22 additions & 0 deletions src/columnar_tableam.c
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
#include "catalog/storage.h"
#include "commands/defrem.h"
#include "commands/extension.h"
#include "storage/procarray.h"
#include "commands/trigger.h"
#include "catalog/dependency.h"
#include "access/htup_details.h"
Expand Down Expand Up @@ -2419,6 +2420,8 @@ pgcolumnar_index_build_range_scan(Relation table_rel, Relation index_rel,
IndexBuildCallback callback, void *callback_state,
TableScanDesc scan)
{
SnapshotData deadHorizon;
Snapshot deadHorizonReg;
PgColumnarReadState *readState;
bool ownReadState;
Bitmapset *projected;
Expand Down Expand Up @@ -2547,6 +2550,24 @@ pgcolumnar_index_build_range_scan(Relation table_rel, Relation index_rel,
/* deleted rows too, flagged, so the build can index them as not alive (#1341) */
PgColumnarReadSetEmitDeleted(readState, true);

/*
* BUT NOT A ROW DEAD TO EVERY TRANSACTION (#1407). The horizon sees exactly the
* transactions committed before every running snapshot began, so a row deleted
* under it is one no snapshot can see, and heap's build skips the like. With
* every deleted row indexed, REINDEX -- documented as what reclaims stale
* entries -- reclaimed none: 9 rows in 10 deleted, and the rebuilt index kept
* its size where heap's shrank to a tenth. curcid 0 keeps this transaction's
* own writes out of it; a row it deleted is not dead to all anyway.
*/
memset(&deadHorizon, 0, sizeof(deadHorizon));
deadHorizon.snapshot_type = SNAPSHOT_MVCC;
deadHorizon.xmin = GetOldestNonRemovableTransactionId(table_rel);
deadHorizon.xmax = deadHorizon.xmin;
deadHorizon.curcid = FirstCommandId;
/* registered: a visibility check asserts it (PG 18+), and the copy outlives this frame */
deadHorizonReg = RegisterSnapshot(&deadHorizon);
PgColumnarReadSetDeadHorizon(readState, deadHorizonReg);

/*
* Say which branch ran and how wide the reader it produced will actually
* read, so a test can assert the projection NARROWED rather than infer it
Expand Down Expand Up @@ -2624,6 +2645,7 @@ pgcolumnar_index_build_range_scan(Relation table_rel, Relation index_rel,
*/
if (scan != NULL)
table_endscan(scan);
UnregisterSnapshot(deadHorizonReg);

return reltuples;
}
Expand Down
18 changes: 18 additions & 0 deletions test/am_contracts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -309,4 +309,22 @@ psql_run "INSERT INTO ibu VALUES (5);"
check "a unique index builds over a key whose other holder is a deleted row" \
"$(state "CREATE UNIQUE INDEX ibu_k ON ibu (k);")|$(q "SET enable_seqscan = off; SELECT count(*) FROM ibu WHERE k = 5;" | tail -1)" "ok|1"

# But only rows some snapshot can still see (#1407). Every deleted row went in, so
# REINDEX, documented as what reclaims stale entries, reclaimed none: 9 rows in 10
# deleted, and the rebuilt index kept its size where heap's shrank to a tenth.
for am in pgcolumnar heap; do
psql_run "CREATE TABLE ix_$am (a int) USING $am; INSERT INTO ix_$am SELECT g FROM generate_series(1, 100000) g;
CREATE INDEX ix_${am}_a ON ix_$am (a); DELETE FROM ix_$am WHERE a % 10 <> 0;"
done
ix_reclaim() { # ix_reclaim AM -> reclaimed, when REINDEX takes the index under half its size
local before after
before="$(q "SELECT pg_relation_size('ix_${1}_a')")"
psql_run "REINDEX INDEX ix_${1}_a;"
after="$(q "SELECT pg_relation_size('ix_${1}_a')")"
[ -n "$before" ] && [ -n "$after" ] && [ "$after" -lt $((before / 2)) ] && echo reclaimed || echo "kept:$before->$after"
}
check "REINDEX after a committed delete of most rows reclaims their entries, as heap's does" \
"$(ix_reclaim pgcolumnar)|$(ix_reclaim heap)|$(q "SET enable_seqscan = off; SELECT count(*) FROM ix_pgcolumnar WHERE a > 0;" | tail -1)" \
"reclaimed|reclaimed|10000"

pgc_summary
95 changes: 95 additions & 0 deletions test/check_ledger.tsv
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
am_contracts am_contracts ON CONFLICT DO NOTHING skips the duplicate and inserts the new row 15;16;17;18;19 2026-10-14 speculative insert refused as unsupported, as before #1341
am_contracts am_contracts ON CONFLICT DO UPDATE is refused as unsupported (0A000), and changes nothing 15;16;17;18;19 2026-10-14 speculative insert refused as unsupported, as before #1341
am_contracts am_contracts ON CONFLICT DO UPDATE with no conflicting row inserts it 15;16;17;18;19 2026-10-14 speculative insert refused as unsupported, as before #1341
am_contracts am_contracts REINDEX after a committed delete of most rows reclaims their entries, as heap's does 15;16;17;18;19 2026-10-15 the index build indexing every deleted row (main before #1407's fix)
am_contracts am_contracts a DO NOTHING racing an uncommitted insert of the same key waits, then skips it 15;16;17;18;19 2026-10-14 speculative insert refused as unsupported, as before #1341
am_contracts am_contracts a TRUNCATE rolled back at transaction level restores every row 15;16;17;18;19 2026-10-14 TRUNCATE empties the old storage in place (RelationTruncate before the swap)
am_contracts am_contracts a TRUNCATE rolled back to a savepoint restores every row, and the transaction goes on 15;16;17;18;19 2026-10-14 TRUNCATE empties the old storage in place (RelationTruncate before the swap)
Expand Down Expand Up @@ -2059,6 +2060,100 @@ native_fetch_coalesce native_fetch_coalesce premise: fetching one projected colu
native_fetch_coalesce native_fetch_coalesce premise: the coalescing helper holds both the bound and the validity copy 15;16;17;18;19 never -
native_fetch_coalesce native_fetch_coalesce the validity copy is bounded by the chunk length before it runs 15;16;17;18;19 never -
native_fetch_coalesce native_fetch_coalesce the wide fetch returns the projected values 15;16;17;18;19 2026-10-14 every column of a coalesced fetch is copied from the start of the span (off = 0)
native_groupagg native_groupagg a NULL key counts as a group in the bucket bound: four groups under a cap of 3 are declined and all answered 15;16;17;18;19 never -
native_groupagg native_groupagg a cached plan run under a lower cap stops at the runtime cap, with its hint 15;16;17;18;19 never -
native_groupagg native_groupagg a date_bin key is bounded as a truncating key is 15;16;17;18;19 never -
native_groupagg native_groupagg a five-minute bucket query over more rows than the cap still takes the grouped path 15;16;17;18;19 never -
native_groupagg native_groupagg a lookalike function with the same signature gets NO bound 15;16;17;18;19 never -
native_groupagg native_groupagg a mixed timestamp/timestamptz predicate never narrows the estimate below the true count 15;16;17;18;19 never -
native_groupagg native_groupagg a non-time expression key gets no bound either 15;16;17;18;19 never -
native_groupagg native_groupagg a plain key over more rows than the cap is declined 15;16;17;18;19 never -
native_groupagg native_groupagg a skewed key whose estimate is far too low is declined under a cap of 30,000, and answers every group 15;16;17;18;19 never -
native_groupagg native_groupagg an informed estimate (plain column) is not replaced 15;16;17;18;19 never -
native_groupagg native_groupagg and a date_bin key with no time bound is bounded from the zone maps 15;16;17;18;19 never -
native_groupagg native_groupagg and it is an UPPER bound: the true group count is at or below it 15;16;17;18;19 never -
native_groupagg native_groupagg and the declined query answers what heap answers 15;16;17;18;19 never -
native_groupagg native_groupagg answer: non-deterministic collation grouping is case-insensitive (10 groups) 15;16;17;18;19 never -
native_groupagg native_groupagg avg(float8) overflow errors like core 15;16;17;18;19 never -
native_groupagg native_groupagg avg(float8) overflow: node fires 15;16;17;18;19 never -
native_groupagg native_groupagg by default a five-minute bucket query takes the grouped path 15;16;17;18;19 never -
native_groupagg native_groupagg empty table: grouped scan yields 0 rows 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY added column 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY added column [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY host 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY host [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY host after deletes 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY host after deletes [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY host, added column 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY host, added column [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY hour 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY hour [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY hour, host (q4/q5 shape) 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY hour, host (q4/q5 shape) [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY integer expr key 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY integer expr key [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY region, host (two text keys) 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY region, host (two text keys) [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY smallint column key 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: GROUP BY smallint column key [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: NULL ts group folds like heap 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: NULL ts group folds like heap [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: WHERE on measure + GROUP BY host 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: WHERE on measure + GROUP BY host [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: WHERE range + GROUP BY hour, host 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: WHERE range + GROUP BY hour, host [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: all rows filtered out -> 0 groups 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: default cap runs the high-cardinality key 15;16;17;18;19 never -
native_groupagg native_groupagg oracle exact: f(key) output still correct 15;16;17;18;19 never -
native_groupagg native_groupagg plan: GROUP BY hour expr uses grouped node 15;16;17;18;19 never -
native_groupagg native_groupagg plan: GROUP BY text key uses grouped node 15;16;17;18;19 never -
native_groupagg native_groupagg plan: GROUP BY with no aggregate falls back 15;16;17;18;19 never -
native_groupagg native_groupagg plan: f(key) output falls back 15;16;17;18;19 never -
native_groupagg native_groupagg plan: non-deterministic collation key falls back 15;16;17;18;19 never -
native_groupagg native_groupagg plan: off -> ordinary Agg, not grouped node 15;16;17;18;19 never -
native_groupagg native_groupagg plan: q4-shape (WHERE+2 keys) uses grouped node 15;16;17;18;19 never -
native_groupagg native_groupagg plan: the node is chosen for a key well under the gate 15;16;17;18;19 never -
native_groupagg native_groupagg planning a bucketed GROUP BY scans zone_map once, not once per row group 15;16;17;18;19 never -
native_groupagg native_groupagg premise: ANALYZE estimates the skewed key's 40,001 groups below 3,000 15;16;17;18;19 never -
native_groupagg native_groupagg premise: and so is ten, so the two costs are comparable 15;16;17;18;19 never -
native_groupagg native_groupagg premise: one aggregate is costed as the grouped node 15;16;17;18;19 never -
native_groupagg native_groupagg premise: the cost fixture holds the rows the 450.00 is computed from 15;16;17;18;19 never -
native_groupagg native_groupagg premise: the fixture has many row groups, so one scan and one per group differ 15;16;17;18;19 never -
native_groupagg native_groupagg premise: the grouped path is costed at all (non-empty estimate) 15;16;17;18;19 never -
native_groupagg native_groupagg premise: the mixed-type window holds data 15;16;17;18;19 never -
native_groupagg native_groupagg raising groupagg_max_groups to 10,000,000 lets the same query take the grouped path 15;16;17;18;19 never -
native_groupagg native_groupagg regress B1: sum(real) fires the node 15;16;17;18;19 never -
native_groupagg native_groupagg regress B1: sum(real) is nonzero 15;16;17;18;19 never -
native_groupagg native_groupagg regress B1: sum(real) matches heap, not 0 15;16;17;18;19 never -
native_groupagg native_groupagg regress B2: gating WHERE (SELECT false) returns no rows 15;16;17;18;19 never -
native_groupagg native_groupagg regress B2: gating WHERE (SELECT true) is a no-op 15;16;17;18;19 never -
native_groupagg native_groupagg regress B2: gating WHERE falls back (not the node) 15;16;17;18;19 never -
native_groupagg native_groupagg sum(float8) and sum(real) overflow raise 22003, as core does 15;16;17;18;19 2026-10-15 the node's float8 sum without its overflow check
native_groupagg native_groupagg sum(float8) and sum(real) overflow: node fires 15;16;17;18;19 never -
native_groupagg native_groupagg ten aggregates cost more than one (#349) 15;16;17;18;19 never -
native_groupagg native_groupagg the bound is accurate for a truncating time key, not just smaller 15;16;17;18;19 never -
native_groupagg native_groupagg the grouped path is on by default 15;16;17;18;19 never -
native_groupagg native_groupagg toggle exact: GROUP BY host 15;16;17;18;19 never -
native_groupagg native_groupagg toggle exact: GROUP BY host [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg toggle exact: GROUP BY host after deletes 15;16;17;18;19 never -
native_groupagg native_groupagg toggle exact: GROUP BY host after deletes [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg toggle exact: GROUP BY hour, host 15;16;17;18;19 never -
native_groupagg native_groupagg toggle exact: GROUP BY hour, host [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg toggle exact: GROUP BY region, host, WHERE 15;16;17;18;19 never -
native_groupagg native_groupagg toggle exact: GROUP BY region, host, WHERE [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg toggle float/avg: GROUP BY host 15;16;17;18;19 never -
native_groupagg native_groupagg toggle float/avg: GROUP BY host [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg toggle float/avg: GROUP BY hour, host 15;16;17;18;19 never -
native_groupagg native_groupagg toggle float/avg: GROUP BY hour, host [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg toggle float/avg: WHERE + GROUP BY region, host 15;16;17;18;19 never -
native_groupagg native_groupagg toggle float/avg: WHERE + GROUP BY region, host [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg toggle min/max numeric display-scale tie 15;16;17;18;19 never -
native_groupagg native_groupagg toggle min/max numeric display-scale tie [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg toggle sum signed-zero (-0 preserved) 15;16;17;18;19 never -
native_groupagg native_groupagg toggle sum signed-zero (-0 preserved) [node fires] 15;16;17;18;19 never -
native_groupagg native_groupagg while a matching-type predicate still gets one 15;16;17;18;19 never -
native_groupagg native_groupagg with no time bound at all, a truncating key is bounded from the zone maps 15;16;17;18;19 never -
native_groupagg native_groupagg with only a lower time bound, a truncating key is still bounded 15;16;17;18;19 never -
native_join_runtime_filter native_join_runtime_filter 3-table answer equals filter-off 15;16;17;18;19 2026-09-23 the runtime bloom probe's verdict inverted, so the filter rejects what matches
native_join_runtime_filter native_join_runtime_filter 3-table join order matches filter-off 15;16;17;18;19 never -
native_join_runtime_filter native_join_runtime_filter 3-table plan has coordinator 15;16;17;18;19 never -
Expand Down
Loading
Loading