diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cf3201a42..cf16dc30a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -185,6 +185,13 @@ jobs:
- name: fetch the release tags, for the fixture-blob comparisons
run: git fetch --depth=1 origin '+refs/tags/v1.0-alpha*:refs/tags/v1.0-alpha*'
+ - name: Resolve the object-storage suite host names
+ # test_objstore_addressing.py, the twin of objstore_addressing.sh, reads
+ # through virtual-host-style S3 addressing (pgc-bucket.s3.local) against
+ # its own loopback fixture server, as the suites job's shells do. Without
+ # the names its tests decline UNAVAILABLE_ENDPOINT and the run ends 67.
+ run: echo "127.0.0.1 s3.local pgc-bucket.s3.local" | sudo tee -a /etc/hosts
+
- name: Add the PGDG repository and refresh the package lists
run: |
set -euo pipefail
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 75f95e4b8..1c3183a7b 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -745,6 +745,13 @@ jobs:
- name: fetch the release tags, for the fixture-blob comparisons
run: git fetch --depth=1 origin '+refs/tags/v1.0-alpha*:refs/tags/v1.0-alpha*'
+ - name: Resolve the object-storage suite host names
+ # test_objstore_addressing.py, the twin of objstore_addressing.sh, reads
+ # through virtual-host-style S3 addressing (pgc-bucket.s3.local) against
+ # its own loopback fixture server, as the suites job's shells do. Without
+ # the names its tests decline UNAVAILABLE_ENDPOINT and the run ends 67.
+ run: echo "127.0.0.1 s3.local pgc-bucket.s3.local" | sudo tee -a /etc/hosts
+
- name: Add the PGDG repository and install PostgreSQL ${{ matrix.pg }} with its headers
run: |
set -euo pipefail
diff --git a/objstore/columnar_objstore_module.c b/objstore/columnar_objstore_module.c
index 69b90bdff..2244cb8e9 100644
--- a/objstore/columnar_objstore_module.c
+++ b/objstore/columnar_objstore_module.c
@@ -245,8 +245,15 @@ os_wait(PgColumnarObjHandle *h, uint32 sockEvents, int *quiet)
{
int rc;
+ /*
+ * WL_TIMEOUT, or the slice is not a slice: without it WaitLatchOrSocket
+ * ignores the timeout and waits for the socket or the latch alone, so a
+ * silent peer never counts a quiet slice. Under proc_exit (a terminated
+ * worker aborting its upload, #1727) interrupts are held and the latch
+ * alone never ends the wait either.
+ */
rc = WaitLatchOrSocket(MyLatch,
- WL_LATCH_SET | WL_EXIT_ON_PM_DEATH | sockEvents,
+ WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH | sockEvents,
h->fd, OS_WAIT_SLICE_MS, PG_WAIT_EXTENSION);
if (rc & WL_LATCH_SET)
ResetLatch(MyLatch);
@@ -2002,8 +2009,8 @@ os_flush_part(PgColumnarObjSink *s)
s->h->url, resp.status)));
/* The part's ETag is the server's word for its bytes; CompleteMultipart
* validates the list against it on a real S3, so echo it verbatim (already
- * quoted by the server). The fixture ignores it and concatenates by part
- * number, so both are satisfied. */
+ * quoted by the server). The fixture checks it the same way and assembles
+ * the listed parts, so both are satisfied. */
etag = resp.etag[0] ? resp.etag : "\"\"";
appendStringInfo(&s->completeXml,
"%d %s ",
@@ -2170,6 +2177,14 @@ objstore_sink_abort(PgColumnarObjSink *s)
char *encId = os_uriencode_query(s->uploadId);
char *query = psprintf("uploadId=%s", encId);
+ /*
+ * The abort can land mid-request (a cancel or a SIGTERM inside
+ * os_wait), with a part's body still on this connection: the
+ * DELETE would be read as more of that body, or its answer
+ * confused with the part's. A fresh connection makes it a request
+ * of its own (#1722).
+ */
+ os_disconnect(s->h);
os_write_request(s->h, "DELETE", query, NULL, 0, &resp, NULL);
pfree(query);
pfree(encId);
diff --git a/src/columnar_sink.c b/src/columnar_sink.c
index ea7a2ddba..5d1e2fc51 100644
--- a/src/columnar_sink.c
+++ b/src/columnar_sink.c
@@ -16,8 +16,11 @@
#include
+#include "access/xact.h"
+#include "lib/ilist.h"
#include "miscadmin.h"
#include "storage/fd.h"
+#include "storage/ipc.h"
#include "columnar_objstore.h"
#include "columnar_sink.h"
@@ -34,8 +37,62 @@ struct PqSink
/* remote (#394): NULL api => local sink */
const PgColumnarObjStoreApi *api;
PgColumnarObjSink *rsink;
+ dlist_node openNode; /* on sink_open_remote while rsink is live */
};
+/*
+ * Remote sinks with an upload in progress (#1722). A FATAL exit (SIGTERM, as
+ * pexport_cleanup sends each parallel_export worker on cancel, or
+ * pg_terminate_backend) calls proc_exit without unwinding, so the writers'
+ * PG_CATCH never reaches PgColumnarSinkAbort and the multipart upload stays
+ * open on the store, billed until a lifecycle rule reaps it. A
+ * before_shmem_exit callback aborts whatever is still on this list. It is
+ * registered after InitPostgres's ShutdownPostgres, so it runs first, while
+ * the transaction and the module's handles are still intact. Transaction end
+ * empties the list: every writer finishes or aborts its sink inside its own
+ * statement, so an entry still listed then belongs to memory being released.
+ */
+static dlist_head sink_open_remote = DLIST_STATIC_INIT(sink_open_remote);
+static bool sink_exit_registered = false;
+
+static void
+sink_abort_open_at_exit(int code, Datum arg)
+{
+ dlist_mutable_iter it;
+
+ /*
+ * The module's abort never raises through PG_TRY, but under proc_exit an
+ * ERROR is promoted to FATAL, which no CATCH sees. A failed DELETE then
+ * ends this callback with one more FATAL line; shmem_exit has already
+ * popped it, so the remaining callbacks still run.
+ */
+ dlist_foreach_modify(it, &sink_open_remote)
+ {
+ PqSink *snk = dlist_container(PqSink, openNode, it.cur);
+
+ dlist_delete(&snk->openNode);
+ snk->api->sink_abort(snk->rsink);
+ snk->rsink = NULL;
+ }
+}
+
+static void
+sink_xact_callback(XactEvent event, void *arg)
+{
+ switch (event)
+ {
+ case XACT_EVENT_COMMIT:
+ case XACT_EVENT_PARALLEL_COMMIT:
+ case XACT_EVENT_ABORT:
+ case XACT_EVENT_PARALLEL_ABORT:
+ case XACT_EVENT_PREPARE:
+ dlist_init(&sink_open_remote);
+ break;
+ default:
+ break;
+ }
+}
+
PqSink *
PgColumnarSinkOpenLocal(const char *path)
{
@@ -83,6 +140,13 @@ PgColumnarSinkOpen(const char *path)
snk->finalPath = pstrdup(path);
snk->api = api;
snk->rsink = api->sink_create(path, NULL);
+ if (!sink_exit_registered)
+ {
+ before_shmem_exit(sink_abort_open_at_exit, (Datum) 0);
+ RegisterXactCallback(sink_xact_callback, NULL);
+ sink_exit_registered = true;
+ }
+ dlist_push_head(&sink_open_remote, &snk->openNode);
return snk;
}
return PgColumnarSinkOpenLocal(path);
@@ -140,6 +204,8 @@ PgColumnarSinkFinish(PqSink *snk)
if (snk->api != NULL)
{
snk->api->sink_finish(snk->rsink);
+ /* only now: a complete that raised leaves it listed for the abort */
+ dlist_delete(&snk->openNode);
snk->rsink = NULL;
return;
}
@@ -173,6 +239,7 @@ PgColumnarSinkAbort(PqSink *snk)
{
if (snk->rsink != NULL)
{
+ dlist_delete(&snk->openNode);
snk->api->sink_abort(snk->rsink);
snk->rsink = NULL;
}
diff --git a/test/check_ledger.tsv b/test/check_ledger.tsv
index 8b086cb67..fb1965c7a 100644
--- a/test/check_ledger.tsv
+++ b/test/check_ledger.tsv
@@ -3972,6 +3972,9 @@ native_fetch_group_memo native_fetch_group_memo scattered index-fetched values a
native_fetch_group_memo native_fetch_group_memo the fetch count is exact (3000 rows found) 15;16;17;18;19 2026-10-22 columnar_reader.c: the memo's last-group fast path serves rows up to 1000 past that group's end
native_fetch_group_memo native_fetch_group_memo the measured plan is a nested loop of index probes (fetch path engaged) 15;16;17;18;19 never -
native_fetch_group_memo native_fetch_group_memo the table is exact after the in-statement rewrite 15;16;17;18;19 never -
+native_fetch_interrupt native_fetch_interrupt pgcolumnar_fetch_row carries an interrupt check 15;16;17;18;19 2026-10-22 columnar_reader.c: pgcolumnar_fetch_row's CHECK_FOR_INTERRUPTS commented out where it stood
+native_fetch_interrupt native_fetch_interrupt the interrupt check precedes the row-group resolution 15;16;17;18;19 2026-10-22 columnar_reader.c: pgcolumnar_fetch_row's CHECK_FOR_INTERRUPTS commented out where it stood
+native_fetch_interrupt native_fetch_interrupt the row-group resolution is where the catalog read lives 15;16;17;18;19 2026-10-22 columnar_reader.c: pgcolumnar_lookup_row_group's uncached path no longer reads the row-group list, a comment naming the read
native_fetch_position native_fetch_position a varying-length column reaches its value through an offset table 15;16;17;18;19 2026-10-16 columnar_reader.c: pgcolumnar_fetch_row reaches a varying-length value by decoding every earlier one (the #143 walk)
native_fetch_position native_fetch_position doubling the row group does not double the cost of the same fetches 15;16;17;18;19 2026-10-16 columnar_reader.c: pgcolumnar_fetch_row counts the validity bits from the group's start for every fetch (the #143 walk);columnar_reader.c: pgcolumnar_fetch_row reaches a varying-length value by decoding every earlier one (the #143 walk)
native_fetch_position native_fetch_position every column matches the heap mirror 15;16;17;18;19 2026-10-16 columnar_reader.c: pgcolumnar_fetch_row takes the row number as the rank, counting rows rather than present values;columnar_reader.c: pgcolumnar_fetch_row's rank is one past the row's
@@ -4009,6 +4012,19 @@ native_fetch_sort_context native_fetch_sort_context premise: the columnar side i
native_fetch_sort_context native_fetch_sort_context three-key merge cond with a filter (#720) 15;16;17;18;19 2026-10-22 columnar_tableam.c: the deferred decode builds its needed-column set in the caller's context and bms_free's it there (#720 reverted, first half)
native_fetch_sort_context native_fetch_sort_context two-key merge join count is right (#720) 15;16;17;18;19 2026-10-22 columnar_tableam.c: the deferred decode builds its needed-column set in the caller's context and bms_free's it there (#720 reverted, first half)
native_fetch_sort_context native_fetch_sort_context two-key merge join sum(id) is right (#720) 15;16;17;18;19 2026-10-22 columnar_tableam.c: the deferred decode builds its needed-column set in the caller's context and bms_free's it there (#720 reverted, first half)
+native_fold_deferral native_fold_deferral EXPLAIN group total == stats() group count 15;16;17;18;19 2026-10-22 columnar_vector.c: the fold counts each group twice in its total
+native_fold_deferral native_fold_deferral adaptive: eager groups load more than survivors alone (work measured, not assumed) 15;16;17;18;19 never -
+native_fold_deferral native_fold_deferral adaptive: only the optimistic first group deferred at 99% survival 15;16;17;18;19 2026-10-22 columnar_vector.c: the fold never defers, so the payload is always loaded eagerly
+native_fold_deferral native_fold_deferral deferred fold == heap mirror (selective) 15;16;17;18;19 2026-10-22 columnar_vector.c: a deferred group's first survivor is never folded
+native_fold_deferral native_fold_deferral deferred fold == row path (selective) 15;16;17;18;19 2026-10-22 columnar_vector.c: a deferred group's first survivor is never folded
+native_fold_deferral native_fold_deferral deferred: every group deferred at 1% survival 15;16;17;18;19 2026-10-22 columnar_vector.c: the fold never defers, so the payload is always loaded eagerly
+native_fold_deferral native_fold_deferral deferred: loads == survivors x payload width (the identity) 15;16;17;18;19 2026-10-22 columnar_vector.c: a deferred group's first survivor is never folded
+native_fold_deferral native_fold_deferral fold == heap mirror (high survival) 15;16;17;18;19 2026-10-22 columnar_vector.c: a deferred group's first survivor is never folded
+native_fold_deferral native_fold_deferral premise: the predicate has survivors (the identity is not 0 == 0) 15;16;17;18;19 never -
+native_fold_deferral native_fold_deferral premise: the selective predicate is genuinely selective (<= 2%) 15;16;17;18;19 never -
+native_fold_deferral native_fold_deferral the fixture spans several groups 15;16;17;18;19 2026-10-22 columnar_write_state.c: the writer ignores a table's stripe_row_limit
+native_fold_deferral native_fold_deferral the loads counter exists under ANALYZE 15;16;17;18;19 never -
+native_fold_deferral native_fold_deferral the selective query takes the fold (ANALYZE, post-#602) 15;16;17;18;19 2026-10-22 columnar_vector.c: the Columnar Batch Fold ANALYZE line reports no
native_fold_skipguard native_fold_skipguard and reports the 58 it skipped, on its own plan (#542) 15;16;17;18;19 2026-10-22 columnar_reader.c: the fold path stops counting the vectors decode skipped (#542 regression)
native_fold_skipguard native_fold_skipguard and the scalar arm agrees 15;16;17;18;19 never -
native_fold_skipguard native_fold_skipguard so decoded plus skipped is the group's vectors, on the FOLD arm 15;16;17;18;19 2026-10-22 columnar_reader.c: the fold path stops counting the vectors decode skipped (#542 regression)
@@ -4333,6 +4349,105 @@ native_join_vector_agg native_join_vector_agg unique join fold answer equals GUC
native_join_vector_agg native_join_vector_agg unique join fold answer equals heap 15;16;17;18;19 never -
native_join_vector_agg native_join_vector_agg unique join uses core Agg when GUC off 15;16;17;18;19 never -
native_join_vector_agg native_join_vector_agg unique join uses vectorized agg when GUC on 15;16;17;18;19 2026-09-12 drop JOINREL fold
+native_late_materialization native_late_materialization a rejected row does not materialize its payload columns 15;16;17;18;19 2026-10-22 columnar_reader.c: a row the qual rejects is skipped but never counted
+native_late_materialization native_late_materialization a volatile qual is refused the two-pass path 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_late_materialization native_late_materialization and the answers are the same with it off 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar_tuple_insert drops the row whose id is 7
+native_late_materialization native_late_materialization and the volatile query still matches the heap mirror 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar_tuple_insert drops the row whose id is 7
+native_late_materialization native_late_materialization heap agrees on how many rows match 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar_tuple_insert drops the row whose id is 7
+native_late_materialization native_late_materialization nothing was pruned at the group level 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_late_materialization native_late_materialization nothing was pruned at the vector level 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_late_materialization native_late_materialization premise: the fixture loaded every row 15;16;17;18;19 never -
+native_late_materialization native_late_materialization premise: the needle is selective, not most of the table 15;16;17;18;19 never -
+native_late_materialization native_late_materialization the executor still reports the rows the scan filtered 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar_tuple_insert drops the row whose id is 7
+native_late_materialization native_late_materialization the query runs on the columnar custom scan 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_late_materialization native_late_materialization the rows are identical to the heap mirror 15;16;17;18;19 2026-10-22 columnar_reader.c: the late-materialization path rejects a row the qual accepts
+native_late_materialization native_late_materialization the same shape without the volatile term does use it 15;16;17;18;19 2026-10-22 columnar_reader.c: a row the qual rejects is skipped but never counted
+native_late_materialization native_late_materialization with the feature off nothing is filtered early 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_lazy_slot native_lazy_slot SELECT * through the index matches heap 15;16;17;18;19 2026-10-22 columnar_tableam.c: tuple_insert stores c40 off by one in a wide bigint table, so lz_c holds a wrong value
+native_lazy_slot native_lazy_slot a range of rows sums the same as heap 15;16;17;18;19 2026-10-22 columnar_tableam.c: tuple_insert stores c40 off by one in a wide bigint table, so lz_c holds a wrong value
+native_lazy_slot native_lazy_slot a row fetched before it is flushed reads correctly 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar_slot_decode_upto decodes one column fewer than the prefix asked for
+native_lazy_slot native_lazy_slot a unique violation is still detected through the fetch path 15;16;17;18;19 never -
+native_lazy_slot native_lazy_slot a wide table is not in a different class from a narrow one 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar_slot_decode_upto decodes the whole row whatever prefix was asked for
+native_lazy_slot native_lazy_slot an ORDER BY over index-fetched rows matches heap 15;16;17;18;19 2026-10-22 columnar_tableam.c: the index fetch no longer clears the slot, so a new row keeps the previous row's decoded prefix
+native_lazy_slot native_lazy_slot copying index-fetched rows into a heap table keeps every column 15;16;17;18;19 2026-10-22 columnar_tableam.c: tuple_insert stores c40 off by one in a wide bigint table, so lz_c holds a wrong value
+native_lazy_slot native_lazy_slot count(*) through the index matches heap 15;16;17;18;19 never -
+native_lazy_slot native_lazy_slot single-row index fetches match heap for every column position 15;16;17;18;19 2026-10-22 columnar_tableam.c: tuple_insert stores c40 off by one in a wide bigint table, so lz_c holds a wrong value
+native_lazy_slot native_lazy_slot the fetch really is an index scan 15;16;17;18;19 never -
+native_metadata_flush native_metadata_flush a 20-column flush opens a small constant number of metadata relations, not one per row 15;16;17;18;19 2026-10-22 columnar_metadata.c: open_columnar_table never reuses a session's open relation (#445 reverted), so each metadata row opens its catalog again
+native_metadata_flush native_metadata_flush a zone-map-pruned range read matches heap (the batched zone rows are correct) 15;16;17;18;19 never -
+native_metadata_flush native_metadata_flush and that range read rules out a vector by its zone row alone 15;16;17;18;19 2026-10-22 columnar_metadata.c: zone_map rows are written with a NULL minimum and maximum, so no vector can be ruled out by its zone row
+native_metadata_flush native_metadata_flush and the 20-column flush opens at least the three tables every flush writes 15;16;17;18;19 2026-10-22 columnar_metadata.c: the flush session never counts an open, so the witness reads opens=0 for every flush
+native_metadata_flush native_metadata_flush every column reads back correct (sum over all 20 columns matches the heap mirror) 15;16;17;18;19 2026-10-22 columnar_reader.c: PgColumnarEncodeValueByLen stores a 4-byte by-value 1777 as 1778
+native_metadata_flush native_metadata_flush narrow (3 col) and wide (20 col) flush open the same number of metadata relations 15;16;17;18;19 2026-10-22 columnar_metadata.c: open_columnar_table never reuses a session's open relation (#445 reverted), so each metadata row opens its catalog again
+native_metadata_flush native_metadata_flush the flush emits the metadata-open witness 15;16;17;18;19 2026-10-22 columnar_write_state.c: the flush never begins a metadata session, so it neither batches nor emits the witness
+native_metadata_flush native_metadata_flush the wide table's row count survives the batched flush 15;16;17;18;19 never -
+native_page_offset_bound native_page_offset_bound alive after projected overrun 15;16;17;18;19 never -
+native_page_offset_bound native_page_offset_bound alive after projected page_offset=-1 15;16;17;18;19 never -
+native_page_offset_bound native_page_offset_bound alive after whole-group overrun 15;16;17;18;19 2026-10-22 columnar_reader.c: the whole-group read's chunk containment guard removed
+native_page_offset_bound native_page_offset_bound alive after whole-group page_offset=-1 15;16;17;18;19 2026-10-22 columnar_reader.c: the whole-group read's chunk containment guard removed
+native_page_offset_bound native_page_offset_bound alive after whole-group wrap 15;16;17;18;19 2026-10-22 columnar_reader.c: the whole-group read's chunk containment guard removed
+native_page_offset_bound native_page_offset_bound an intact table reads every row and value with projection off 15;16;17;18;19 2026-10-22 columnar_reader.c: both containment guards refuse a chunk ending exactly at its group end
+native_page_offset_bound native_page_offset_bound an intact table's projected read returns every id 15;16;17;18;19 2026-10-22 columnar_reader.c: both containment guards refuse every chunk
+native_page_offset_bound native_page_offset_bound projected page_offset overrun is refused by the containment guard 15;16;17;18;19 2026-10-22 columnar_reader.c: the projected read's chunk containment guard removed
+native_page_offset_bound native_page_offset_bound projected page_offset=-1 hits the containment guard 15;16;17;18;19 2026-10-22 columnar_reader.c: the projected read's chunk containment guard removed
+native_page_offset_bound native_page_offset_bound whole-group page_offset overrun is refused by the containment guard 15;16;17;18;19 2026-10-22 columnar_reader.c: the whole-group read's chunk containment guard removed
+native_page_offset_bound native_page_offset_bound whole-group page_offset=-1 hits the containment guard 15;16;17;18;19 2026-10-22 columnar_reader.c: the whole-group read's chunk containment guard removed
+native_page_offset_bound native_page_offset_bound whole-group page_offset=0 is refused by the containment guard 15;16;17;18;19 2026-10-22 columnar_reader.c: the whole-group read's chunk containment guard removed
+native_param_pushdown native_param_pushdown a correlated PARAM_EXEC builds no skip predicate 15;16;17;18;19 2026-10-22 columnar_customscan.c: a correlated PARAM_EXEC is frozen into a scan key too
+native_param_pushdown native_param_pushdown a correlated PARAM_EXEC is not mis-pruned (results stay correct) 15;16;17;18;19 never -
+native_param_pushdown native_param_pushdown a correlated PARAM_EXEC upper bound is not mis-pruned either 15;16;17;18;19 2026-10-22 columnar_customscan.c: a correlated PARAM_EXEC is frozen into a scan key too
+native_param_pushdown native_param_pushdown a generic-plan parameterized qual prunes chunk groups (19 of 20 removed) 15;16;17;18;19 2026-10-22 columnar_customscan.c: an int4 scan-key operand is frozen 2000 above its value
+native_param_pushdown native_param_pushdown a second execution in the same session counts with its own value 15;16;17;18;19 2026-10-22 columnar_customscan.c: an int4 scan-key operand is frozen 2000 above its value
+native_param_pushdown native_param_pushdown and prunes with its own value too (18 of 20 removed) 15;16;17;18;19 2026-10-22 columnar_customscan.c: an int4 scan-key operand is frozen 2000 above its value
+native_param_pushdown native_param_pushdown backend alive 15;16;17;18;19 never -
+native_param_pushdown native_param_pushdown literal count is the expected value 15;16;17;18;19 never -
+native_param_pushdown native_param_pushdown parameterized count matches the literal count 15;16;17;18;19 2026-10-22 columnar_customscan.c: an int4 scan-key operand is frozen 2000 above its value
+native_param_pushdown native_param_pushdown the correlated arm rescans the columnar scan once per outer row 15;16;17;18;19 never -
+native_parquet_codecs native_parquet_codecs backend survived the crafted header 15;16;17;18;19 2026-10-22 columnar_parquet_codec.c: a page declaring a size past MaxAllocSize aborts instead of being refused
+native_parquet_codecs native_parquet_codecs crafted uncompressed size gives a clean decode error 15;16;17;18;19 2026-10-22 columnar_parquet_codec.c: a page declaring a size past MaxAllocSize aborts instead of being refused
+native_parquet_codecs native_parquet_codecs gzip FDW count 15;16;17;18;19 never -
+native_parquet_codecs native_parquet_codecs gzip decodes to the generator's rows 15;16;17;18;19 2026-10-22 columnar_parquet_codec.c: every decompressed page over 4 KB has one byte flipped after decompression, the same way for every codec
+native_parquet_codecs native_parquet_codecs gzip sum matches the generator 15;16;17;18;19 2026-10-22 columnar_parquet_codec.c: every decompressed page over 4 KB has one byte flipped after decompression, the same way for every codec
+native_parquet_codecs native_parquet_codecs lz4 FDW count 15;16;17;18;19 never -
+native_parquet_codecs native_parquet_codecs lz4 decodes to the generator's rows 15;16;17;18;19 2026-10-22 columnar_parquet_codec.c: every decompressed page over 4 KB has one byte flipped after decompression, the same way for every codec
+native_parquet_codecs native_parquet_codecs premise: the gzip file is GZIP-compressed 15;16;17;18;19 never -
+native_parquet_codecs native_parquet_codecs premise: the lz4 file is LZ4-compressed 15;16;17;18;19 never -
+native_parquet_codecs native_parquet_codecs premise: the uncompressed file is UNCOMPRESSED 15;16;17;18;19 never -
+native_parquet_codecs native_parquet_codecs premise: the zstd file is ZSTD-compressed 15;16;17;18;19 never -
+native_parquet_codecs native_parquet_codecs uncompressed decodes to the generator's rows 15;16;17;18;19 2026-10-22 columnar_parquet_codec.c: every decompressed page over 4 KB has one byte flipped after decompression, the same way for every codec
+native_parquet_codecs native_parquet_codecs zstd FDW count 15;16;17;18;19 never -
+native_parquet_codecs native_parquet_codecs zstd decodes to the generator's rows 15;16;17;18;19 2026-10-22 columnar_parquet_codec.c: every decompressed page over 4 KB has one byte flipped after decompression, the same way for every codec
+native_parquet_fdw native_parquet_fdw EXPLAIN shows Foreign Scan 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw a one-column query reads one of the eleven columns 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: projection pushdown ignored, every column decoded
+native_parquet_fdw native_parquet_fdw a selective predicate skips the three row groups it cannot match 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: row-group skipping never skips a group
+native_parquet_fdw native_parquet_fdw and the skipped scan still returns every matching row 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_skip moves each group's skip decision to the next group, skipping the one that matches
+native_parquet_fdw native_parquet_fdw backend still up after the refusals 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the scan's missing-path refusal removed (the backend crashes)
+native_parquet_fdw native_parquet_fdw fdw row count 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw full scan: fdw == source 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw nested export rows 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw nested full scan: fdw == source 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw nested-loop self-join (rescan): fdw == source 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: every row group skipped once any qual is pushable
+native_parquet_fdw native_parquet_fdw predicate: fdw == source 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: every row group skipped once any qual is pushable
+native_parquet_fdw native_parquet_fdw projection: fdw == source 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw reject empty path option (HV00D) 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw reject invalid table option (HV00D) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the option validator accepts an unknown option
+native_parquet_fdw native_parquet_fdw scalar export rows 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw scan of non-parquet file errors (XX001) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the short-file refusal removed, so the bad-magic guard refuses instead
+native_parquet_fdw native_parquet_fdw scan with incompatible declared type errors (42804) 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw scan without path option errors (HV00J) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the scan's missing-path refusal removed (the backend crashes)
+native_parquet_fdw native_parquet_fdw skip export rows 15;16;17;18;19 never -
+native_parquet_fdw native_parquet_fdw the skip file has four row groups 15;16;17;18;19 2026-10-22 columnar_parquet.c: the exporter writes 131072-row groups instead of 65536
+native_parquet_fieldid native_parquet_fieldid a duplicate field id in the file is refused (42702) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a requested field id binds the column whose id is one more
+native_parquet_fieldid native_parquet_fieldid a duplicate requested field id is refused (22023) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a requested field id binds the column whose id is one more
+native_parquet_fieldid native_parquet_fieldid a field_ids/column-count mismatch is refused (22023) 15;16;17;18;19 never -
+native_parquet_fieldid native_parquet_fieldid a file with no field ids is refused (22023) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the 'file carries no field ids' guard removed, so an id-less file falls to the absent-id refusal
+native_parquet_fieldid native_parquet_fieldid a nested/array output column is refused (0A000) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the scalar-only guard on field-id projection removed
+native_parquet_fieldid native_parquet_fieldid an absent field id is refused (22023) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the absent-field-id refusal becomes abort()
+native_parquet_fieldid native_parquet_fieldid backend still up after the refusals 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the absent-field-id refusal becomes abort()
+native_parquet_fieldid native_parquet_fieldid field-id projection binds and reorders by id (g=gamma, a=alpha) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a requested field id binds the column whose id is one more
+native_parquet_fieldid native_parquet_fieldid field-id projection reads a subset (only id 3 = beta) 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a requested field id binds the column whose id is one more
+native_parquet_fieldid native_parquet_fieldid positional read_parquet is unaffected 15;16;17;18;19 never -
+native_parquet_fieldid native_parquet_fieldid the duplicate-requested-id error names the id, not the file 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a requested field id binds the column whose id is one more
native_parquet_flba native_parquet_flba FDW scan == source 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a FIXED_LEN_BYTE_ARRAY uuid's bytes are reversed on read
native_parquet_flba native_parquet_flba INT32-backed DECIMAL keeps its null 15;16;17;18;19 never -
native_parquet_flba native_parquet_flba INT32-backed DECIMAL reads 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: an INT32 or INT64 DECIMAL is widened byte-reversed;columnar_parquet_reader.c: pq_decimal_to_numeric reads a narrower-than-16-byte negative DECIMAL as its unsigned magnitude
@@ -4348,6 +4463,24 @@ native_parquet_flba native_parquet_flba pyarrow decimal128 values are exact 15;1
native_parquet_flba native_parquet_flba pyarrow uuid reads as uuid 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a FIXED_LEN_BYTE_ARRAY uuid's bytes are reversed on read
native_parquet_flba native_parquet_flba uuid round-trips through export/read 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a FIXED_LEN_BYTE_ARRAY uuid's bytes are reversed on read
native_parquet_flba native_parquet_flba whole-row round-trip == source 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a FIXED_LEN_BYTE_ARRAY uuid's bytes are reversed on read
+native_parquet_hardening native_parquet_hardening backend survived short chunk list 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the chunk-count refusal becomes abort()
+native_parquet_hardening native_parquet_hardening date in range still reads 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: an in-range date decodes one day late
+native_parquet_hardening native_parquet_hardening date invalid -> 22008 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the date validity check removed (the offset-overflow branch kept)
+native_parquet_hardening native_parquet_hardening date offset overflow -> 22008 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the whole date guard removed, so the epoch offset wraps
+native_parquet_hardening native_parquet_hardening import_parquet also raises 22003 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the int2 range guard removed, so 40000 wraps to -25536
+native_parquet_hardening native_parquet_hardening import_parquet left no wrapped rows 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the int2 range guard removed, so 40000 wraps to -25536
+native_parquet_hardening native_parquet_hardening int2 in range still reads 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the int2 guard over-applied, refusing the in-range 30000
+native_parquet_hardening native_parquet_hardening int2 out of range -> 22003 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the int2 range guard removed, so 40000 wraps to -25536
+native_parquet_hardening native_parquet_hardening no wrapped -25536 appears 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the int2 range guard removed, so 40000 wraps to -25536
+native_parquet_hardening native_parquet_hardening parquet_schema still describes a bad chunk list 15;16;17;18;19 never -
+native_parquet_hardening native_parquet_hardening read_parquet also raises 22003 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the int2 range guard removed, so 40000 wraps to -25536
+native_parquet_hardening native_parquet_hardening read_parquet in range still reads 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the int2 guard over-applied, refusing the in-range 30000
+native_parquet_hardening native_parquet_hardening short chunk list rejected with a specific error 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the row-group chunk-count guard removed
+native_parquet_hardening native_parquet_hardening time in range still reads 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: an in-range INT64 time decodes one microsecond late
+native_parquet_hardening native_parquet_hardening time out of day -> 22008 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the INT64 time range guard removed
+native_parquet_hardening native_parquet_hardening timestamp in range still reads 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: an in-range timestamp decodes one microsecond late
+native_parquet_hardening native_parquet_hardening timestamp overflow -> 22008 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the timestamp guard removed, so the epoch offset wraps
+native_parquet_hardening native_parquet_hardening valid two-column file still reads 15;16;17;18;19 never -
native_parquet_multifile native_parquet_multifile EXPLAIN reports 3 files 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a walked entry is read whatever its extension
native_parquet_multifile native_parquet_multifile EXPLAIN sums row groups across files 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a walked entry is read whatever its extension
native_parquet_multifile native_parquet_multifile FDW(directory) == oracle 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a walked entry is read whatever its extension
@@ -4380,6 +4513,58 @@ native_parquet_multifile native_parquet_multifile predicate skips the two non-ma
native_parquet_multifile native_parquet_multifile read_parquet(directory) == oracle 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a walked entry is read whatever its extension
native_parquet_multifile native_parquet_multifile schema-mismatched file in directory errors 15;16;17;18;19 never -
native_parquet_multifile native_parquet_multifile tree read order follows the sorted full path 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: a walked directory's files are not sorted;columnar_parquet_reader.c: a walked entry is read whatever its extension
+native_parquet_partition native_parquet_partition IN on a partition column prunes 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition __HIVE_DEFAULT_PARTITION__ reads as null 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition a byte invalid in the server encoding is rejected 15;16;17;18;19 never -
+native_parquet_partition native_parquet_partition a file missing a declared partition component errors 15;16;17;18;19 never -
+native_parquet_partition native_parquet_partition a file whose basename looks like a partition component is unaffected 15;16;17;18;19 never -
+native_parquet_partition native_parquet_partition a null partition value is not the literal marker 15;16;17;18;19 never -
+native_parquet_partition native_parquet_partition a null partition value prunes 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition a percent-encoded partition value decodes 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition a predicate matching everything prunes nothing 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition a predicate on a file column prunes no files 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition a pruned file's row groups are never counted 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition a stable clause still prunes 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition a volatile clause keeps every matching partition row 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition a volatile clause prunes nothing 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the volatile-clause guard on partition pruning removed
+native_parquet_partition native_parquet_partition all four partitions read 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition all three partitions still read 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition an empty partition_columns option is rejected 15;16;17;18;19 never -
+native_parquet_partition native_parquet_partition an encoded null byte in a partition value is rejected 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the explicit encoded-NUL guard removed, so the encoding check's 22021 stands in
+native_parquet_partition native_parquet_partition an inequality on a partition column prunes 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition an unencoded value is unaffected 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition an unknown partition column errors rather than being ignored 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the unknown-partition-column guard removed, so a later column-count error stands in
+native_parquet_partition native_parquet_partition and reads the other half 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition backend survived the bad declarations 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the unknown-partition-column refusal becomes abort()
+native_parquet_partition native_parquet_partition backend survived the malformed partition values 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the unknown-partition-column refusal becomes abort()
+native_parquet_partition native_parquet_partition both columns prune down to one file 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition declaring a file column as a partition column errors 15;16;17;18;19 never -
+native_parquet_partition native_parquet_partition group by a partition column 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition one partition value prunes half the files 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition partition column is typed, not text 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition partition prune plus row-group skip 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition partition values materialize 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition pruned scan still returns the right rows 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition row groups are still skipped inside the surviving files 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning never drops a file
+native_parquet_partition native_parquet_partition the encoded value is queryable as itself 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_partition native_parquet_partition the tree reads again once both are gone 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: partition pruning drops the files that match instead of those that do not
+native_parquet_projection native_parquet_projection after a dropped column, one column reads 1 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_needed matches the needed set by target index, not by attribute number
+native_parquet_projection native_parquet_projection after a dropped column, projection [d] == oracle 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_needed matches the needed set by target index, not by attribute number
+native_parquet_projection native_parquet_projection count(*) decodes nothing, survives corruption 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_needed marks every column needed, so projection decodes all
+native_parquet_projection native_parquet_projection count(*) reads 0 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_needed marks every column needed, so projection decodes all
+native_parquet_projection native_parquet_projection good-column values are still correct 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_needed marks every column needed, so projection decodes all
+native_parquet_projection native_parquet_projection one column reads 1 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_needed marks every column needed, so projection decodes all
+native_parquet_projection native_parquet_projection projection [*] == oracle 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a BYTE_ARRAY text longer than 2 bytes decodes without its last byte (shared with read_parquet)
+native_parquet_projection native_parquet_projection projection [a, d] == oracle 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the last target column is never decoded, even when referenced
+native_parquet_projection native_parquet_projection projection [a] == oracle 15;16;17;18;19 never -
+native_parquet_projection native_parquet_projection projection [b, c, d] == oracle 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a BYTE_ARRAY text longer than 2 bytes decodes without its last byte (shared with read_parquet)
+native_parquet_projection native_parquet_projection projection [c] == oracle 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a BYTE_ARRAY text longer than 2 bytes decodes without its last byte (shared with read_parquet)
+native_parquet_projection native_parquet_projection qual on unselected column == oracle 15;16;17;18;19 never -
+native_parquet_projection native_parquet_projection select star reads 4 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the last target column is never decoded, even when referenced
+native_parquet_projection native_parquet_projection selected 1 + qual 1 reads 2 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_needed marks every column needed, so projection decodes all
+native_parquet_projection native_parquet_projection selecting the corrupt column does error 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the last target column is never decoded, even when referenced
+native_parquet_projection native_parquet_projection selecting the good column skips the corrupt one 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pqfdw_compute_needed marks every column needed, so projection decodes all
+native_parquet_projection native_parquet_projection two columns read 2 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the last target column is never decoded, even when referenced
native_parquet_pushdown native_parquet_pushdown INT64-backed DECIMAL: predicate skips 3 of 4 groups 15;16;17;18;19 2026-10-16 columnar_parquet_reader.c: INT64 statistics are not decoded, so an INT64-backed DECIMAL never skips;columnar_parquet_reader.c: pqfdw_compute_skip never marks a row group skippable
native_parquet_pushdown native_parquet_pushdown INT64-backed DECIMAL: skipping did not drop rows 15;16;17;18;19 never -
native_parquet_pushdown native_parquet_pushdown INT64-backed DECIMAL: unfiltered scan skips nothing 15;16;17;18;19 never -
@@ -4462,6 +4647,69 @@ native_parquet_schema native_parquet_schema type of c_text 15;16;17;18;19 never
native_parquet_schema native_parquet_schema type of c_time 15;16;17;18;19 never -
native_parquet_schema native_parquet_schema type of c_ts 15;16;17;18;19 never -
native_parquet_schema native_parquet_schema type of c_uuid 15;16;17;18;19 never -
+native_parquet_stack native_parquet_stack a shallow valid schema chain still reads 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: walk_schema's guard trips on any nesting, not only on stack exhaustion
+native_parquet_stack native_parquet_stack backend survived read_parquet on the crash input 15;16;17;18;19 2026-10-22 columnar_thrift.c: PgColumnarThriftSkip calls abort() where its stack guard would raise
+native_parquet_stack native_parquet_stack backend survived the nested-struct footer 15;16;17;18;19 2026-10-22 columnar_thrift.c: PgColumnarThriftSkip calls abort() where its stack guard would raise
+native_parquet_stack native_parquet_stack backend survived the schema-chain footer 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: walk_schema calls abort() where its stack guard would raise
+native_parquet_stack native_parquet_stack backend survived the valid file 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: walk_schema calls abort() on entry, so any footer that parses crashes the backend
+native_parquet_stack native_parquet_stack nested-struct footer raises stack-depth, not SIGSEGV 15;16;17;18;19 2026-10-22 columnar_thrift.c: PgColumnarThriftSkip's check_stack_depth() removed, so a nested-struct footer is not bounded
+native_parquet_stack native_parquet_stack read_parquet rejects the nested-struct footer the same way 15;16;17;18;19 2026-10-22 columnar_thrift.c: PgColumnarThriftSkip's check_stack_depth() removed, so a nested-struct footer is not bounded
+native_parquet_stack native_parquet_stack schema-chain footer raises stack-depth, not SIGSEGV 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: walk_schema's check_stack_depth() removed, so a schema chain is not bounded
+native_parquet_streaming native_parquet_streaming a second dictionary page in a chunk is rejected 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the one-dictionary-page-per-chunk guard removed
+native_parquet_streaming native_parquet_streaming all-null column (empty dictionary page) still reads 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming backend survived the crafted page areas 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the second-dictionary-page refusal becomes abort()
+native_parquet_streaming native_parquet_streaming backend survived the failed read 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming negative page offset is rejected by the offset check 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_read_page_header no longer refuses a negative page offset
+native_parquet_streaming native_parquet_streaming page size past end of file is rejected by the size bound 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: compressed_size no longer bounded by what is left of the file
+native_parquet_streaming native_parquet_streaming parquet_schema reads a file over the 1GB palloc ceiling 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming parquet_schema reports the columns of the oversized file 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming parquet_schema still reads a normal file 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming read_parquet still reads a normal file 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming streaming: LIMIT 10 (inside group 0) decodes one row group 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming streaming: LIMIT 1010 (into group 1) decodes two row groups 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming streaming: a full scan decodes all four row groups 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming streaming: full scan content is correct 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming streaming: rescan under a nested loop is correct 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming the uncompressed source file still reads 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming unparseable page header is rejected 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming v2 level lengths past the page are rejected 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming value read of the sparse file fails cleanly 15;16;17;18;19 never -
+native_parquet_streaming native_parquet_streaming zero-filled page area is rejected, not walked byte by byte 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the progress guard for a data page with no values removed
+native_parquet_units native_parquet_units import_parquet honours the unit 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units millis == micros 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units millis pushdown result == oracle 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units millis-scaled stats skip the non-matching group 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units millis-to-micros overflow raises 22008 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units nanos == micros 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads NANOS as micros (no /1000)
+native_parquet_units native_parquet_units nanos read as bigint is exact 15;16;17;18;19 never -
+native_parquet_units native_parquet_units nanos sub-us truncates toward zero [frac] 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads NANOS as micros (no /1000)
+native_parquet_units native_parquet_units nanos sub-us truncates toward zero [neg] 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads NANOS as micros (no /1000)
+native_parquet_units native_parquet_units parquet_schema advises bigint for nanos (lossless) 15;16;17;18;19 never -
+native_parquet_units native_parquet_units parquet_schema advises time for TIME_MILLIS 15;16;17;18;19 never -
+native_parquet_units native_parquet_units parquet_schema advises timestamp for micros 15;16;17;18;19 never -
+native_parquet_units native_parquet_units parquet_schema advises timestamp for millis 15;16;17;18;19 never -
+native_parquet_units native_parquet_units read_parquet honours the unit 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units the millis file reads back exactly as written 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units time micros decodes to the right time 15;16;17;18;19 never -
+native_parquet_units native_parquet_units time millis decodes to the right time 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units ts_ms decodes to the right instant 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads MILLIS as micros (no x1000)
+native_parquet_units native_parquet_units ts_ns decodes to the right instant 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: pq_scale_to_usecs reads NANOS as micros (no /1000)
+native_parquet_units native_parquet_units ts_us decodes to the right instant 15;16;17;18;19 never -
+native_read_parquet native_read_parquet a role without pg_read_server_files is refused by read_parquet's own gate 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: read_parquet's own pg_read_server_files gate removed
+native_read_parquet native_read_parquet and a member of pg_read_server_files reads every row 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a scalar's definition level ignored, every entry read as present
+native_read_parquet native_read_parquet backend still up after the refusals 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a scalar's definition level ignored, every entry read as present
+native_read_parquet native_read_parquet import parity rows 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a scalar's definition level ignored, every entry read as present
+native_read_parquet native_read_parquet nested export rows 15;16;17;18;19 never -
+native_read_parquet native_read_parquet nested round-trip: read_parquet == the generated rows 15;16;17;18;19 2026-10-22 columnar_reader.c: the columnar reader returns int4 777 as 778 (store_att_byval), so the source table and the file agree on the wrong value
+native_read_parquet native_read_parquet premise: reject missing column definition list 15;16;17;18;19 never -
+native_read_parquet native_read_parquet projected column round-trips 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a scalar's definition level ignored, every entry read as present
+native_read_parquet native_read_parquet read_parquet == import_parquet 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a scalar's definition level ignored, every entry read as present
+native_read_parquet native_read_parquet reject fewer columns than file 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the leaf-count guard removed
+native_read_parquet native_read_parquet reject incompatible declared type 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: the scalar physical-type guard removed
+native_read_parquet native_read_parquet reject non-parquet file 15;16;17;18;19 never -
+native_read_parquet native_read_parquet scalar export rows 15;16;17;18;19 never -
+native_read_parquet native_read_parquet scalar read_parquet row count 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a scalar's definition level ignored, every entry read as present
+native_read_parquet native_read_parquet scalar round-trip: read_parquet == the generated rows 15;16;17;18;19 2026-10-22 columnar_reader.c: the columnar reader returns int4 777 as 778 (store_att_byval), so the source table and the file agree on the wrong value
native_reclaim native_reclaim and every live row is still there, once 15;16;17;18;19 never -
native_reclaim native_reclaim compact_rewrite ACCEPTS the boundary threshold 0.0 15;16;17;18;19 2026-10-16 columnar_vacuum.c: compact_rewrite's guard refuses minFrac <= 0.0
native_reclaim native_reclaim compact_rewrite ACCEPTS the boundary threshold 1.0 15;16;17;18;19 2026-10-16 columnar_vacuum.c: compact_rewrite's guard refuses minFrac >= 1.0
@@ -4499,6 +4747,19 @@ native_reclaim native_reclaim repeated recluster reuses space (file plateaus) 15
native_reclaim native_reclaim reused-block data matches heap mirror 15;16;17;18;19 never -
native_reclaim native_reclaim row count correct after cycle 15;16;17;18;19 never -
native_reclaim native_reclaim ten row groups, every one half deleted 15;16;17;18;19 2026-10-14 the write state ignores stripe_row_limit
+native_reclaim_reconcile native_reclaim_reconcile compact_rewrite reconciled the stale row 15;16;17;18;19 2026-10-22 columnar_metadata.c: PgColumnarReconcileFreeList never drops a free row, overlapping or not
+native_reclaim_reconcile native_reclaim_reconcile compact_rewrite recorded the free ranges it left 15;16;17;18;19 2026-10-22 columnar_metadata.c: record_free_space drops its insert, so every freed range leaks
+native_reclaim_reconcile native_reclaim_reconcile data intact after compact reconcile 15;16;17;18;19 never -
+native_reclaim_reconcile native_reclaim_reconcile data intact after reconcile + rewrite 15;16;17;18;19 never -
+native_reclaim_reconcile native_reclaim_reconcile data intact after truncate reconcile 15;16;17;18;19 never -
+native_reclaim_reconcile native_reclaim_reconcile parity after reuse cycle 1 15;16;17;18;19 never -
+native_reclaim_reconcile native_reclaim_reconcile parity after reuse cycle 2 15;16;17;18;19 2026-10-22 columnar_metadata.c: PgColumnarReconcileFreeList never drops a free row, overlapping or not
+native_reclaim_reconcile native_reclaim_reconcile parity before 15;16;17;18;19 never -
+native_reclaim_reconcile native_reclaim_reconcile plain compact kept the legitimate free ranges, which overlap no live group 15;16;17;18;19 2026-10-22 columnar_metadata.c: PgColumnarReconcileFreeList drops every free row, not only those overlapping a live group
+native_reclaim_reconcile native_reclaim_reconcile plain compact reconciled the stale row 15;16;17;18;19 2026-10-22 columnar_metadata.c: PgColumnarReconcileFreeList never drops a free row, overlapping or not
+native_reclaim_reconcile native_reclaim_reconcile row count intact 15;16;17;18;19 never -
+native_reclaim_reconcile native_reclaim_reconcile stale overlapping free row injected 15;16;17;18;19 never -
+native_reclaim_reconcile native_reclaim_reconcile truncate reconciled the stale row 15;16;17;18;19 2026-10-22 columnar_metadata.c: PgColumnarReconcileFreeList never drops a free row, overlapping or not
native_recluster native_recluster VACUUM set the visibility map, so the index-only count answers from the index before the recluster 15;16;17;18;19 never -
native_recluster native_recluster box parity (pre-recluster) 15;16;17;18;19 never -
native_recluster native_recluster full-table parity after recluster 15;16;17;18;19 2026-10-16 columnar_vacuum.c: recluster_online retires none of the old groups (PgColumnarRetireGroup removed)
@@ -4535,6 +4796,37 @@ native_repack native_repack the columnar table is unharmed: content hash 19 2026
native_repack native_repack the columnar table is unharmed: row count 19 2026-10-16 columnar_tableam.c: relation_copy_for_cluster returns without raising, so a rewrite copies nothing
native_repack native_repack the columnar table is unharmed: storage options 19 never -
native_repack native_repack without an identity index it is refused by PostgreSQL, not by us 19 never -
+native_rewrite native_rewrite a group deleted below the threshold is not rewritten 15;16;17;18;19 2026-10-22 columnar_vacuum.c: every group with a live row is a rewrite candidate, deleted or not
+native_rewrite native_rewrite a rewrite plus later writes reuse no group number 15;16;17;18;19 never -
+native_rewrite native_rewrite a second rewrite with nothing deleted rewrites no group 15;16;17;18;19 2026-10-22 columnar_vacuum.c: every group with a live row is a rewrite candidate, deleted or not
+native_rewrite native_rewrite an index range read after vacuum returns the rows asked for 15;16;17;18;19 2026-10-22 columnar_vacuum.c: rewrite_one_group inserts no index entries for the rows it moves
+native_rewrite native_rewrite compact_rewrite holds ShareUpdateExclusiveLock 15;16;17;18;19 never -
+native_rewrite native_rewrite compact_rewrite holds no AccessExclusiveLock 15;16;17;18;19 never -
+native_rewrite native_rewrite dead-row space reclaimed 15;16;17;18;19 never -
+native_rewrite native_rewrite deleted id absent via index 15;16;17;18;19 never -
+native_rewrite native_rewrite every live row still reads back after rewrite and vacuum 15;16;17;18;19 2026-10-22 columnar_vacuum.c: rewrite_one_group drops the first live row of each group it rewrites
+native_rewrite native_rewrite group numbers issued after a rewrite are past every earlier one 15;16;17;18;19 never -
+native_rewrite native_rewrite index scan returns each live row once 15;16;17;18;19 2026-10-22 columnar_vacuum.c: rewrite_one_group drops the first live row of each group it rewrites
+native_rewrite native_rewrite live id present via index 15;16;17;18;19 2026-10-22 columnar_vacuum.c: rewrite_one_group drops the first live row of each group it rewrites
+native_rewrite native_rewrite parity after delete 15;16;17;18;19 never -
+native_rewrite native_rewrite parity after rewrite 15;16;17;18;19 2026-10-22 columnar_vacuum.c: rewrite_one_group drops the first live row of each group it rewrites
+native_rewrite native_rewrite rewrote some groups 15;16;17;18;19 never -
+native_rewrite native_rewrite row count after delete 15;16;17;18;19 never -
+native_rewrite native_rewrite row count after rewrite 15;16;17;18;19 2026-10-22 columnar_vacuum.c: rewrite_one_group drops the first live row of each group it rewrites
+native_rewrite native_rewrite there are deleted rows to reclaim 15;16;17;18;19 never -
+native_rewrite native_rewrite unique constraint still enforced 15;16;17;18;19 2026-10-22 columnar_vacuum.c: rewrite_one_group drops the first live row of each group it rewrites
+native_rewrite native_rewrite vacuum rewrites into a fresh storage id 15;16;17;18;19 never -
+native_rewrite_conc native_rewrite_conc a compact_rewrite holds its lock on t_col mid-verb 15;16;17;18;19 2026-10-22 columnar_vacuum.c: compact_rewrite no longer honours pgcolumnar.maintenance_hold_ms
+native_rewrite_conc native_rewrite_conc a reader with a 3 s lock_timeout finishes while the rewrite holds its lock 15;16;17;18;19 2026-10-22 columnar_vacuum.c: compact_rewrite opens the table with AccessExclusiveLock instead of ShareUpdateExclusiveLock
+native_rewrite_conc native_rewrite_conc a writer with a 3 s lock_timeout inserts while the rewrite holds its lock 15;16;17;18;19 2026-10-22 columnar_vacuum.c: compact_rewrite opens the table with AccessExclusiveLock instead of ShareUpdateExclusiveLock
+native_rewrite_conc native_rewrite_conc all workers succeeded (retried past serialization failures) 15;16;17;18;19 2026-10-22 columnar_metadata.c: every delete flush is refused as a concurrent compaction (over-apply)
+native_rewrite_conc native_rewrite_conc and the rewrite still held its lock when both had finished 15;16;17;18;19 2026-10-22 columnar_vacuum.c: compact_rewrite no longer honours pgcolumnar.maintenance_hold_ms
+native_rewrite_conc native_rewrite_conc conc-rewrite count/sum 15;16;17;18;19 2026-10-22 columnar_metadata.c: the F3b conflict check never raises, so a delete racing a committed rewrite lands on the retired group
+native_rewrite_conc native_rewrite_conc conc-rewrite range 15;16;17;18;19 2026-10-22 columnar_metadata.c: the F3b conflict check never raises, so a delete racing a committed rewrite lands on the retired group
+native_rewrite_conc native_rewrite_conc conc-rewrite whole-row 15;16;17;18;19 2026-10-22 columnar_metadata.c: the F3b conflict check never raises, so a delete racing a committed rewrite lands on the retired group
+native_rewrite_conc native_rewrite_conc row count sane 15;16;17;18;19 never -
+native_rewrite_conc native_rewrite_conc the compactor rewrote groups while the workers were writing 15;16;17;18;19 2026-10-22 columnar_metadata.c: every delete flush is refused as a concurrent compaction (over-apply)
+native_rewrite_conc native_rewrite_conc the held rewrite itself completed 15;16;17;18;19 never -
native_roundtrip native_roundtrip native aggregate (sum over a scanned column) 15;16;17;18;19 2026-10-17 columnar_reader.c: the native reader never loads the last row group
native_roundtrip native_roundtrip native count with filter 15;16;17;18;19 2026-10-17 columnar_reader.c: the native reader never loads the last row group
native_roundtrip native_roundtrip native filtered scan 15;16;17;18;19 2026-10-17 columnar_reader.c: a null by-reference column reads back as a present empty value;columnar_reader.c: zone-map pruning skips the groups that can match and keeps the ones that cannot
@@ -4597,6 +4889,29 @@ native_saop_pushdown native_saop_pushdown the predicate builder's reject express
native_saop_pushdown native_saop_pushdown the predicate builder's reject expression contains SK_SEARCHNULL 15;16;17;18;19 2026-10-16 columnar_reader.c: the reject expression keeps only SK_ISNULL
native_saop_pushdown native_saop_pushdown vector-agg count over an IN-list is exact (conservative keys not folded) 15;16;17;18;19 2026-10-16 columnar_reader.c: a set key never probes the bloom filter (it probes zero values and rules the group out)
native_saop_pushdown native_saop_pushdown vector-agg path engaged for the guard arm (plan shape) 15;16;17;18;19 never -
+native_sort_by native_sort_by backend still up after the refusals 15;16;17;18;19 2026-10-22 columnar_vacuum.c: vacuum_sorted with no key declared aborts the backend in place of its refusal
+native_sort_by native_sort_by declared sort_by round-trips 15;16;17;18;19 never -
+native_sort_by native_sort_by empty table sorts without error 15;16;17;18;19 2026-10-22 columnar_vacuum.c: the sorted rewrite orders each key descending
+native_sort_by native_sort_by equality parity (post-sort) 15;16;17;18;19 never -
+native_sort_by native_sort_by equality parity (pre-sort) 15;16;17;18;19 never -
+native_sort_by native_sort_by explicit columns lay the rows out as the declared key does 15;16;17;18;19 2026-10-22 columnar_vacuum.c: the declared key loses its first column, so the rewrite sorts by ts alone
+native_sort_by native_sort_by explicit-column sort parity 15;16;17;18;19 never -
+native_sort_by native_sort_by full-table parity after sort 15;16;17;18;19 never -
+native_sort_by native_sort_by premise: before sorting, the scan order does not follow (host, ts) 15;16;17;18;19 never -
+native_sort_by native_sort_by row count 15;16;17;18;19 never -
+native_sort_by native_sort_by row count after sort 15;16;17;18;19 never -
+native_sort_by native_sort_by set_options rejects a nonexistent sort_by column 15;16;17;18;19 never -
+native_sort_by native_sort_by set_options rejects a virtual generated sort_by column 18;19 never -
+native_sort_by native_sort_by single-row parity 15;16;17;18;19 never -
+native_sort_by native_sort_by single-row table sorts without error 15;16;17;18;19 2026-10-22 columnar_vacuum.c: the sorted rewrite orders each key descending
+native_sort_by native_sort_by sort_by cleared 15;16;17;18;19 never -
+native_sort_by native_sort_by sorting a text key enables group skipping 15;16;17;18;19 2026-10-22 columnar_vacuum.c: vacuum_sorted rewrites in arrival order (no sort keys)
+native_sort_by native_sort_by the plan under test is a columnar custom scan 15;16;17;18;19 never -
+native_sort_by native_sort_by the scan order follows the declared key (host, ts) 15;16;17;18;19 2026-10-22 columnar_vacuum.c: the sorted rewrite applies only the first sort key (host), leaving ts unordered
+native_sort_by native_sort_by vacuum_sorted rejects an explicit virtual generated column 18;19 2026-10-22 columnar_vacuum.c: the virtual-column refusal aborts the backend in place of its error (PG18+)
+native_sort_by native_sort_by virtual generated sort keys 15;16;17 never -
+native_sort_by native_sort_by zero-arg errors on a dropped declared column 15;16;17;18;19 2026-10-22 columnar_vacuum.c: a dropped declared sort_by column gets the generic column-does-not-exist message
+native_sort_by native_sort_by zero-arg errors when no key declared 15;16;17;18;19 2026-10-22 columnar_vacuum.c: vacuum_sorted with no key declared aborts the backend in place of its refusal
native_toasted_write native_toasted_write equality on a toasted value (bloom + recheck) matches heap 15;16;17;18;19 2026-10-17 columnar_write_state.c: the bloom hash of a varlena is taken from a fixed value, not the row's flattened one;columnar_write_state.c: the running maximum is never raised past a chunk's first value
native_toasted_write native_toasted_write premise: each range selects some rows and not all 15;16;17;18;19 never -
native_toasted_write native_toasted_write premise: the source values are toasted (pglz) 15;16;17;18;19 never -
@@ -4634,6 +4949,34 @@ native_varlena_bound native_varlena_bound backend survived the corrupt minimum 1
native_varlena_bound native_varlena_bound backend survived the external-tagged minimum 15;16;17;18;19 never -
native_varlena_bound native_varlena_bound the txt column has a whole-chunk zone map to corrupt 15;16;17;18;19 never -
native_varlena_bound native_varlena_bound with the minimum restored, the range read is clean 15;16;17;18;19 never -
+native_vecdecode native_vecdecode EXPLAIN reports Columnar Vectors Decoded 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_vecdecode native_vecdecode a nullable aggregate reaches the fold 15;16;17;18;19 2026-10-22 columnar_vector.c: pgcolumnar_batch_shape_eligible refuses every shape, so no aggregate reaches the batch fold
+native_vecdecode native_vecdecode a range matching no row at all is still correct 15;16;17;18;19 never -
+native_vecdecode native_vecdecode a ruled-out vector is not decoded 15;16;17;18;19 2026-10-22 columnar_reader.c: the zone maps never build a skip mask, so no vector is ruled out
+native_vecdecode native_vecdecode aggregate over a range spanning many skipped vectors matches heap 15;16;17;18;19 never -
+native_vecdecode native_vecdecode aggregate over a skipping range matches heap 15;16;17;18;19 never -
+native_vecdecode native_vecdecode and EVERY ruled-out vector is: decoded plus skipped is the group's vectors 15;16;17;18;19 2026-10-22 columnar_reader.c: decode ignores the skip mask and decodes every vector
+native_vecdecode native_vecdecode and it agrees with heap after many leading skipped vectors 15;16;17;18;19 never -
+native_vecdecode native_vecdecode and it is not stuck at zero when a full scan decodes everything 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_vecdecode native_vecdecode boundary range parity, a range starting exactly on a vector edge 15;16;17;18;19 never -
+native_vecdecode native_vecdecode control: and the same counter reads every vector when nothing is skipped 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_vecdecode native_vecdecode cross-vector parity of a nullable column, nulls included 15;16;17;18;19 never -
+native_vecdecode native_vecdecode cross-vector range parity 15;16;17;18;19 never -
+native_vecdecode native_vecdecode narrow range parity with heap 15;16;17;18;19 never -
+native_vecdecode native_vecdecode no whole group is pruned on the narrow query 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_vecdecode native_vecdecode one row group, so every difference is per-vector 15;16;17;18;19 2026-10-22 columnar_write_state.c: the writer divides the table's stripe_row_limit by 100
+native_vecdecode native_vecdecode premise: the nullable column has nulls inside the ranges read below 15;16;17;18;19 never -
+native_vecdecode native_vecdecode the FOLD ARM really decodes only the vectors it needs 15;16;17;18;19 2026-10-22 columnar_reader.c: the zone maps never build a skip mask, so no vector is ruled out
+native_vecdecode native_vecdecode the aggregate really reaches the batch fold 15;16;17;18;19 2026-10-22 columnar_vector.c: pgcolumnar_batch_shape_eligible refuses every shape, so no aggregate reaches the batch fold
+native_vecdecode native_vecdecode the deep range reaches the fold too 15;16;17;18;19 2026-10-22 columnar_vector.c: pgcolumnar_batch_shape_eligible refuses every shape, so no aggregate reaches the batch fold
+native_vecdecode native_vecdecode the fold answers correctly over a range that skips vectors 15;16;17;18;19 never -
+native_vecdecode native_vecdecode the fold answers count and sum of a nullable column over a skipping range 15;16;17;18;19 2026-10-22 columnar_vector.c: the fold ignores the validity bitmap, so a null row is read as a present value
+native_vecdecode native_vecdecode the fold does not count rows from a vector it never decoded 15;16;17;18;19 2026-10-22 columnar_vector.c: the fold ignores the skip mask and reads a skipped vector's hole as values (the hole zero-filled so the read is deterministic)
+native_vecdecode native_vecdecode the narrow plan is a columnar custom scan 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_vecdecode native_vecdecode the narrow range skips vectors 15;16;17;18;19 2026-10-22 columnar_reader.c: the zone maps never build a skip mask, so no vector is ruled out
+native_vecdecode native_vecdecode the poison-accepting range reaches the fold 15;16;17;18;19 2026-10-22 columnar_vector.c: pgcolumnar_batch_shape_eligible refuses every shape, so no aggregate reaches the batch fold
+native_vecdecode native_vecdecode the wide plan is a columnar custom scan 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
+native_vecdecode native_vecdecode the wide range skips none, so the arms really differ 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.enable_custom_scan defaults to off
native_vecskip native_vecskip and the aggregate skips the same seven vectors 15;16;17;18;19 2026-10-16 columnar_reader.c: no vector skip at all (zone-map rule off, refine_skipvec and qual_skipvec return at once)
native_vecskip native_vecskip cross-vector range parity 15;16;17;18;19 never -
native_vecskip native_vecskip empty range parity 15;16;17;18;19 never -
@@ -4659,6 +5002,48 @@ native_writer native_writer native row groups (5000 rows / 1000 limit) 15;16;17;
native_writer native_writer native row-group row total 15;16;17;18;19 never -
native_writer native_writer native storage row written 15;16;17;18;19 never -
native_writer native_writer native vector length recorded 15;16;17;18;19 2026-10-16 columnar.h: COLUMNAR_NATIVE_VECTOR_LENGTH 2048
+native_zonemap native_zonemap every vector's maximum of id, k and v is the heap's 15;16;17;18;19 2026-10-22 columnar_write_state.c: no vector carries a minimum or maximum, and so no chunk does either
+native_zonemap native_zonemap every vector's minimum of id, k and v is the heap's 15;16;17;18;19 2026-10-22 columnar_write_state.c: no vector carries a minimum or maximum, and so no chunk does either
+native_zonemap native_zonemap every whole-chunk maximum of id, k and v is the heap's 15;16;17;18;19 2026-10-22 columnar_write_state.c: no vector carries a minimum or maximum, and so no chunk does either
+native_zonemap native_zonemap every whole-chunk minimum of id, k and v is the heap's 15;16;17;18;19 2026-10-22 columnar_write_state.c: no vector carries a minimum or maximum, and so no chunk does either
+native_zonemap native_zonemap id (int4) min width = 4 bytes 15;16;17;18;19 never -
+native_zonemap native_zonemap k (int8) max width = 8 bytes 15;16;17;18;19 never -
+native_zonemap native_zonemap min/max present for all columns 15;16;17;18;19 2026-10-22 columnar_write_state.c: no vector carries a minimum or maximum, and so no chunk does either
+native_zonemap native_zonemap null_count sums (v, nullable) 15;16;17;18;19 never -
+native_zonemap native_zonemap null_count zero (id, no nulls) 15;16;17;18;19 never -
+native_zonemap native_zonemap per-vector value+null = 1024 15;16;17;18;19 never -
+native_zonemap native_zonemap per-vector value_count folds into chunk (k) 15;16;17;18;19 never -
+native_zonemap native_zonemap per-vector zone rows 15;16;17;18;19 never -
+native_zonemap native_zonemap premise: v is not ascending within a vector, so a stale minimum would show 15;16;17;18;19 never -
+native_zonemap native_zonemap read parity (no regression) 15;16;17;18;19 never -
+native_zonemap native_zonemap row count 15;16;17;18;19 never -
+native_zonemap native_zonemap sum null for non-int4 columns 15;16;17;18;19 never -
+native_zonemap native_zonemap sum present for int4 columns 15;16;17;18;19 never -
+native_zonemap native_zonemap value_count sums (id, no nulls) 15;16;17;18;19 never -
+native_zonemap native_zonemap value_count sums (v, nullable) 15;16;17;18;19 never -
+native_zonemap native_zonemap whole-chunk sum(id) matches heap 15;16;17;18;19 never -
+native_zonemap native_zonemap whole-chunk value+null = group rows 15;16;17;18;19 never -
+native_zonemap native_zonemap whole-chunk zone rows 15;16;17;18;19 never -
+native_zonemap native_zonemap zone maps cleaned on drop 15;16;17;18;19 never -
+native_zonemap_narrow native_zonemap_narrow a pruning predicate still prunes on the wide table 15;16;17;18;19 2026-10-22 columnar_reader.c: group skipping excludes every group
+native_zonemap_narrow native_zonemap_narrow and on the narrow table 15;16;17;18;19 2026-10-22 columnar_reader.c: group skipping reads the predicate column's zone map but never excludes a group
+native_zonemap_narrow native_zonemap_narrow narrow scan result is correct 15;16;17;18;19 2026-10-22 columnar_reader.c: group skipping excludes every group
+native_zonemap_narrow native_zonemap_narrow the zone map removes the nine groups c1 > 19000 cannot match, on the wide table 15;16;17;18;19 2026-10-22 columnar_reader.c: group skipping reads the predicate column's zone map but never excludes a group
+native_zonemap_narrow native_zonemap_narrow wide scan result is correct 15;16;17;18;19 2026-10-22 columnar_reader.c: group skipping excludes every group
+native_zonemap_narrow native_zonemap_narrow zone_map reads scale with predicates, not table width (wide ~= narrow) 15;16;17;18;19 2026-10-22 columnar_reader.c: every attribute's zone map is read up front (#314 reverted)
+native_zonemap_session native_zonemap_session a second predicate column still resolves zone_map once 15;16;17;18;19 2026-10-22 columnar_metadata.c: the session opens zone_map afresh on every probe (#744 reverted inside the session)
+native_zonemap_session native_zonemap_session and each of them opened exactly once 15;16;17;18;19 2026-10-22 columnar_reader.c: the scan's zone-map session is one static shared across scans and reset at the end hook (the first implementation of #744)
+native_zonemap_session native_zonemap_session and each of them probed exactly its own groups 15;16;17;18;19 2026-10-22 columnar_reader.c: the scan's zone-map session is one static shared across scans and reset at the end hook (the first implementation of #744)
+native_zonemap_session native_zonemap_session and the short circuit means it adds one probe, not one per group 15;16;17;18;19 2026-10-22 columnar_reader.c: every zone-map fetch is made twice
+native_zonemap_session native_zonemap_session every scan around an aborted one reports its own session (#744) 15;16;17;18;19 2026-10-22 columnar_reader.c: the scan's zone-map session is one static shared across scans and reset at the end hook (the first implementation of #744)
+native_zonemap_session native_zonemap_session nested columnar scans each hold their own handle 15;16;17;18;19 2026-10-22 columnar_reader.c: the scan's zone-map session is one static shared across scans and reset at the end hook (the first implementation of #744)
+native_zonemap_session native_zonemap_session nested columnar scans return the right answer 15;16;17;18;19 2026-10-22 columnar_reader.c: every chunk group is ruled out
+native_zonemap_session native_zonemap_session the fixture has more than one chunk group 15;16;17;18;19 2026-10-22 columnar_write_state.c: the writer multiplies the table's stripe_row_limit by 100
+native_zonemap_session native_zonemap_session the middle scan really did error inside the subtransaction 15;16;17;18;19 2026-10-22 columnar_reader.c: every chunk group is ruled out
+native_zonemap_session native_zonemap_session the scan after the subtransaction abort returns the right answer 15;16;17;18;19 2026-10-22 columnar_reader.c: the scan's zone-map session is one static shared across scans and reset at the end hook (the first implementation of #744)
+native_zonemap_session native_zonemap_session the scan really probed the zone maps, once per group 15;16;17;18;19 2026-10-22 columnar_reader.c: every zone-map fetch is made twice
+native_zonemap_session native_zonemap_session the scan resolves zone_map ONCE, not once per group (#744) 15;16;17;18;19 2026-10-22 columnar_metadata.c: the session opens zone_map afresh on every probe (#744 reverted inside the session)
+native_zonemap_session native_zonemap_session zs2 has more than one chunk group 15;16;17;18;19 2026-10-22 columnar_write_state.c: the writer ignores the table's stripe_row_limit
nologin_workers nologin_workers a NOLOGIN role's parallel flush encodes every column in a worker 15;16;17;18;19 2026-10-21 the workers connect as the calling role, as before #1585, so PG15 and PG16 refuse a NOLOGIN caller
nologin_workers nologin_workers a NOLOGIN role's parallel_copy loads every row 15;16;17;18;19 2026-10-21 the workers connect as the calling role, as before #1585, so PG15 and PG16 refuse a NOLOGIN caller
nologin_workers nologin_workers a NOLOGIN role's parallel_export_parquet exports every row 15;16;17;18;19 2026-10-21 the workers connect as the calling role, as before #1585, so PG15 and PG16 refuse a NOLOGIN caller
@@ -4674,6 +5059,22 @@ nologin_workers nologin_workers premise: SET ROLE to the NOLOGIN role takes 15;1
nologin_workers nologin_workers premise: the NOLOGIN role cannot open a session 15;16;17;18;19 never -
nologin_workers nologin_workers premise: the server log records the NOLOGIN role's refused login 15;16;17;18;19 never -
nologin_workers nologin_workers the flush is dispatched to workers 15;16;17;18;19 never -
+objstore_addressing objstore_addressing a key with a space, '+' and '=' reads back (path-style) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: os_uriencode_path encodes a space as '+'
+objstore_addressing objstore_addressing a key with a space, '+' and '=' reads back (virtual-host) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: os_uriencode_path encodes a space as '+'
+objstore_addressing objstore_addressing and its path-style request path is the key URI-encoded once 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: virtual-host addressing always (objstore_s3_addressing='path' ignored)
+objstore_addressing objstore_addressing and its virtual-host request path is the key alone, URI-encoded once 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: path-style addressing always (objstore_s3_addressing='virtual' ignored)
+objstore_addressing objstore_addressing and the request path carried the bucket again 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: virtual-host addressing always (objstore_s3_addressing='path' ignored)
+objstore_addressing objstore_addressing gs:// read round-trips (scheme handled, HMAC creds reused) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the AWS_ENDPOINT_URL override is dropped (ep = NULL)
+objstore_addressing objstore_addressing gs:// read-back of the written object 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the AWS_ENDPOINT_URL override is dropped (ep = NULL)
+objstore_addressing objstore_addressing gs:// with no endpoint goes to storage.googleapis.com (the allow-list refuses that host by name) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: gs:// demands AWS_ENDPOINT_URL like s3:// instead of defaulting to storage.googleapis.com
+objstore_addressing objstore_addressing gs:// write round-trips 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the AWS_ENDPOINT_URL override is dropped (ep = NULL)
+objstore_addressing objstore_addressing path-style put the bucket in the request path 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: virtual-host addressing always (objstore_s3_addressing='path' ignored)
+objstore_addressing objstore_addressing premise: fixture up 15;16;17;18;19 never -
+objstore_addressing objstore_addressing removal proof: with addressing=path the bucket is back in the path 15;16;17;18;19 never -
+objstore_addressing objstore_addressing s3:// path-style read 15;16;17;18;19 never -
+objstore_addressing objstore_addressing s3:// with no endpoint demands one (names AWS_ENDPOINT_URL) 15;16;17;18;19 never -
+objstore_addressing objstore_addressing virtual-host put the KEY ALONE in the path (no bucket segment) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: path-style addressing always (objstore_s3_addressing='virtual' ignored)
+objstore_addressing objstore_addressing virtual-host read == path-style read (same object, different addressing) 15;16;17;18;19 never -
objstore_allowlist objstore_allowlist a multi-entry list matches any entry 15;16;17;18;19 never -
objstore_allowlist objstore_allowlist a non-superuser cannot widen the list (42501) 15;16;17;18;19 2026-10-14 objstore_allowed_endpoints defined PGC_SIGHUP, so no session may SET it
objstore_allowlist objstore_allowlist a superuser session SET works (admin override) 15;16;17;18;19 2026-10-14 objstore_allowed_endpoints defined PGC_SIGHUP, so no session may SET it
@@ -4722,6 +5123,177 @@ objstore_crlf objstore_crlf a CR/LF in the URL path is rejected before the reque
objstore_crlf objstore_crlf backend alive 15;16;17;18;19 never -
objstore_crlf objstore_crlf backend alive after the REST attempt 15;16;17;18;19 never -
objstore_crlf objstore_crlf no injected line reached the listener (no request splitting) 15;16;17;18;19 2026-10-14 the request-line guard on the URL path removed
+objstore_http_read objstore_http_read A: count(*) over http 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range header's end is one past the last byte wanted
+objstore_http_read objstore_http_read A: full scan http == local 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range header's end is one past the last byte wanted
+objstore_http_read objstore_http_read A: projection http == local 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range header's end is one past the last byte wanted
+objstore_http_read objstore_http_read B: buffered 2-col requests (K = 4 + G*2) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range header's end is one past the last byte wanted
+objstore_http_read objstore_http_read B: buffered full-width requests (K = 4 + G*NCOLS) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range header's end is one past the last byte wanted
+objstore_http_read objstore_http_read B: buffered/unbuffered request ratio 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range header's end is one past the last byte wanted
+objstore_http_read objstore_http_read B: every GET carried a Range header 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: no Range header sent on a GET
+objstore_http_read objstore_http_read B: unbuffered >= 10x K 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range header's end is one past the last byte wanted
+objstore_http_read objstore_http_read C: EOF mid-body is refused 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: os_read_exact ends the read on EOF mid-body instead of raising
+objstore_http_read objstore_http_read C: a dropped keep-alive connection is retried (http == local) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: os_request never retries a stale keep-alive connection
+objstore_http_read objstore_http_read C: a server that hangs up on a fresh connection is refused 15;16;17;18;19 never -
+objstore_http_read objstore_http_read C: a short 206 body is refused 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: os_read_body accepts a 206 body shorter than the range (cl > want only)
+objstore_http_read objstore_http_read C: and that fresh connection is not retried (one request logged) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: os_request retries a fresh connection that failed before a response
+objstore_http_read objstore_http_read C: http 404 SQLSTATE 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a HEAD 404 read as an empty object
+objstore_http_read objstore_http_read C: range-ignoring server SQLSTATE 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range-ignored guard dropped (a 200 falls through to unexpected HTTP status)
+objstore_http_read objstore_http_read C: unreachable endpoint SQLSTATE 15;16;17;18;19 never -
+objstore_http_read objstore_http_read D: cancel latency bounded (<=10s) 15;16;17;18;19 never -
+objstore_http_read objstore_http_read D: stalled transfer cancelled with query_canceled 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the Range header's end is one past the last byte wanted
+objstore_http_read objstore_http_read premise: buffered run logged requests 15;16;17;18;19 never -
+objstore_http_read objstore_http_read premise: fixture server is up 15;16;17;18;19 never -
+objstore_http_read objstore_http_read premise: fixture written 15;16;17;18;19 never -
+objstore_http_read objstore_http_read premise: the range-audit phase logged GETs 15;16;17;18;19 never -
+objstore_http_read objstore_http_read premise: unbuffered run logged requests 15;16;17;18;19 never -
+objstore_http_read objstore_http_read survival: the server answers and no backend crashed 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the HEAD 404 refusal
+objstore_listing objstore_listing FDW over a prefix == read_parquet over the prefix 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the listing stops after its first page
+objstore_listing objstore_listing Hive over a remote prefix stamps the partition column 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the last key of every page is dropped
+objstore_listing objstore_listing a LIST that returns HTTP 500 is refused, not read as an empty prefix 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a non-2xx LIST read as an empty, untruncated page
+objstore_listing objstore_listing a non-advancing continuation token is refused, not an infinite paging loop 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the non-advancing continuation token refusal removed
+objstore_listing objstore_listing a partition predicate prunes a remote file (Files Pruned = 1) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the last key of every page is dropped
+objstore_listing objstore_listing a truncated page with no keys is refused, not looped 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the truncated-page-with-no-keys refusal removed
+objstore_listing objstore_listing an exact-key read issues no ListObjectsV2 15;16;17;18;19 never -
+objstore_listing objstore_listing and the request log recorded the exact-key read 15;16;17;18;19 never -
+objstore_listing objstore_listing backend still up after the listing refusals 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the non-advancing token refusal (the backend crashes)
+objstore_listing objstore_listing exact-key read returns its rows 15;16;17;18;19 never -
+objstore_listing objstore_listing glob s3://.../lst/*.parquet matches the 3 top-level parts, not sub/ 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the listing stops after its first page
+objstore_listing objstore_listing hidden (_SUCCESS) and non-parquet (notes.txt) objects are excluded 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the listing stops after its first page
+objstore_listing objstore_listing premise: fixture up 15;16;17;18;19 never -
+objstore_listing objstore_listing read_parquet over a prefix reads every object under it (4 parts, recursive) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the listing stops after its first page
+objstore_listing objstore_listing the prefix listing paged (more than one list-type=2 request) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the listing stops after its first page
+objstore_module objstore_module a broken module does NOT masquerade as an unhandled scheme 15;16;17;18;19 never -
+objstore_module objstore_module a gs URL reports an object-storage error, not a missing file 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the module stops handling gs:// URLs
+objstore_module objstore_module a https URL reports an object-storage error, not a missing file 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a module built with OpenSSL stops handling https:// URLs
+objstore_module objstore_module a local path still works 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: every local read_parquet path is refused as a missing file
+objstore_module objstore_module a relative path is not mistaken for a URL 15;16;17;18;19 never -
+objstore_module objstore_module a remote glob (*) is handled remotely (an object-storage error, not a local one) 15;16;17;18;19 never -
+objstore_module objstore_module a remote glob (?) is handled remotely (an object-storage error, not a local one) 15;16;17;18;19 never -
+objstore_module objstore_module a remote glob ([0-9]) is handled remotely (an object-storage error, not a local one) 15;16;17;18;19 never -
+objstore_module objstore_module a s3 URL reports an object-storage error, not a missing file 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the module stops handling s3:// URLs
+objstore_module objstore_module and neither claims the module is missing 15;16;17;18;19 never -
+objstore_module objstore_module and neither downgrades it to an unhandled scheme 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a missing object-store module is reported as an unhandled scheme
+objstore_module objstore_module and the * glob is NOT reported as a local filesystem miss 15;16;17;18;19 never -
+objstore_module objstore_module and the ? glob is NOT reported as a local filesystem miss 15;16;17;18;19 never -
+objstore_module objstore_module and the [0-9] glob is NOT reported as a local filesystem miss 15;16;17;18;19 never -
+objstore_module objstore_module and the gs URL is NOT reported as a missing file 15;16;17;18;19 never -
+objstore_module objstore_module and the https URL is NOT reported as a missing file 15;16;17;18;19 never -
+objstore_module objstore_module and the loader's own reason survives 15;16;17;18;19 2026-10-22 columnar_objstore.c: a module that is present but fails to load is reported as not installed
+objstore_module objstore_module and the s3 URL is NOT reported as a missing file 15;16;17;18;19 never -
+objstore_module objstore_module both reads name the missing module, identically 15;16;17;18;19 2026-10-22 columnar_parquet_reader.c: a missing object-store module is reported as an unhandled scheme
+objstore_module objstore_module nor as a module that was never installed 15;16;17;18;19 2026-10-22 columnar_objstore.c: a module that is present but fails to load is reported as not installed
+objstore_module objstore_module positive control: it IS defined in the module, so nm really looked 15;16;17;18;19 never -
+objstore_module objstore_module premise: and the real module is back afterwards 15;16;17;18;19 never -
+objstore_module objstore_module premise: the module really is corrupt for this run, not merely intended to be 15;16;17;18;19 never -
+objstore_module objstore_module premise: the module really was absent while those two reads ran 15;16;17;18;19 never -
+objstore_module objstore_module premise: the real module was restored afterwards 15;16;17;18;19 never -
+objstore_module objstore_module the main library is installed 15;16;17;18;19 never -
+objstore_module objstore_module the module's entry point is NOT inside the preloaded library 15;16;17;18;19 never -
+objstore_module objstore_module the object-store module is installed BESIDE it, not inside it 15;16;17;18;19 never -
+objstore_module objstore_module with the module absent, neither read leaks the loader's own error 15;16;17;18;19 never -
+objstore_module objstore_module with the module present, both reads report an unhandled scheme 15;16;17;18;19 never -
+objstore_s3_read objstore_s3_read 403 ordering premise: verified requests still reach 404 = 58P01 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a 404 on open is refused 28000 instead of 58P01
+objstore_s3_read objstore_s3_read 403: tamper bucket surfaces 28000 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a 403 on open is read as an empty object
+objstore_s3_read objstore_s3_read A: count(*) over s3 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: AWS_DEFAULT_REGION outranks AWS_REGION
+objstore_s3_read objstore_s3_read A: full scan s3 == local 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: AWS_DEFAULT_REGION outranks AWS_REGION
+objstore_s3_read objstore_s3_read A: percent-encoded key s3 == local 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: AWS_DEFAULT_REGION outranks AWS_REGION
+objstore_s3_read objstore_s3_read A: projection s3 == local 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: AWS_DEFAULT_REGION outranks AWS_REGION
+objstore_s3_read objstore_s3_read B: signing left the request arithmetic unchanged 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: AWS_DEFAULT_REGION outranks AWS_REGION
+objstore_s3_read objstore_s3_read backend still up after the s3 refusals 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort cleanup of an open handle calls abort() after the ERROR reached the client
+objstore_s3_read objstore_s3_read config: s3 read with no AWS_SECRET_ACCESS_KEY is refused naming it 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a missing AWS_SECRET_ACCESS_KEY signs with an empty secret
+objstore_s3_read objstore_s3_read config: s3 read with no environment is 28000 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: s3:// with no AWS_ENDPOINT_URL falls back to a default host instead of refusing
+objstore_s3_read objstore_s3_read config: s3 read with no region is refused naming it 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: with no region configured the signer uses us-east-1 instead of refusing
+objstore_s3_read objstore_s3_read https endpoint is not downgraded 15;16;17;18;19 never -
+objstore_s3_read objstore_s3_read premise: fixture written 15;16;17;18;19 never -
+objstore_s3_read objstore_s3_read premise: signed run logged requests 15;16;17;18;19 never -
+objstore_s3_read objstore_s3_read premise: sigv4 fixture server is up 15;16;17;18;19 never -
+objstore_s3_read objstore_s3_read region: AWS_DEFAULT_REGION is the fallback 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: AWS_DEFAULT_REGION is never consulted
+objstore_s3_read objstore_s3_read region: AWS_REGION outranks AWS_DEFAULT_REGION 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: AWS_DEFAULT_REGION outranks AWS_REGION
+objstore_sink_write objstore_sink_write all four workers' uploads were open before the cancel 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the sink never flushes a part, so no export opens a multipart upload
+objstore_sink_write objstore_sink_write allow-list: an unlisted endpoint refuses 42501 before any write 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write and completed it exactly once (POST with uploadId, no partNumber) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: CompleteMultipartUpload is never sent and read as done
+objstore_sink_write objstore_sink_write arrow export to s3:// also round-trips 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a single-PUT object loses its last byte
+objstore_sink_write objstore_sink_write cancel: no _SUCCESS marker at the prefix 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write cancel: no completed object remains at the prefix 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write cancel: the dispatcher issued DELETE over the known keys 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write cancel: the server holds no open upload under the prefix 15;16;17;18;19 2026-10-22 the before_shmem_exit registration in PgColumnarSinkOpen dropped (noreg)
+objstore_sink_write objstore_sink_write inject: every upload started was aborted (no billable orphan) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a failed export never aborts its multipart upload
+objstore_sink_write objstore_sink_write inject: mid-upload failure surfaces as disk-full (53100) 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write inject: the key does not exist 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: no multipart, the growing object is PUT to the final key as it grows
+objstore_sink_write objstore_sink_write inject: the server holds no open upload for the key 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the abort DELETE names an upload that is not this one
+objstore_sink_write objstore_sink_write large export to s3:// succeeds 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write large round trip: s3 read-back == source 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the last part is left out of the CompleteMultipartUpload list
+objstore_sink_write objstore_sink_write parallel export to an s3:// prefix succeeds 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write parallel remote: a _SUCCESS marker object exists at the prefix 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write parallel remote: the marker went to the prefix as a PUT 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write parallel: a re-run into the same prefix succeeds (overwrite) 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write parallel: every worker PUT went to the prefix 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write parallel: one object per worker was written (4) 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write parallel: the union of the per-worker objects == source 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a single-PUT object loses its last byte
+objstore_sink_write objstore_sink_write part failure: a refused part fails the export (58030) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a part upload the server refused is read as stored
+objstore_sink_write objstore_sink_write part failure: the key does not exist 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: no multipart, the growing object is PUT to the final key as it grows
+objstore_sink_write objstore_sink_write part failure: the server holds no open upload for the key 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the abort DELETE names an upload that is not this one
+objstore_sink_write objstore_sink_write premise: a worker was uploading before the cancel 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write premise: sigv4 fixture server is up 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write premise: the export was cancelled, not completed 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write premise: the fault landed mid-multipart (an upload was started) 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write premise: the large export took the multipart protocol 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write premise: the part failure landed mid-multipart (an upload was started) 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write shrink: _SUCCESS certifies the reconciled prefix 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write shrink: a smaller re-run leaves exactly its own 2 parts (stale tail dropped) 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write shrink: parts 0002/0003 from the larger run are gone 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write shrink: the 4-worker run wrote 4 parts + marker 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write small export to s3:// succeeds 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write small object went as ONE plain PUT (no multipart) 15;16;17;18;19 never -
+objstore_sink_write objstore_sink_write small round trip: s3 read-back == source 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: a single-PUT object loses its last byte
+objstore_sink_write objstore_sink_write terminate: export workers exit within 75 s while the store is silent 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: os_wait without WL_TIMEOUT, so a silent store never counts a quiet slice
+objstore_stash_recovery objstore_stash_recovery and the installed module is still the one that was there, not the debris 15;16;17;18;19 2026-10-22 objstore_module.sh start guard with no debris branch: every stash restored over the installed module
+objstore_stash_recovery objstore_stash_recovery and the module left installed is still valid 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery and the stash is cleared 15;16;17;18;19 2026-10-22 objstore_module.sh start guard refuses on any stash, as before #393's fix
+objstore_stash_recovery objstore_stash_recovery and the stash is not left behind for the next run to trip on 15;16;17;18;19 2026-10-22 objstore_module.sh start guard refuses on any stash, as before #393's fix
+objstore_stash_recovery objstore_stash_recovery premise: a 19-byte stand-in really is installed as the module 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery premise: a debris stash really is in place for the run below 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery premise: and the debris is a valid module that differs from the installed one 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery premise: and the real module is the thing parked at the stash 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery premise: and the stash beside it is the real module 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery premise: nothing is installed at the module's path for this slice 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery premise: the installed module is valid before this slice arranges anything 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery premise: which is not a module 15;16;17;18;19 never -
+objstore_stash_recovery objstore_stash_recovery the debris is gone afterwards 15;16;17;18;19 2026-10-22 objstore_module.sh start guard refuses on any stash, as before #393's fix
+objstore_stash_recovery objstore_stash_recovery the module is installed again afterwards, and valid 15;16;17;18;19 2026-10-22 objstore_module.sh start guard decides on presence alone: every stash is debris and is deleted
+objstore_stash_recovery objstore_stash_recovery the real module survived, rather than being deleted with the stash 15;16;17;18;19 2026-10-22 objstore_module.sh start guard decides on presence alone: every stash is debris and is deleted
+objstore_stash_recovery objstore_stash_recovery the suite recovers the only surviving copy and runs to completion 15;16;17;18;19 2026-10-22 objstore_module.sh start guard walks .away only, so a .probe that is the only copy is never restored
+objstore_stash_recovery objstore_stash_recovery the suite replaces the stand-in with the real module and completes 15;16;17;18;19 2026-10-22 objstore_module.sh start guard decides on presence alone: every stash is debris and is deleted
+objstore_stash_recovery objstore_stash_recovery the suite runs to completion with debris beside a valid module 15;16;17;18;19 2026-10-22 objstore_module.sh start guard refuses on any stash, as before #393's fix
+objstore_tls_read objstore_tls_read A2: s3 over https differential == local 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: trust from SSL_CERT_DIR only (SSL_CTX_set_default_verify_dir in place of set_default_verify_paths)
+objstore_tls_read objstore_tls_read A: count(*) over https 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: trust from SSL_CERT_DIR only (SSL_CTX_set_default_verify_dir in place of set_default_verify_paths)
+objstore_tls_read objstore_tls_read A: https differential == local 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: trust from SSL_CERT_DIR only (SSL_CTX_set_default_verify_dir in place of set_default_verify_paths)
+objstore_tls_read objstore_tls_read C: expired names its reason 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: expired refusal is the TLS one 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: expired refused with 08006 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: expired returned no rows 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: SSL_VERIFY_NONE in place of SSL_VERIFY_PEER
+objstore_tls_read objstore_tls_read C: self-signed names its reason 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: self-signed refusal is the TLS one 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: self-signed refused with 08006 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: self-signed returned no rows 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: SSL_VERIFY_NONE in place of SSL_VERIFY_PEER
+objstore_tls_read objstore_tls_read C: untrusted-ca names its reason 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: untrusted-ca refusal is the TLS one 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: untrusted-ca refused with 08006 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: untrusted-ca returned no rows 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: SSL_VERIFY_NONE in place of SSL_VERIFY_PEER
+objstore_tls_read objstore_tls_read C: wrong-host names its reason 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: wrong-host refusal is the TLS one 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: wrong-host refused with 08006 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
+objstore_tls_read objstore_tls_read C: wrong-host returned no rows 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: the IP-literal endpoint is never bound (X509_VERIFY_PARAM_set1_ip_asc skipped)
+objstore_tls_read objstore_tls_read D: cancel latency bounded (<=10s) 15;16;17;18;19 never -
+objstore_tls_read objstore_tls_read D: stalled TLS transfer cancelled with query_canceled 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: trust from SSL_CERT_DIR only (SSL_CTX_set_default_verify_dir in place of set_default_verify_paths)
+objstore_tls_read objstore_tls_read E: SSL_CERT_DIR alone trusts the CA (count(*) over https) 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: trust from SSL_CERT_FILE only (SSL_CTX_set_default_verify_file in place of set_default_verify_paths)
+objstore_tls_read objstore_tls_read premise: expired certificate is genuinely expired 15;16;17;18;19 never -
+objstore_tls_read objstore_tls_read premise: expired handshake was attempted 15;16;17;18;19 never -
+objstore_tls_read objstore_tls_read premise: self-signed handshake was attempted 15;16;17;18;19 never -
+objstore_tls_read objstore_tls_read premise: six TLS servers are up 15;16;17;18;19 never -
+objstore_tls_read objstore_tls_read premise: untrusted-ca handshake was attempted 15;16;17;18;19 never -
+objstore_tls_read objstore_tls_read premise: wrong-host handshake was attempted 15;16;17;18;19 never -
+objstore_tls_read objstore_tls_read survival: no backend was terminated by a signal 15;16;17;18;19 2026-10-22 columnar_objstore_module.c: abort() in place of the certificate-verification refusal
parallel parallel par count 15;16;17;18;19 2026-10-22 columnar_reader.c: a parallel custom scan claims row groups from its own rowGroupIndex, not the shared counter, so every participant reads every group (D6e)
parallel parallel par filtered scan 15;16;17;18;19 2026-10-22 columnar_reader.c: a parallel custom scan claims row groups from its own rowGroupIndex, not the shared counter, so every participant reads every group (D6e)
parallel parallel par no-filter 15;16;17;18;19 2026-10-22 columnar_reader.c: a parallel custom scan claims row groups from its own rowGroupIndex, not the shared counter, so every participant reads every group (D6e)
@@ -5298,6 +5870,90 @@ pg_dump_roundtrip pg_dump_roundtrip plain: row count survives 15;16;17;18;19 nev
pg_dump_roundtrip pg_dump_roundtrip plain: row data survives (checksum) 15;16;17;18;19 never -
pg_dump_roundtrip pg_dump_roundtrip plain: the projection declaration IS carried 15;16;17;18;19 2026-10-16 columnar_projection.c: drop_projection keeps the projection's declaration;pgcolumnar--1.0-alpha5.sql: pgcolumnar.projection_declaration no longer registered with pg_extension_config_dump
pg_dump_roundtrip pg_dump_roundtrip the base table has a projection to preserve 15;16;17;18;19 never -
+phase2 phase2 between count 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 count where a<50 15;16;17;18;19 never -
+phase2 phase2 count(*) 15;16;17;18;19 never -
+phase2 phase2 empty range 15;16;17;18;19 never -
+phase2 phase2 fallback codec=0 15;16;17;18;19 2026-10-22 columnar_compression.c: zstd output kept when it is no smaller than its input
+phase2 phase2 fallback value 15;16;17;18;19 never -
+phase2 phase2 lz4 block codec 15;16;17;18;19 2026-10-22 columnar_write_state.c: the session's pgcolumnar.compression ignored, every write uses the boot default zstd
+phase2 phase2 lz4 count 15;16;17;18;19 never -
+phase2 phase2 lz4 distinct pl 15;16;17;18;19 never -
+phase2 phase2 lz4 payload ok 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 lz4 sum(id) 15;16;17;18;19 never -
+phase2 phase2 minmax present 15;16;17;18;19 never -
+phase2 phase2 no nulls t.b 15;16;17;18;19 never -
+phase2 phase2 none block codec 15;16;17;18;19 2026-10-22 columnar_write_state.c: the session's pgcolumnar.compression ignored, every write uses the boot default zstd
+phase2 phase2 none count 15;16;17;18;19 never -
+phase2 phase2 none distinct pl 15;16;17;18;19 never -
+phase2 phase2 none payload ok 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 none sum(id) 15;16;17;18;19 never -
+phase2 phase2 nonnull val 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 null count 15;16;17;18;19 never -
+phase2 phase2 null rows 15;16;17;18;19 never -
+phase2 phase2 null value 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 order by a limit3 15;16;17;18;19 never -
+phase2 phase2 orphan chunks 15;16;17;18;19 2026-10-22 columnar_metadata.c: a dropped table's column_chunk rows kept
+phase2 phase2 orphan row groups 15;16;17;18;19 2026-10-22 columnar_metadata.c: a dropped table's row_group rows kept
+phase2 phase2 pglz block codec 15;16;17;18;19 2026-10-22 columnar_write_state.c: the session's pgcolumnar.compression ignored, every write uses the boot default zstd
+phase2 phase2 pglz count 15;16;17;18;19 never -
+phase2 phase2 pglz distinct pl 15;16;17;18;19 never -
+phase2 phase2 pglz payload ok 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 pglz sum(id) 15;16;17;18;19 never -
+phase2 phase2 point lookup 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 project a 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 project b 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 project b,c 15;16;17;18;19 never -
+phase2 phase2 project c 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 project sum 15;16;17;18;19 never -
+phase2 phase2 range decodes 1 of 3 vectors 15;16;17;18;19 2026-10-22 columnar_reader.c: the zone maps rule out no vector
+phase2 phase2 range min/max 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 v0 max=10000 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 v0 min=1 15;16;17;18;19 never -
+phase2 phase2 v2 max=25000 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 vectors 15;16;17;18;19 never -
+phase2 phase2 zstd block codec 15;16;17;18;19 2026-10-22 columnar_write_state.c: the block codec never applied, every chunk stored with codec 0
+phase2 phase2 zstd count 15;16;17;18;19 never -
+phase2 phase2 zstd distinct pl 15;16;17;18;19 never -
+phase2 phase2 zstd payload ok 15;16;17;18;19 2026-10-22 columnar_metadata.c: a zone map's maximum written as its minimum
+phase2 phase2 zstd sum(id) 15;16;17;18;19 never -
+phase3 phase3 bulk update rows 15;16;17;18;19 never -
+phase3 phase3 bulk update sfx 15;16;17;18;19 never -
+phase3 phase3 delete boundaries 15;16;17;18;19 never -
+phase3 phase3 delete count 15;16;17;18;19 never -
+phase3 phase3 delete gone 15;16;17;18;19 never -
+phase3 phase3 delete neighbors 15;16;17;18;19 never -
+phase3 phase3 delete tail count 15;16;17;18;19 never -
+phase3 phase3 delete tail max 15;16;17;18;19 never -
+phase3 phase3 delete_vector cleaned 15;16;17;18;19 2026-10-22 columnar_metadata.c: a dropped table's delete_vector rows kept
+phase3 phase3 delete_vector deleted 15;16;17;18;19 never -
+phase3 phase3 delete_vector rows>0 15;16;17;18;19 never -
+phase3 phase3 durable odd only 15;16;17;18;19 never -
+phase3 phase3 durable survivors 15;16;17;18;19 never -
+phase3 phase3 rb after count 15;16;17;18;19 never -
+phase3 phase3 rb after range 15;16;17;18;19 never -
+phase3 phase3 rb before 15;16;17;18;19 never -
+phase3 phase3 reg count 15;16;17;18;19 never -
+phase3 phase3 reg filter a<100 15;16;17;18;19 never -
+phase3 phase3 row group cleaned 15;16;17;18;19 2026-10-22 columnar_metadata.c: a dropped table's row_group rows kept
+phase3 phase3 ryw count 15;16;17;18;19 never -
+phase3 phase3 ryw persisted 15;16;17;18;19 never -
+phase3 phase3 ryw sum 15;16;17;18;19 never -
+phase3 phase3 savepoint failed delete discarded 15;16;17;18;19 2026-10-22 columnar_tableam.c: a savepoint's abort keeps its pending delete-vector changes
+phase3 phase3 savepoint failed final 15;16;17;18;19 never -
+phase3 phase3 savepoint failed insert discarded 15;16;17;18;19 2026-10-22 columnar_tableam.c: a savepoint's abort keeps its pending writes
+phase3 phase3 savepoint final 15;16;17;18;19 never -
+phase3 phase3 savepoint in-txn 15;16;17;18;19 never -
+phase3 phase3 savepoint nested read in a delete sees its deleted rows, as on heap 15;16;17;18;19 2026-10-22 columnar_tableam.c: a savepoint's commit does not hand its delete buffers and written marks to the parent
+phase3 phase3 temp deleted 15;16;17;18;19 never -
+phase3 phase3 temp survivors 15;16;17;18;19 never -
+phase3 phase3 the deleted range straddles vectors 0 and 1 15;16;17;18;19 2026-10-22 columnar_tableam.c: pgcolumnar.chunk_group_row_limit boots at 16384, not 10000
+phase3 phase3 update count 15;16;17;18;19 never -
+phase3 phase3 update key gone 15;16;17;18;19 never -
+phase3 phase3 update key new 15;16;17;18;19 never -
+phase3 phase3 update key total 15;16;17;18;19 never -
+phase3 phase3 update one row 15;16;17;18;19 never -
+phase3 phase3 update value 15;16;17;18;19 never -
preimage_rewrite preimage_rewrite ...and prunes as well as the equivalent explicit range 15;16;17;18;19 2026-10-21 columnar_customscan.c: the date_trunc preimage keys are derived and then dropped, so no rewrite happens
preimage_rewrite preimage_rewrite ...and still answers correctly with the fold enabled 15;16;17;18;19 never -
preimage_rewrite preimage_rewrite ...and the heap agrees it is unsatisfiable 15;16;17;18;19 never -
diff --git a/test/check_ledger_budget.txt b/test/check_ledger_budget.txt
index dbcc9fa8f..ed89477fe 100644
--- a/test/check_ledger_budget.txt
+++ b/test/check_ledger_budget.txt
@@ -15,7 +15,7 @@
#
# Adding a check to a suite that is already covered does not move it, which is
# what makes it safe to bound.
-suites_not_covered 69
+suites_not_covered 34
#
# checks_never_observed_red -- A CENSUS. NOT a ceiling, and it must not become
# one.
@@ -6112,4 +6112,696 @@ suites_not_covered 69
# never 4176 + 16 = 4192
# dated 2914 + 38 = 2952
# 4192 + 2952 == 7144
-checks_never_observed_red 4192
+#
+# #1341, native_fold_deferral seeded, with its twin (OffgridwithJD): 4192 -> 4196, and
+# suites_not_covered 69 -> 68. The suite had no rows. It enters with 13 checks from
+# five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# The heap mirror was a CTAS from the columnar table, so it read through the scan under
+# test: with the writer storing one value off by one, the fold, the row path and the
+# mirror all agreed and main passed 13/13. The mirror is now written from the
+# generator. The three answer checks compared readings taken with tail -1 using check:
+# with the server stopped all three passed on empty strings. Readings now fail empty
+# and the checks are check_text. And three premises a one-line pgcolumnar change
+# reddens (the fold's ANALYZE line, the EXPLAIN group total, the writer's stripe limit)
+# lost the prefix. 9 are dated by eight mutations, each reddening the same names in
+# both twins. 4 arrive never: the two remaining premises, the counter's presence, and
+# the eager-load bound, which no measured mutation reaches: with every group deferring,
+# the loads equal survivors times width exactly, which still clears its 99% floor.
+#
+# ledger rows 7144 + 13 = 7157
+# never 4192 + 4 = 4196
+# dated 2952 + 9 = 2961
+# 4196 + 2961 == 7157
+#
+# #1341, native_lazy_slot seeded, with its twin (OffgridwithJD): 4196 -> 4199,
+# and suites_not_covered 68 -> 67. The suite had no rows. It enters with its 10
+# checks from five-major runs (all green on each of 15-19, shell and twin alike), after two
+# fixes. The heap mirror was a copy of lz_c, read through the reader under test, so a value
+# the writer stored wrong was wrong on both sides: with the writer storing c40 off by one,
+# main passed 10/10. It is now written from the generator, and that mutant reddens four
+# checks. And five comparisons read `q ... | tail -1` and compared with check, so two failed
+# reads agreed, and a failed timing read as fast: with the server stopped after setup, six
+# checks passed on main. Each side's failure now carries its side's name, and a failed
+# timing is no reading. 7 are dated by four mutations, each reddening the same names in
+# both twins. 3 arrive never.
+#
+# ledger rows 7157 + 10 = 7167
+# never 4196 + 3 = 4199
+# dated 2961 + 7 = 2968
+# 4199 + 2968 == 7167
+#
+# #1341, native_metadata_flush seeded, with its twin (OffgridwithJD): 4199 -> 4201,
+# and suites_not_covered 67 -> 66. The suite had no rows. It enters with 8 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after four fixes,
+# each measured on main's shell, which passed all six under it. A witness that counted
+# nothing (zerocount, opens=0 for every flush) passed both open-count checks, since 0 equals
+# 0 and 0 is small: a floor now wants at least the three tables every flush writes. The heap
+# mirror was copied from the columnar table, so a writer storing a wrong value
+# (writecorrupt) agreed with itself: it is now written from the generator. Nothing showed
+# the zone rows were read, since the reader also rules vectors out by value after decoding:
+# a check now wants a vector ruled out by its zone row alone (Skipped minus Ruled Out by
+# Value), and zone rows with no min/max (zonenull) redden it. And the witness check was
+# labelled a premise though a flush that never begins its session (nosession) reddens it,
+# so it lost the prefix (#1339). 6 are dated by five mutations, each the same names in
+# both twins. 2 arrive never: the row count and the range read against the heap mirror.
+#
+# ledger rows 7167 + 8 = 7175
+# never 4199 + 2 = 4201
+# dated 2968 + 6 = 2974
+# 4201 + 2974 == 7175
+#
+# #1341, native_late_materialization seeded, with its twin (OffgridwithJD): 4201 -> 4203, and
+# suites_not_covered 66 -> 65. The suite had no rows. It enters with 14 checks from
+# five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# Every counter defaulted to 0 when it could not be read, and four checks want exactly
+# 0: with the server stopped before the feature-off arm, "with the feature off nothing
+# is filtered early" and "a volatile qual is refused the two-pass path" passed on main.
+# An unread counter is now "no reading". And five checks a one-line pgcolumnar change
+# reddens (the custom scan off by default, the early counter dropped, a survivor
+# rejected, a writer that drops one row) lost the prefix; the two fixture premises
+# now read the heap mirror h, which pgcolumnar does not write (jdatcmd's #1691
+# review). 12 are dated by seven mutations, each reddening the same names in both
+# twins: dropseven (pgcolumnar_tuple_insert drops the row whose id is 7) dates "heap
+# agrees on how many rows match", the executor's own Rows Removed by Filter and the
+# two answer checks with the feature off and refused, which no mutation of the
+# late-materialization path reaches. 2 arrive never: the two fixture premises.
+# Building a rejected row's payload anyway (matall) reddens nothing: no counter
+# reports per-row materialization.
+#
+# ledger rows 7175 + 14 = 7189
+# never 4201 + 2 = 4203
+# dated 2974 + 12 = 2986
+# 4203 + 2986 == 7189
+#
+# #1341, native_reclaim_reconcile seeded, with its twin (OffgridwithJD): 4203 -> 4210,
+# and suites_not_covered 65 -> 64. The suite had no rows. It enters with its 13
+# checks from five-major runs (all green on each of 15-19, shell and twin alike), after two
+# fixes. Every reconcile check counted only the injected stale row, so a reconcile that
+# dropped EVERY free row, leaking all reusable space, passed 11/11 on main. A plain compact
+# (which does not reuse) must now keep the legitimate free ranges the earlier rewrite left,
+# as count and bytes, and "compact_rewrite recorded the free ranges it left" checks that
+# one exists. It is not a premise (jdatcmd's #1692 review): the ranges exist only because
+# pgcolumnar recorded them, and nofree (record_free_space drops its insert, one line)
+# reddens it alone. And "row count intact" compared two
+# readings with plain check: with the server stopped before it, main passed it on two
+# empty strings. It is check_num now. The reuse-cycle loop is unrolled so its two names are
+# literals. 6 are dated by three mutations, each reddening the same names in both twins.
+# 7 arrive never.
+#
+# ledger rows 7189 + 13 = 7202
+# never 4203 + 7 = 4210
+# dated 2986 + 6 = 2992
+# 4210 + 2992 == 7202
+#
+# #1341, native_page_offset_bound seeded, with its twin (OffgridwithJD): 4210 -> 4212, and
+# suites_not_covered 64 -> 63. The suite had no rows. It enters with 12 checks from
+# five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# Every refusal arm counted any psql failure as refused: with the whole-group guard
+# removed the backend segfaulted and the arm still passed, and with the projected
+# guard removed a later XX001 passed it. Each now wants XX001 with the guard's own
+# message. Survival was a bare SELECT 1, which answers again after crash recovery:
+# with the probe 4s late, main passed both whole-group survival arms with the backend
+# killed. It now also reads the server log's crash count. And nothing read an intact
+# table, so a guard over-applied to refuse every chunk (overall) or a legal chunk
+# ending exactly at its group end (offby1) passed all 10: two controls now read rows
+# behind an OFFSET 0 fence, which an aggregate over an intact table would not decode.
+# 10 are dated by four mutations, each reddening the same names in both twins. With
+# the whole-group guard removed, whether the read segfaults or fails decoding depends
+# on where it lands; its three refusal checks are red either way. 2 arrive never:
+# the two projected survival arms, which no mutation crashes.
+#
+# ledger rows 7202 + 12 = 7214
+# never 4210 + 2 = 4212
+# dated 2992 + 10 = 3002
+# 4212 + 3002 == 7214
+#
+# #1341, native_param_pushdown seeded, with its twin (OffgridwithJD): 4212 -> 4216, and
+# suites_not_covered 63 -> 62. The suite had no rows. It enters with 10 checks from
+# five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# With the PARAM_EXEC guard removed (freezeexec) the correlated arm still answered 3002:
+# Begin freezes before any outer row sets the param, and "ts >= "
+# prunes nothing. The arm now reads the scan's Usable Skip Predicates and asks the upper
+# bound too, where that frozen value prunes every group. Every arm ran one execution per
+# session, so a value frozen once and reused (staleparam) passed; two executions in one
+# session now each count and prune with their own value. And survival was a bare
+# SELECT 1, green on main with a backend SIGKILLed; it now reads the crash count.
+# 6 are dated by four mutations, each reddening the same names in both twins. 4
+# arrive never: the literal count, the rescan premise, the lower-bound correlated answer
+# and survival (red only under the harness kill).
+#
+# ledger rows 7214 + 10 = 7224
+# never 4212 + 4 = 4216
+# dated 3002 + 6 = 3008
+# 4216 + 3008 == 7224
+#
+# #1341, native_sort_by seeded, with its twin (OffgridwithJD): 4216 -> 4230, and
+# suites_not_covered 62 -> 61. The suite had no rows. It enters with 23 rows: 20 checks
+# run on every major, two virtual-column checks run on 18-19, and their skip recorded
+# on 15-17, all green in shell and twin alike on each of 15-19, after two fixes.
+# The physical order was never read: group skipping on host was the only evidence of a
+# sort, and a rewrite that applied only the first key (ts left unordered) passed 16/16 on
+# main. The rows are now read in scan order and every adjacent pair is compared on (host,
+# ts), with a premise that the inserted order is not already sorted, and the explicit-
+# column sort must lay the rows out as the declared key does. And every refusal counted
+# any psql failure: a backend aborting in place of the no-key refusal passed its own
+# check, a crash in place of the virtual-column refusal passed 18/18 on PG18, and a
+# dropped declared column given the generic message passed. Each refusal now wants its
+# guard's SQLSTATE and message, and a new check reads the server log's crash count. 9
+# are dated by the mutations, each reddening the same names in both twins. 14 arrive
+# never: the premise, the virtual arm's skip on 15-17, and the checks no measured
+# mutation reaches.
+#
+# ledger rows 7224 + 23 = 7247
+# never 4216 + 14 = 4230
+# dated 3008 + 9 = 3017
+# 4230 + 3017 == 7247
+#
+# #1341, native_rewrite seeded, with its twin (OffgridwithJD): 4230 -> 4241,
+# and suites_not_covered 61 -> 60. The suite had no rows. It enters with its 20
+# checks from five-major runs (all green on each of 15-19, shell and twin alike), after four
+# fixes. Rewriting every undeleted group, or ignoring min_deleted_fraction, passed all 17 on
+# main: two checks now hold the threshold from both sides. vacuum without its index rebuild
+# passed too: stale entries land on every live row once over the whole table, so a count
+# and a hash through the index both matched; a narrow index range read now catches it
+# (id 4097 read back as 6146). The four counts compared two readings with plain check, and
+# "unique constraint still enforced" took any psql failure: with the server stopped after
+# the rewrite, four checks passed on main. Counts are check_num and the refusal wants
+# 23505 and n_id_uq's message. 9 are dated by five mutations of the rewrite, each
+# reddening the same names in both twins. 11 arrive never.
+#
+# ledger rows 7247 + 20 = 7267
+# never 4230 + 11 = 4241
+# dated 3017 + 9 = 3026
+# 4241 + 3026 == 7267
+#
+# #1341, native_zonemap seeded, with its twin (OffgridwithJD): 4241 -> 4259, and
+# suites_not_covered 60 -> 59. The suite had no rows. It enters with 23 checks from
+# five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# The bounds were checked for presence and width only, and every column ascended, so a
+# vector's first value was always its minimum: with a vector max never raised past its
+# first value, a min never lowered, or a chunk max never folded past its first vector,
+# main passed 18/18. v is now a permutation (values, nulls and counts unchanged), and
+# four checks decode each bound of id, k and v and compare it with the heap's for its
+# group and vector. And three checks compared two readings with check: with the server
+# stopped they passed on two empty strings; they are now check_num. 5 are dated by
+# four mutations, each reddening the same names in both twins. 18 arrive never.
+#
+# ledger rows 7267 + 23 = 7290
+# never 4241 + 18 = 4259
+# dated 3026 + 5 = 3031
+# 4259 + 3031 == 7290
+#
+# #1341, native_zonemap_session seeded, with its twin (OffgridwithJD): 4259 -> 4259,
+# and suites_not_covered 59 -> 58. The suite had no rows. It enters with 13 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# "nested columnar scans each hold their own handle" compared only the join's count, which
+# a shared handle still answers correctly: with one static session shared across scans,
+# the first implementation's defect, main passed it. It is now the witness, each scan
+# reporting its own groups opened once, and the count is a check of its own. The three scans
+# around the aborted one must also each probe exactly their own groups. Three premises that
+# a one-line pgcolumnar change reddens lost the prefix (#1339), and a new check holds zs2
+# to more than one group, which a writer ignoring stripe_row_limit fails. The third is
+# "the fixture has more than one chunk group" (jdatcmd's #1698 review): it reads the
+# writer's row_group catalog, and optx100 (a writer that multiplies stripe_row_limit by
+# 100) reddens it. All 13 are dated by seven mutations, each reddening the same names in
+# both twins; none arrives never. The re-resolved index oid columnar_metadata.c documents
+# is not counted by any witness here: with it restored the suite stays 13/13, stated
+# rather than claimed.
+#
+# ledger rows 7290 + 13 = 7303
+# never 4259 + 0 = 4259
+# dated 3031 + 13 = 3044
+# 4259 + 3044 == 7303
+#
+# #1341, native_zonemap_narrow seeded, with its twin (OffgridwithJD): 4259 -> 4259,
+# and suites_not_covered 58 -> 57. The suite had no rows. It enters with 6 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after one fix.
+# Nothing showed a group was skipped: the pruning check read only the answer, so a scan
+# that read the zone maps but never excluded a group, or never consulted them, returned
+# the same 1000 rows, and main passed all four checks under each. Two checks now read the
+# scan's Columnar Chunk Groups Removed by Filter (9 of 10, on each table), and the width
+# check refuses an unread count rather than comparing two blanks. 6 are dated by four
+# mutations, each reddening the same names in both twins. 0 arrive never.
+#
+# ledger rows 7303 + 6 = 7309
+# never 4259 + 0 = 4259
+# dated 3044 + 6 = 3050
+# 4259 + 3050 == 7309
+#
+# #1341, native_vecdecode seeded, with its twin (OffgridwithJD): 4259 -> 4269,
+# and suites_not_covered 57 -> 56. The suite had no rows. It enters with 28 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# The fixture had no nulls, so a fold that ignored the validity bitmap (nonull) passed all
+# 24 on main: a nullable column w now runs through the fold and through a scalar parity
+# check. "decoded plus skipped is the group's vectors" read missing counters as 0, so with
+# the plans failing it passed as 0 + 0 = 0, and two aggregate parities compared two empty
+# readings: with the server stopped before the plans main passed 3, now none. And nine
+# "premise:" checks read pgcolumnar's own behaviour, so they lost the prefix (#1339):
+# nofold reddens the three "reaches the fold" checks, decodeall the fold arm's decode
+# count, noskip "the narrow range skips vectors"; and (jdatcmd's #1700 review) noscan,
+# the custom scan off by default, reddens the two plan-shape checks, "no whole group is
+# pruned" and "the wide range skips none", while optdiv100, a writer that divides the
+# table's stripe_row_limit by 100, reddens "one row group". 18 are dated by seven
+# mutations, each the same names in both twins; noscan also dates "EXPLAIN reports
+# Columnar Vectors Decoded", "and it is not stuck at zero" and the control. The poison
+# check is dated by holeread, whose hole is zero-filled so a fold that reads it counts
+# rows that are not there on a non-assert build too. 10 arrive never: the heap-side
+# nulls premise and nine parity checks with heap.
+#
+# ledger rows 7309 + 28 = 7337
+# never 4259 + 10 = 4269
+# dated 3050 + 18 = 3068
+# 4269 + 3068 == 7337
+#
+# #1341, native_parquet_fieldid seeded, with its twin (OffgridwithJD): 4269 -> 4271,
+# and suites_not_covered 56 -> 55. The suite had no rows. It enters with 11 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# The refusal arms read only the SQLSTATE, and four refusals share 22023: with the 'no field
+# ids' guard removed (noanyid) an id-less file fell to the absent-id refusal and main passed
+# 11/11. Each arm now wants its own guard's message. And survival was a bare SELECT 1: with
+# the absent-id refusal turned into abort() it passed after crash recovery, and the crash took
+# five later refusal arms with it. Survival now reads the server log's count of killed
+# backends, and every reading waits for the server first, so a crash fails only its own arm
+# and survival. 9 are dated by five mutations, each the same names in both twins. 2
+# arrive never.
+#
+# ledger rows 7337 + 11 = 7348
+# never 4269 + 2 = 4271
+# dated 3068 + 9 = 3077
+# 4271 + 3077 == 7348
+#
+# #1341, native_parquet_codecs seeded, with its twin (OffgridwithJD): 4271 -> 4278,
+# and suites_not_covered 55 -> 54. The suite had no rows. It enters with 14 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# Every codec was compared with c_none.parquet read back through read_parquet, the reader
+# under test: with one decoded byte flipped in every page over 4 KB, the same way for
+# every codec, main passed all nine checks. Each file now decodes to the generator's rows,
+# built in SQL. Each file's codec is read from its own metadata (a crafter that wrote every
+# file uncompressed passed main 9/9). And survival reads the server log's crash count: with
+# the crafted-size guard aborting and the probe 3 s late, main's bare SELECT 1 passed. 7
+# are dated by four mutations, each reddening the same names in both twins. 7 arrive
+# never: the four codec premises read pyarrow's metadata, not pgcolumnar, and the FDW counts
+# hold under each mutation.
+#
+# ledger rows 7348 + 14 = 7362
+# never 4271 + 7 = 4278
+# dated 3077 + 7 = 3084
+# 4278 + 3084 == 7362
+#
+# #1341, native_parquet_units seeded, with its twin (OffgridwithJD): 4278 -> 4285,
+# and suites_not_covered 54 -> 53. The suite had no rows. It enters with 20 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# The pushdown oracle was the two-group millis file read back through read_parquet, the
+# reader under test: with every row after a page's first taking the next dictionary
+# entry, the scan and the mirror shifted alike and main passed 19/19. The oracle is now
+# the generator's hourly series, and a new check compares the whole file with it. The two
+# nanos truncation checks compared the file with a second file read the same way, so
+# with the server stopped both passed on two empty reads; they now want the toward-zero
+# microseconds as literals. 13 are dated by four mutations, each reddening the same
+# names in both twins. 7 arrive never.
+#
+# ledger rows 7362 + 20 = 7382
+# never 4278 + 7 = 4285
+# dated 3084 + 13 = 3097
+# 4285 + 3097 == 7382
+#
+# #1341, native_parquet_projection seeded, with its twin (OffgridwithJD): 4285 -> 4287,
+# and suites_not_covered 53 -> 52. The suite had no rows. It enters with 17 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# The oracle h was read_parquet of the same file, and read_parquet shares the scan's
+# decoders: with a text decoder dropping a byte, or an INT64 decoder off by one, main passed
+# 15/15. h is now built in SQL from the crafter's generator. And no table had a dropped
+# column, so a projection matching the needed set by target index rather than attribute
+# number passed 15/15; ftd drops a column ahead of the others and must still read exactly
+# the column asked for. 15 are dated by five mutations, each reddening the same names in
+# both twins. 2 arrive never: projection [a] and the unselected-qual hash, which no
+# mutation reached.
+#
+# ledger rows 7382 + 17 = 7399
+# never 4285 + 2 = 4287
+# dated 3097 + 15 = 3112
+# 4287 + 3112 == 7399
+#
+# #1341, native_parquet_hardening seeded, with its twin (OffgridwithJD): 4287 -> 4289,
+# and suites_not_covered 52 -> 51. The suite had no rows. It enters with 18 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after four fixes,
+# each measured on main's shell on PG17. The short-chunk verdict matched any 'has a malformed
+# row group', which five guards raise: with the chunk-count guard removed a later guard
+# rejected the file and main passed 18/18; it now wants this guard's detail. The 'still
+# reads' arms counted rows: a date, time or timestamp decoded one unit off passed 18/18; they
+# now compare the decoded values. The no-wrap arm counted -25536 in the in-range file, which
+# never holds 40000, so it could not fail; it now reads the out-of-range file, which must
+# refuse. And survival was a bare SELECT 1: with the chunk refusal turned into abort() and the
+# probe 5 s late it passed; it now reads the server log's count of killed backends, and every
+# reading waits for the server. The range refusals also want their own type's message, since
+# four share 22008. 16 are dated by eleven mutations, each the same names in both twins. 2
+# arrive never.
+#
+# ledger rows 7399 + 18 = 7417
+# never 4287 + 2 = 4289
+# dated 3112 + 16 = 3128
+# 4289 + 3128 == 7417
+#
+# #1341, native_parquet_stack seeded, with its twin (OffgridwithJD): 4289 -> 4289,
+# and suites_not_covered 51 -> 50. The suite had no rows. It enters with 8
+# checks from five-major runs (all green on each of 15-19, shell and twin alike). The footer
+# parser has two independent unbounded recursions, PgColumnarThriftSkip and walk_schema,
+# each guarded by check_stack_depth(); the fixture pins max_stack_depth so a ~20 KB crafted
+# footer trips the guard without overflowing the real C stack, so removing a guard fails the
+# check by a wrong SQLSTATE rather than by crashing. The two refusal checks pin 54001, not
+# any error, and read_parquet is held to the same refusal. 4 are dated: the nested-struct
+# and read_parquet refusals by removing the thrift guard, the schema-chain refusal by removing
+# the walk guard, and the valid-file control by a guard that trips on any nesting. The four
+# survival checks were a bare SELECT 1, which answers again once crash recovery is over, and
+# the twin's read_parquet survival test never read the file (jdatcmd's #1708 review). Each
+# arm now takes a crash baseline right before its trigger, every reading waits for the
+# server, and survival wants SELECT 1 and an unchanged count of "terminated by signal" in the
+# server log, so a crash fails only its own arm. An abort() at each input's decode site dates
+# them: thriftabort (PgColumnarThriftSkip aborts where its guard would raise) the
+# nested-struct and read_parquet survivals, walkabort (the same in walk_schema) the
+# schema-chain survival, validabort (walk_schema aborts on entry) the valid-file survival.
+# All 8 are dated; none arrives never.
+#
+# ledger rows 7417 + 8 = 7425
+# never 4289 + 0 = 4289
+# dated 3128 + 8 = 3136
+# 4289 + 3136 == 7425
+#
+# #1341, native_parquet_streaming seeded, with its twin (OffgridwithJD): 4289 -> 4304,
+# and suites_not_covered 50 -> 49. The suite had no rows. It enters with 20 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after one fix measured
+# on main's shell on PG17: survival was a bare SELECT 1, so with the second-dictionary refusal
+# turned into abort() 'backend survived the crafted page areas' passed, and the crash also
+# failed the v2 arm read during recovery. Survival now reads the server log's count of killed
+# backends since its section began, and every reading waits for the server first. As hardening,
+# the two refusals that accepted any ERROR now want the page loop's own decode refusal; no
+# mutant here separated them. The v2 level guard stays unisolated, as the suite's header
+# argues. 5 are dated by five mutations, each the same names in both twins. 15 arrive
+# never.
+#
+# ledger rows 7425 + 20 = 7445
+# never 4289 + 15 = 4304
+# dated 3136 + 5 = 3141
+# 4304 + 3141 == 7445
+#
+# #1341, native_parquet_partition seeded, with its twin (OffgridwithJD): 4304 -> 4310,
+# and suites_not_covered 49 -> 48. The suite had no rows. It enters with 35 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# The pruning reader defaulted to 0 on any failure, so with the server stopped the three
+# 'prunes nothing' checks passed on no reading; 'Files Pruned' prints only above 0, so a
+# missing line now counts as 0 only beside the plan's Files line. The refusals took any
+# error: with the unknown-column guard removed (nocolcheck) a later 42804 stood in, and with
+# the explicit encoded-NUL guard removed (nonul) the encoding check's 22021 did, main 35/35
+# each; every refusal now wants its own message. And survival was a bare SELECT 1, which
+# an abort() in the unknown-column refusal passed with the probe 5 s late; it now reads the
+# server log's crash count. 29 are dated by six mutations, each the same names in both
+# twins. 6 arrive never.
+#
+# ledger rows 7445 + 35 = 7480
+# never 4304 + 6 = 4310
+# dated 3141 + 29 = 3170
+# 4310 + 3170 == 7480
+#
+# #1341, native_parquet_fdw seeded, with its twin (OffgridwithJD): 4310 -> 4320,
+# and suites_not_covered 48 -> 47. The suite had no rows. It enters with 20 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after four fixes.
+# Projection and row-group skipping were never read: every arm compared answers, which the
+# executor's recheck and a discarding projection keep right, so with every column decoded
+# (noproj) or no group skipped (noskip) main passed 14/14. Two checks now read the scan's
+# EXPLAIN ANALYZE counters, over a 200,000-row file of four row groups. The refusal arms
+# accepted any psql failure: with the scan's missing-path guard removed (nopathguard) the
+# backend crashed and main passed, and with the short-file guard removed (nolen) the
+# bad-magic guard refused instead and main passed. Each arm now wants its own guard's
+# SQLSTATE and message, and a survival check reads the server log's count of killed
+# backends. The oracle is a heap table written from the generator, and the self-join counts
+# are check_num, since two failed reads agreed. The skipped scan's row count read a plain
+# int4 constant, a cross-type comparison that skips nothing (#1712), so it came from a full
+# scan (jdatcmd's #1711 review); it now runs the plan's own int8 query, and skipshift (each
+# group's skip decision moved to the next group, still 3 skipped) dates it. 10 are dated by
+# eight mutations, each the same names in both twins. 10 arrive never.
+#
+# ledger rows 7480 + 20 = 7500
+# never 4310 + 10 = 4320
+# dated 3170 + 10 = 3180
+# 4320 + 3180 == 7500
+#
+# #1341, native_read_parquet seeded, with its twin (OffgridwithJD): 4320 -> 4324,
+# and suites_not_covered 47 -> 46. The suite had no rows. It enters with 15 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# The round trips compared read_parquet with the source table, which the columnar scan
+# reads both to write the file and as the oracle: with the columnar reader returning
+# int4 777 as 778 (columnar_reader.c, store_att_byval), main passed 12/12. Every round trip now hashes the generator's own rows. The refusals
+# counted any psql failure: with the scalar physical-type guard removed, main passed
+# 12/12; each refusal now requires its SQLSTATE and its own message, and a survival check
+# reads the crash count. read_parquet's own pg_read_server_files gate was never tested:
+# with it removed, main passed 12/12; a role holding USAGE but not the role is now refused
+# by it, beside a member that reads every row. 11 are dated by five mutations, each
+# reddening the same names in both twins. 4 arrive never, among them "premise: reject
+# missing column definition list", which reads PostgreSQL's parser (42601), not pgcolumnar.
+#
+# ledger rows 7500 + 15 = 7515
+# never 4320 + 4 = 4324
+# dated 3180 + 11 = 3191
+# 4324 + 3191 == 7515
+#
+# #1341, objstore_listing seeded, with its twin (OffgridwithJD): 4324 -> 4328,
+# and suites_not_covered 46 -> 45. The suite had no rows. It enters with 15 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# The two non-progress refusals share 08P01 and read only the SQLSTATE: the no-keys page
+# replays its token, so with the progress check removed (noprogress) the token check refused
+# it on the next page and main passed 12/12. Each refusal now wants its own guard's message.
+# No check made a LIST fail, so a non-2xx read as an empty prefix (non200empty) passed 12/12;
+# the fixture server gained a __listing_status__ hook and a check wants the HTTP status
+# refusal (08006). The exact-key 'no LIST' count read a log nothing proved alive; a control
+# now wants the key's own GET in it. A survival check reads the server log's count of killed
+# backends, and every refusal waits for the server first. 11 are dated by seven mutations,
+# each the same names in both twins. 4 arrive never.
+#
+# ledger rows 7515 + 15 = 7530
+# never 4324 + 4 = 4328
+# dated 3191 + 11 = 3202
+# 4328 + 3202 == 7530
+#
+# #1341, native_rewrite_conc seeded, with its twin (OffgridwithJD): 4328 -> 4330,
+# and suites_not_covered 45 -> 44. The suite had no rows. It enters with 11 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# Nothing tested that the online rewrite does not block: with compact_rewrite under
+# AccessExclusiveLock (ael) the workers simply waited and main passed 5/5. A compact_rewrite
+# now holds its lock mid-verb (pgcolumnar.maintenance_hold_ms) while a reader and a writer,
+# each with a 3 s lock_timeout, must finish. And the race was assumed, not shown: a new check
+# requires the compactor to have moved rows while the workers wrote. 9 are dated by four
+# mutations, each the same names in both twins (noconflict on 3 of 3 runs). 2 arrive never.
+#
+# ledger rows 7530 + 11 = 7541
+# never 4328 + 2 = 4330
+# dated 3202 + 9 = 3211
+# 4330 + 3211 == 7541
+#
+# #1341, objstore_addressing seeded, with its twin (OffgridwithJD): 4330 -> 4335,
+# and suites_not_covered 44 -> 43. The suite had no rows. It enters with 16 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# The gs:// no-endpoint check passed whenever the message did not name AWS_ENDPOINT_URL, so a
+# mistyped default host (gswrongdef) passed 12/12; it now requires the allow-list refusal naming
+# storage.googleapis.com:443. No key needed URI-encoding, so a space sent as '+' (plusspace) or
+# '+'/'=' left bare (plusraw) passed 12/12; a key "dir sp/a+b=c.parquet" is now read under both
+# addressings and its request path checked in the fixture's log. 11 are dated by seven
+# mutations, each the same names in both twins. 5 arrive never. Neither pgc_skip name
+# (pyarrow, the virtual-host name) gets a row: CI emits neither.
+#
+# ledger rows 7541 + 16 = 7557
+# never 4330 + 5 = 4335
+# dated 3211 + 11 = 3222
+# 4335 + 3222 == 7557
+#
+# #1341, objstore_stash_recovery seeded, with its twin (OffgridwithJD): 4335 -> 4344,
+# and suites_not_covered 43 -> 42. The suite had no rows. It enters with 18 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# "and it did not stop at the start guard" counted 'interrupted mid-move', the refusal's
+# text before #393's fix, which nothing prints any more: with the guard turned back into a
+# refusal, eight checks went red and it stayed green. The debris was a byte-identical copy,
+# so a guard that restored every stash over the installed module passed 17/17; the debris
+# is now a different valid module and the installed one is compared with the pristine copy.
+# Both restore slices parked the module at .away, so a guard that walked .away alone passed
+# 17/17; the only-copy slice now parks it at .probe. Each slice also starts from the
+# pristine copy, so one slice's damage no longer reddens the next one's premises.
+# 9 are dated by four mutations of objstore_module.sh's guard, each reddening the same
+# names in both twins. 9 arrive never.
+#
+# ledger rows 7557 + 18 = 7575
+# never 4335 + 9 = 4344
+# dated 3222 + 9 = 3231
+# 4344 + 3231 == 7575
+#
+# #1341, objstore_s3_read seeded, with its twin (OffgridwithJD): 4344 -> 4348,
+# and suites_not_covered 42 -> 41. The suite had no rows. It enters with 17 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after these fixes.
+# The no-environment and 403 refusals read a bare 28000, which five guards share: with s3://
+# falling back to a default host (noepreq) the region guard refused instead and main passed
+# 12/12. Each refusal now names its cause. Region handling was never tested: dropping the
+# AWS_DEFAULT_REGION fallback, swapping its precedence, signing us-east-1 when no region is set,
+# or signing with an empty secret each passed 12/12, and four checks now pin them. Nothing
+# read the crash count, and an abort() in abort cleanup passed every SQLSTATE check (main's
+# shell exited FATAL on the restart with no red): survival now reads the server log. The https
+# check's name interpolated the build's want, so the grader could not read it. 13 are dated
+# by twelve mutations, each the same names in both twins. 4 arrive never. Neither the
+# pyarrow pgc_skip name nor the optional real-S3 check gets a row: CI emits neither.
+#
+# ledger rows 7575 + 17 = 7592
+# never 4344 + 4 = 4348
+# dated 3231 + 13 = 3244
+# 4348 + 3244 == 7592
+#
+# #1341, objstore_tls_read seeded, with its twin (OffgridwithJD): 4348 -> 4355,
+# and suites_not_covered 41 -> 40. The suite had no rows. It enters with 29 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after four fixes.
+# A refusal was any 08006 plus a reason grepped anywhere in the output: with the module
+# speaking cleartext to the TLS port (httpfall), main's three "refused with 08006" checks
+# passed; each refusal now also wants the module's own TLS message naming the URL, and the
+# whole DETAIL, OpenSSL's reason for that certificate. A self-signed leaf had no arm; it has
+# one now. Trust from SSL_CERT_DIR was never read: with only SSL_CERT_FILE honoured
+# (nocadir), main passed 19/19; arm E restarts with SSL_CERT_DIR alone and reads the good
+# server. Nothing read the server log: with abort() in place of the verification refusal,
+# the crash also reddened the next arm's premise and D, and "returned no rows" passed; each
+# refusal now waits for SELECT 1, and a survival check counts "terminated by signal".
+# 22 are dated by six mutations, each the same names in both twins. 7 arrive never.
+# Neither pgc_skip name (pyarrow, openssl) gets a row: CI emits neither.
+#
+# ledger rows 7592 + 29 = 7621
+# never 4348 + 7 = 4355
+# dated 3244 + 22 = 3266
+# 4355 + 3266 == 7621
+#
+# #1341, objstore_http_read seeded, with its twin (OffgridwithJD): 4355 -> 4363,
+# and suites_not_covered 40 -> 39. The suite had no rows. It enters with 24 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike). Main's arm C took
+# the SQLSTATE alone, so a dropped Range-ignored guard (noguard200) passed on 08P01 from
+# "unexpected HTTP status 200"; each refusal now also needs its own guard's message. Short
+# 206 bodies, EOF mid-body and the keep-alive retry had no fixture: noshort, eofok, noretry
+# and alwaysretry each passed 18/18 on main. The fixture server gains /short/, /trunc/,
+# /dropka/ and /hangup/ modes, inert unless a path names them, and a survival check reads
+# the crash count after waiting for the server. 16 are dated by ten mutations, each the
+# same names in both twins. 8 arrive never. The pgc_skip name (pyarrow) gets no row:
+# CI does not emit it.
+#
+# ledger rows 7621 + 24 = 7645
+# never 4355 + 8 = 4363
+# dated 3266 + 16 = 3282
+# 4363 + 3282 == 7645
+#
+# #1341, objstore_sink_write seeded, with its twin (OffgridwithJD): 4363 -> 4387,
+# and suites_not_covered 39 -> 38. The suite had no rows. It enters with 35 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after five fixes.
+# The fixture server completed an upload from every stored part, whatever the complete listed,
+# so dropping the last part from the list (droplist) passed 30/30; it now assembles the listed
+# parts, each checked against its ETag, as S3 does. No check made a part fail, so a refused
+# part read as stored (partok) passed 30/30; a __part_status__ hook and four checks want the
+# part check's own 58030, no key and no open upload. Abort was read from the DELETE count, so
+# an abort naming the wrong upload (abortwrongid) passed 30/30; the server now keeps its open
+# uploads and two checks want none. The Arrow arm read only export_arrow > 0 (green under
+# shortput); the object is imported back. Every oracle is the generator, not the scanned
+# table, and each refusal wants its own guard's message. 11 are dated by eight mutations,
+# each the same names in both twins. 24 arrive never.
+#
+# ledger rows 7645 + 35 = 7680
+# never 4363 + 24 = 4387
+# dated 3282 + 11 = 3293
+# 4387 + 3293 == 7680
+#
+# #1722, a cancelled remote parallel export aborts its workers' uploads (OffgridwithJD):
+# 3907 -> 3907. One check joins objstore_sink_write, red first on all five majors in both
+# twins: after the cancel the fixture server still held 4 open multipart uploads under cx/,
+# because SIGTERM ends each worker in FATAL, which never unwinds to the writers' PG_CATCH
+# and its sink abort. It is dated by dropping the new before_shmem_exit registration, which
+# reddens it by name in both twins.
+#
+# ledger rows 7680 + 1 = 7681
+# never 4387 + 0 = 4387
+# dated 3293 + 1 = 3294
+# 4387 + 3294 == 7681
+#
+# #1727's review (jdatcmd): two more checks. os_wait passed no WL_TIMEOUT, so
+# WaitLatchOrSocket ignored its 5 s slice and a silent store never counted a quiet slice;
+# under proc_exit interrupts are held, so a worker SIGTERM ended stayed in its abort until
+# the store answered (all four alive at +92 s). "terminate: export workers exit within
+# 75 s while the store is silent" stops the fixture mid-upload and times the workers'
+# exit: red without WL_TIMEOUT (4 left after 91 s, both twins), 61 s with it; that
+# pre-fix wait dates it. And the cancel arm now cancels only once all four uploads are
+# registered on the server, "all four workers' uploads were open before the cancel": an
+# upload whose POST ?uploads was in flight is #1743's window, and it flaked the
+# open-upload check in CI. It is not a premise: a sink that never flushes a part (no
+# export opens a multipart upload, one line) reddens it, which dates it.
+#
+# ledger rows 7681 + 2 = 7683
+# never 4387 + 0 = 4387
+# dated 3294 + 2 = 3296
+# 4387 + 3296 == 7683
+#
+# #1341, native_fetch_interrupt seeded, with its twin (OffgridwithJD): 4387 -> 4387,
+# and suites_not_covered 38 -> 37. The suite had no rows. It enters with 3 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after one fix: the
+# checks read the function bodies with their comments in them, so the guard written as
+# /* CHECK_FOR_INTERRUPTS(); */, or folded into the comment above it, passed 3/3 on main, and so
+# did the lookup's catalog read commented out. Both bodies now have their comments blanked,
+# newlines kept. All 3 are dated by three mutations, each the same names in both twins; none arrive never.
+#
+# ledger rows 7683 + 3 = 7686
+# never 4387 + 0 = 4387
+# dated 3296 + 3 = 3299
+# 4387 + 3299 == 7686
+#
+# #1341, objstore_module seeded, with its twin (OffgridwithJD): 4387 -> 4409,
+# and suites_not_covered 37 -> 36. The suite had no rows. It enters with 30 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# The remote-read checks took any of seven phrases, "is not supported" among them: with the
+# module no longer handling s3://, gs:// or (built with OpenSSL) https://, main passed 30/30.
+# Each URL's refusal is now pinned by SQLSTATE and message, the https one by whether the
+# module links libssl. "a local path still works" counted a columnar table, which never
+# reaches read_parquet's dispatch: with every local read_parquet refused, main passed 30/30.
+# It now reads back a file export_parquet wrote. 8 are dated by seven mutations, each the
+# same names in both twins. 22 arrive never. The unmovable-module skip gets no row: CI
+# emits none. Nor does the start guard's failure, now under a literal name the twin
+# reads every run (one extra test): green runs never record it.
+#
+# ledger rows 7686 + 30 = 7716
+# never 4387 + 22 = 4409
+# dated 3299 + 8 = 3307
+# 4409 + 3307 == 7716
+#
+# #1341, phase2 seeded, with its twin (OffgridwithJD): 4409 -> 4434,
+# and suites_not_covered 36 -> 35. The suite had no rows. It enters with 47 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after two fixes.
+# The codec loop round-tripped each codec and never read which one was stored, since the
+# native conversion dropped the 2.2 catalog's check and left $code computed for nothing:
+# with the session's setting ignored, or no block codec ever applied, all 42 passed. Each
+# codec now reads its block_codec (four checks). The filter checks held with the zone
+# maps ruling out nothing, because exact selection returns the same rows; a range inside
+# the last vector must now decode one of the three (one check).
+# 22 are dated by seven mutations of the write path, the compression fallback, the zone
+# maps and the drop cleanup, each reddening the same names in both twins. 25 arrive never.
+#
+# ledger rows 7716 + 47 = 7763
+# never 4409 + 25 = 4434
+# dated 3307 + 22 = 3329
+# 4434 + 3329 == 7763
+#
+# #1341, phase3 seeded, with its twin (OffgridwithJD): 4434 -> 4465,
+# and suites_not_covered 35 -> 34. The suite had no rows. It enters with 37 checks
+# from five-major runs (all green on each of 15-19, shell and twin alike), after three fixes.
+# Its savepoint arm rolled back only completed statements, whose buffers executor end had
+# already flushed under the subtransaction, so the abort's xmin verdict hid them (#1441):
+# with the savepoint's discard of pending writes or pending deletes removed, all 32 passed.
+# An INSERT and a DELETE that fail partway inside a savepoint must now leave nothing, in
+# the transaction and after it (three checks). The delete arm said its range straddled a
+# vector boundary and nothing read one: with 16384-row vectors all 32 passed; the zone
+# maps' maxima are now read (one check). And a DELETE whose RETURNING reads the table in
+# a block that commits into one that aborts must see the rows deleted so far, as heap
+# does: with the commit not handing the read's written marks to the parent, no later
+# read wrote them again, and the reads summed 19999 against heap's 14950 (one check).
+# 6 are dated by six mutations of the subtransaction callback, the vector size and the
+# drop cleanup, each reddening the same names in both twins. 31 arrive never.
+#
+# ledger rows 7763 + 37 = 7800
+# never 4434 + 31 = 4465
+# dated 3329 + 6 = 3335
+# 4465 + 3335 == 7800
+checks_never_observed_red 4465
diff --git a/test/craft_parquet_codecs.py b/test/craft_parquet_codecs.py
new file mode 100644
index 000000000..724419473
--- /dev/null
+++ b/test/craft_parquet_codecs.py
@@ -0,0 +1,56 @@
+"""Write the native_parquet_codecs fixtures into DIR (argv[1]), for both twins.
+
+c_none / c_gzip / c_zstd / c_lz4.parquet hold the same 20000 rows, each written with one
+codec; codecs.txt records the compression pyarrow actually stored for each file's first
+column (one " " line), so a test can prove which codec it read.
+badhdr.parquet is a gzip page whose header declares uncompressed_page_size -1.
+
+The rows are the generator both twins also compute in SQL:
+ id = g, grp = g % 50, val = g * 1.5, s = 'row-' || (g % 100), g in 0 .. 19999
+Written fresh for pgColumnar.
+"""
+import sys
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+
+W = sys.argv[1]
+N = 20000
+tbl = pa.table({
+ "id": pa.array(range(N), pa.int32()),
+ "grp": pa.array([i % 50 for i in range(N)], pa.int32()),
+ "val": pa.array([i * 1.5 for i in range(N)], pa.float64()),
+ "s": pa.array([f"row-{i % 100}" for i in range(N)]),
+})
+with open(f"{W}/codecs.txt", "w") as out:
+ for codec in ["none", "gzip", "zstd", "lz4"]:
+ pq.write_table(tbl, f"{W}/c_{codec}.parquet", compression=codec)
+ c = pq.ParquetFile(f"{W}/c_{codec}.parquet").metadata.row_group(0).column(0).compression
+ out.write(f"{codec} {c}\n")
+
+# A gzip page whose header's uncompressed_page_size (field 2) is rewritten to zigzag(-1).
+pq.write_table(pa.table({"a": pa.array([1, 2, 3], pa.int32())}),
+ f"{W}/badhdr.parquet", compression="gzip", use_dictionary=False)
+raw = bytearray(open(f"{W}/badhdr.parquet", "rb").read())
+pos = 4 # the first PageHeader follows the "PAR1" magic
+last = 0
+patched = False
+for _ in range(6):
+ b = raw[pos]
+ pos += 1
+ t, d = b & 0x0F, b >> 4
+ if t == 0:
+ break
+ fid = last + d if d else None
+ last = fid
+ start = pos
+ while raw[pos] & 0x80:
+ pos += 1
+ pos += 1
+ if fid == 2:
+ assert pos - start == 1, (start, pos)
+ raw[start] = 0x01 # zigzag(-1)
+ patched = True
+ break
+assert patched, "did not find uncompressed_page_size"
+open(f"{W}/badhdr.parquet", "wb").write(bytes(raw))
diff --git a/test/craft_parquet_hardening.py b/test/craft_parquet_hardening.py
new file mode 100644
index 000000000..d0570f8d7
--- /dev/null
+++ b/test/craft_parquet_hardening.py
@@ -0,0 +1,145 @@
+"""craft_parquet_hardening.py DIR: write the Parquet files test/native_parquet_hardening.sh
+and its pytest twin read: the narrowing binds (out of range and in range) and a
+two-column file whose row group carries one column chunk. One writer for both
+twins, so neither can drift from the other. Needs pyarrow."""
+import sys
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+
+W = sys.argv[1]
+
+# ---- narrowing: out-of-range values must raise, not wrap --------------------
+# int2 bound over INT32: 40000 does not fit int16 (would wrap to -25536)
+pq.write_table(pa.table({"c": pa.array([30000, 40000], pa.int32())}),
+ f"{W}/i2_bad.parquet")
+# same bind, all values in range: must keep working
+pq.write_table(pa.table({"c": pa.array([-30000, 0, 30000], pa.int32())}),
+ f"{W}/i2_ok.parquet")
+# time bound over INT64 micros: beyond one day is not a time
+pq.write_table(pa.table({"c": pa.array([0, 86400000000 + 1], pa.int64())}),
+ f"{W}/time_bad.parquet")
+pq.write_table(pa.table({"c": pa.array([0, 3600000000], pa.int64())}),
+ f"{W}/time_ok.parquet")
+# date bound over INT32 days since epoch. INT32 max does NOT overflow the epoch
+# offset (2147483647 - 10957 stays in range); it is rejected by IS_VALID_DATE.
+pq.write_table(pa.table({"c": pa.array([0, 2147483647], pa.int32())}),
+ f"{W}/date_bad.parquet")
+# INT32 min is what overflows the subtraction itself, exercising the other branch
+pq.write_table(pa.table({"c": pa.array([0, -2147483648], pa.int32())}),
+ f"{W}/date_ovf.parquet")
+pq.write_table(pa.table({"c": pa.array([0, 19000], pa.int32())}),
+ f"{W}/date_ok.parquet")
+# timestamp bound over INT64 micros: INT64 min overflows the epoch-offset
+# subtraction. INT64 max does NOT -- it still lands inside PostgreSQL's timestamp
+# range (year ~294276), so it is deliberately not the case tested here.
+pq.write_table(pa.table({"c": pa.array([0, -9223372036854775808], pa.int64())}),
+ f"{W}/ts_bad.parquet")
+pq.write_table(pa.table({"c": pa.array([0, 1700000000000000], pa.int64())}),
+ f"{W}/ts_ok.parquet")
+
+# ---- malformed row-group chunk list ----------------------------------------
+pq.write_table(pa.table({"a": pa.array([1, 2, 3], pa.int32()),
+ "b": pa.array([4, 5, 6], pa.int32())}),
+ f"{W}/two.parquet")
+
+# Walk the Thrift-compact footer to the exact byte holding the row group's
+# `columns` list header, then claim it holds one fewer chunk than it does.
+# Blind byte-searching is not reliable here: the same byte value occurs in the
+# data pages, so the header has to be located structurally.
+raw = bytearray(open(f"{W}/two.parquet", "rb").read())
+flen = int.from_bytes(raw[-8:-4], "little")
+base = len(raw) - 8 - flen # start of FileMetaData
+pos = base
+
+def varint():
+ global pos
+ shift = 0; out = 0
+ while True:
+ b = raw[pos]; pos += 1
+ out |= (b & 0x7F) << shift
+ if not b & 0x80:
+ return out
+ shift += 7
+
+def zigzag():
+ u = varint()
+ return (u >> 1) ^ -(u & 1)
+
+def list_hdr():
+ global pos
+ b = raw[pos]; pos += 1
+ size = (b >> 4) & 0x0F
+ et = b & 0x0F
+ if size == 0x0F:
+ size = varint()
+ return size, et
+
+def skip(t):
+ global pos
+ if t in (1, 2): return # bool: value is in the header
+ if t == 3: pos += 1 # byte
+ elif t in (4, 5, 6): zigzag() # i16/i32/i64
+ elif t == 7: pos += 8 # double
+ elif t == 8: pos += varint() # binary
+ elif t in (9, 10):
+ n, et = list_hdr()
+ for _ in range(n): skip(et)
+ elif t == 12:
+ last = 0
+ while True:
+ ft, _fid, last = field(last)
+ if ft == 0: break
+ skip(ft)
+
+def field(last):
+ global pos
+ b = raw[pos]; pos += 1
+ if b == 0:
+ return 0, 0, last
+ t = b & 0x0F
+ delta = b >> 4
+ fid = last + delta if delta else zigzag()
+ return t, fid, fid
+
+# FileMetaData -> field 4 (row_groups) -> first RowGroup -> field 1 (columns)
+target = None
+last = 0
+while True:
+ ft, fid, last = field(last)
+ if ft == 0:
+ break
+ if fid == 4 and ft == 9: # row_groups: list
+ nrg, _et = list_hdr()
+ assert nrg >= 1, nrg
+ rlast = 0
+ while True:
+ rft, rfid, rlast = field(rlast)
+ if rft == 0:
+ break
+ if rfid == 1 and rft == 9: # columns: list
+ target = pos # byte holding this list's header
+ n, et = list_hdr()
+ assert n == 2, f"expected 2 column chunks, got {n}"
+ skip(et) # past ColumnChunk[0]
+ start2 = pos
+ skip(et) # past ColumnChunk[1]
+ end2 = pos
+ break
+ skip(rft)
+ break
+ skip(ft)
+
+assert target is not None, "row group columns list not found"
+# Remove ColumnChunk[1] outright and say so in the header, so the footer stays a
+# well-formed Thrift struct. Decrementing the count alone would desync the parser
+# and fail for an unrelated reason; the only defect must be that the row group
+# carries fewer chunks than the schema has leaves.
+assert (raw[target] >> 4) == 2, hex(raw[target])
+raw[target] = (1 << 4) | (raw[target] & 0x0F)
+removed = end2 - start2
+del raw[start2:end2]
+raw[-8:-4] = (flen - removed).to_bytes(4, "little")
+open(f"{W}/short_chunks.parquet", "wb").write(bytes(raw))
+print(f" columns list at {target}: 2 chunks -> 1, removed {removed} footer bytes",
+ file=sys.stderr)
diff --git a/test/craft_parquet_projection.py b/test/craft_parquet_projection.py
new file mode 100644
index 000000000..ebc780474
--- /dev/null
+++ b/test/craft_parquet_projection.py
@@ -0,0 +1,39 @@
+"""Write the files test/native_parquet_projection.sh and its pytest twin read.
+
+ python3 test/craft_parquet_projection.py DIR
+
+DIR/proj.parquet a, b, c, d over 5000 rows in row groups of 1000, from the same
+ generator the suites rebuild in SQL as their oracle:
+ a = i, b = 2i, c = 's' || (i % 20), d = i / 3.0
+DIR/corrupt.parquet g, bad over 1000 rows, with the second column's data page
+ smashed, so decoding `bad` fails while `g` and the footer stay
+ intact.
+
+Both harnesses run this one crafter, so a file can never differ between them.
+"""
+import sys
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+
+W = sys.argv[1]
+n = 5000
+pq.write_table(pa.table({
+ "a": pa.array(range(n), pa.int32()),
+ "b": pa.array([i * 2 for i in range(n)], pa.int64()),
+ "c": pa.array([f"s{i % 20}" for i in range(n)]),
+ "d": pa.array([i / 3.0 for i in range(n)], pa.float64()),
+}), f"{W}/proj.parquet", row_group_size=1000)
+
+pq.write_table(pa.table({"g": pa.array(range(1000), pa.int32()),
+ "bad": pa.array(range(1000), pa.int32())}),
+ f"{W}/corrupt.parquet", row_group_size=1000, use_dictionary=False)
+md = pq.ParquetFile(f"{W}/corrupt.parquet").metadata.row_group(0).column(1)
+off = md.data_page_offset
+raw = bytearray(open(f"{W}/corrupt.parquet", "rb").read())
+# smash the second column's page header and the start of its data, leaving the
+# first column and the footer intact
+for i in range(off, off + 32):
+ raw[i] = 0xFF
+open(f"{W}/corrupt.parquet", "wb").write(bytes(raw))
+print(f" corrupted column 'bad' at offset {off}", file=sys.stderr)
diff --git a/test/craft_parquet_streaming.py b/test/craft_parquet_streaming.py
new file mode 100644
index 000000000..dc1267651
--- /dev/null
+++ b/test/craft_parquet_streaming.py
@@ -0,0 +1,166 @@
+"""Write every Parquet file native_parquet_streaming.sh and its twin read, into DIR.
+
+Usage: craft_parquet_streaming.py DIR
+
+Exit status, as the shell reads it:
+ 0 every file was written
+ 3 no room even for the oversized sparse file's metadata
+ 4 the sparse file did not reach its logical size (no sparse files here)
+ 5 the footer offset to patch is ambiguous; refusing to guess
+ 6 a crafted file is missing after writing
+ 7 the source file has no dictionary page to duplicate
+
+3 and 4 mean the box cannot build the oversized file, which the shell records as a
+named skip; any other failure means the crafted files are not all there, which it
+records as a failed check rather than letting each refusal pass on "could not open
+file". The construction of each file, and why, is described beside it below.
+"""
+import os
+import sys
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+
+W = sys.argv[1]
+
+
+def parts(path):
+ raw = open(path, "rb").read()
+ n = int.from_bytes(raw[-8:-4], "little")
+ return raw[-8 - n:-8], n, raw[4:-8 - n] # footer, its length, page area
+
+
+# ---- a real small file, and an oversized sparse one carrying its footer ------------
+src = os.path.join(W, "small.parquet")
+pq.write_table(pa.table({"id": pa.array([1, 2, 3], pa.int32()),
+ "v": pa.array(["a", "b", "c"])}), src)
+data = open(src, "rb").read()
+metalen = int.from_bytes(data[-8:-4], "little")
+meta = data[-8 - metalen:-8]
+
+# comfortably over MaxAllocSize (1GB - 1), which is what palloc() rejected
+SIZE = 1600 * 1024 * 1024
+st = os.statvfs(W)
+if st.f_bavail * st.f_frsize < 64 * 1024 * 1024:
+ sys.exit(3)
+big = os.path.join(W, "huge_sparse.parquet")
+with open(big, "wb") as f:
+ f.write(b"PAR1")
+ f.truncate(SIZE) # the hole; never read by the footer path
+ f.seek(SIZE - 8 - metalen)
+ f.write(meta)
+ f.write(metalen.to_bytes(4, "little"))
+ f.write(b"PAR1")
+if os.path.getsize(big) != SIZE:
+ sys.exit(4)
+
+# ---- crafted files: each keeps a valid footer and breaks one thing the page loop
+# must not trust. The footer comes from a real uncompressed file, so a zeroed page
+# area really parses as page headers rather than failing in the codec first.
+plain = os.path.join(W, "plain.parquet")
+pq.write_table(pa.table({"id": pa.array(list(range(200)), pa.int32())}),
+ plain, compression="none")
+meta, metalen, body = parts(plain)
+
+
+def build(name, body_bytes, m=None, ml=None):
+ with open(os.path.join(W, name), "wb") as f:
+ f.write(b"PAR1")
+ f.write(body_bytes)
+ f.write(m if m is not None else meta)
+ f.write((ml if ml is not None else metalen).to_bytes(4, "little"))
+ f.write(b"PAR1")
+
+
+# 1. a large zero-filled page area of a REQUIRED column (no definition levels, so the
+# zero-value page is reached), sparse: each zero byte parses as an empty v1 data
+# page, so without the progress guard the loop walks it one byte at a time.
+req = os.path.join(W, "plain_req.parquet")
+pq.write_table(pa.table({"id": pa.array(list(range(200)), pa.int32())},
+ schema=pa.schema([pa.field("id", pa.int32(), nullable=False)])),
+ req, compression="none")
+rmeta, rmetalen, rbody = parts(req)
+ZSIZE = 1600 * 1024 * 1024
+with open(os.path.join(W, "zero_pages.parquet"), "wb") as f:
+ f.write(b"PAR1")
+ f.truncate(ZSIZE - 8 - rmetalen)
+ f.seek(ZSIZE - 8 - rmetalen)
+ f.write(rmeta)
+ f.write(rmetalen.to_bytes(4, "little"))
+ f.write(b"PAR1")
+
+
+# 2. a page header (built by hand, Thrift compact) declaring a compressed_size far
+# past end of file, written over the start of the chunk the footer points at.
+def zz(v):
+ u = (v << 1) ^ (v >> 63) if v < 0 else (v << 1)
+ out = bytearray()
+ while True:
+ b = u & 0x7F
+ u >>= 7
+ out.append(b | (0x80 if u else 0))
+ if not u:
+ return bytes(out)
+
+
+def fh(delta, t):
+ return bytes([(delta << 4) | t])
+
+
+I32, STRUCT, STOP = 5, 12, b"\x00"
+BIG = 500 * 1024 * 1024 # past end of file, but under MaxAllocSize
+synth = (fh(1, I32) + zz(0) + fh(1, I32) + zz(64) + fh(1, I32) + zz(BIG)
+ + fh(2, STRUCT) + fh(1, I32) + zz(200) + fh(1, I32) + zz(0)
+ + fh(1, I32) + zz(3) + fh(1, I32) + zz(3) + STOP + STOP)
+build("bad_size.parquet", synth + body[len(synth):])
+
+# 3. the page area is noise, so the page header does not parse at all.
+build("noise_pages.parquet", bytes((i * 37 + 11) & 0xFF for i in range(len(body))))
+
+# 4. the footer points the SECOND column chunk's start offset to -2^20: negative, so
+# it passes the loop's "pos < len" and reaches the header read's offset check.
+two = os.path.join(W, "two.parquet")
+pq.write_table(pa.table({"id": pa.array(list(range(200)), pa.int32()),
+ "v": pa.array(["r%04d" % i for i in range(200)])}),
+ two, compression="none", use_dictionary=False)
+tmeta, tmetalen, tbody = parts(two)
+tcol = pq.ParquetFile(two).metadata.row_group(0).column(1)
+tstart = tcol.dictionary_page_offset or tcol.data_page_offset
+pat = zz(tstart)
+if tmeta.count(pat) != 1:
+ sys.exit(5)
+patched = tmeta.replace(pat, zz(-(1 << 20)))
+build("bad_offset.parquet", tbody, patched, len(patched))
+
+# 5. two dictionary pages in one chunk (the format allows at most one).
+dmd = pq.ParquetFile(plain).metadata.row_group(0).column(0)
+if not dmd.dictionary_page_offset or dmd.data_page_offset <= dmd.dictionary_page_offset:
+ sys.exit(7)
+dlen = dmd.data_page_offset - dmd.dictionary_page_offset
+doff = dmd.dictionary_page_offset - 4
+build("two_dicts.parquet", body[:doff] + body[doff:doff + dlen] * 2 + body[doff + dlen:])
+
+# 6. a DATA_PAGE_V2 whose declared level lengths exceed the page (see the shell for
+# why this rejection does not isolate the v2 guard).
+synth_v2 = (fh(1, I32) + zz(3) + fh(1, I32) + zz(64) + fh(1, I32) + zz(32)
+ + fh(5, STRUCT) + fh(1, I32) + zz(200) + fh(1, I32) + zz(0)
+ + fh(1, I32) + zz(200) + fh(1, I32) + zz(0) + fh(1, I32) + zz(64)
+ + fh(1, I32) + zz(0) + fh(1, 2) + STOP + STOP)
+build("v2_levels.parquet", synth_v2 + body[len(synth_v2):])
+
+# an all-null column: pyarrow writes it with an EMPTY dictionary page
+pq.write_table(pa.table({"c": pa.array([None] * 100, pa.int32())}),
+ os.path.join(W, "allnull.parquet"), use_dictionary=True, compression="none")
+
+# #620: four row groups of 1000 rows, for the FDW's Row Groups Decoded counter
+n = 4000
+pq.write_table(pa.table({"id": pa.array(range(n), pa.int32()),
+ "v": pa.array([i * 2 for i in range(n)], pa.int64())}),
+ os.path.join(W, "stream_groups.parquet"), row_group_size=1000)
+if pq.ParquetFile(os.path.join(W, "stream_groups.parquet")).num_row_groups != 4:
+ sys.exit(6)
+
+for name in ("zero_pages", "bad_size", "noise_pages", "bad_offset", "two_dicts",
+ "v2_levels", "allnull", "stream_groups"):
+ if not os.path.exists(os.path.join(W, name + ".parquet")):
+ sys.exit(6)
diff --git a/test/lib.sh b/test/lib.sh
index 39c017da6..d1d0567c1 100755
--- a/test/lib.sh
+++ b/test/lib.sh
@@ -401,10 +401,24 @@ pgc_setup() {
esac
echo "-- initdb"
- pgc_pg "initdb -D '$PGC_PGDATA' -A trust" >/dev/null 2>&1
+ # -U postgres, because every psql below connects as postgres (#1341). initdb's
+ # default superuser is whoever runs it, which is postgres only under the
+ # runuser above; a non-root run -- the pytest-cluster job, which chowns
+ # pkglibdir precisely so that it can stay non-root, driving a shell suite as
+ # test_objstore_stash_recovery.py does -- got a cluster with no postgres role,
+ # and every start attempt "failed". pgc_cluster.initdb has always passed it.
+ pgc_pg "initdb -D '$PGC_PGDATA' -A trust -U postgres" >/dev/null 2>&1
{
echo "port=$PGC_PORT"
echo "listen_addresses='127.0.0.1'"
+ # THE SOCKET DIRECTORY, pinned to this cluster's own datadir, as
+ # pgc_cluster.py pins it. A packaged postgres defaults it to
+ # /var/run/postgresql, which only postgres can write: under runuser that is
+ # fine, but a non-root, non-postgres run -- the pytest-cluster job driving
+ # objstore_module.sh from test_objstore_stash_recovery.py -- FATALs on the
+ # lock file, and pg_ctl says only "could not start server" (#1717). Every
+ # connection here is TCP on 127.0.0.1, so nothing reads the socket's path.
+ echo "unix_socket_directories='$PGC_PGDATA'"
echo "shared_preload_libraries='pgcolumnar'"
# Deterministic text output so heap and columnar hashes match.
echo "extra_float_digits=3"
diff --git a/test/lossy_arms.tsv b/test/lossy_arms.tsv
index 925a8e6fb..ac504acf2 100644
--- a/test/lossy_arms.tsv
+++ b/test/lossy_arms.tsv
@@ -133,8 +133,6 @@ native_rewrite group numbers issued after a rewrite are past every earlier one
native_truncate file shrank
native_truncate GUC on: file shrank
native_upgrade_converge fresh $TARGET install has objects to compare
-native_vecdecode a ruled-out vector is not decoded
-native_zonemap_narrow zone_map reads scale with predicates, not table width (wide ~= narrow)
objstore_listing the prefix listing paged (more than one list-type=2 request)
objstore_module premise: the real module was restored afterwards
objstore_sink_write parallel: every worker PUT went to the prefix
diff --git a/test/native_fetch_interrupt.sh b/test/native_fetch_interrupt.sh
index 613dbebf9..a68c16f7a 100755
--- a/test/native_fetch_interrupt.sh
+++ b/test/native_fetch_interrupt.sh
@@ -31,8 +31,16 @@ pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/src"
-# The body of pgcolumnar_fetch_row, from its definition to its closing brace.
-body="$(awk '/^pgcolumnar_fetch_row\(/{p=1} p{print} p&&/^}/{exit}' "$SRC/columnar_reader.c")"
+# The body of a function in columnar_reader.c, from its definition to its closing
+# brace, with its comments blanked, multi-line ones included: a guard commented out
+# of the code is not a guard, and every check below passed with the call written
+# as /* CHECK_FOR_INTERRUPTS(); */ (#1341). Each line keeps its place, so the
+# placement check still compares line numbers of the code itself.
+fn_body() {
+ awk -v fn="$1" '$0 ~ "^" fn "\\(" {p=1} p{print} p&&/^}/{exit}' "$SRC/columnar_reader.c" |
+ perl -0777 -pe 's{/\*.*?\*/}{ my $c = $&; $c =~ tr/\n//cd; $c }gse; s{//[^\n]*}{}g'
+}
+body="$(fn_body pgcolumnar_fetch_row)"
# The guard has to be in this function, not merely in the file. The interrupt
# checks elsewhere in columnar_reader.c are on the scan/decode path, which the
@@ -50,14 +58,14 @@ check "pgcolumnar_fetch_row carries an interrupt check" \
# the catalog read moved, so this arm cannot rot into comparing against a
# call that no longer does the work.
cfi_line="$(printf '%s\n' "$body" | grep -n 'CHECK_FOR_INTERRUPTS' | head -1 | cut -d: -f1)"
-# match the CALL, with its paren -- the bare name also appears in a comment
-# above the call, and a comment must not satisfy a placement check
+# match the CALL, with its paren; comments are already blanked, so a comment
+# that names it cannot satisfy a placement check
rgl_line="$(printf '%s\n' "$body" | grep -n 'pgcolumnar_lookup_row_group(' | head -1 | cut -d: -f1)"
check "the interrupt check precedes the row-group resolution" \
"$( [ -n "$cfi_line" ] && [ -n "$rgl_line" ] && [ "$cfi_line" -lt "$rgl_line" ] && echo yes || echo no )" \
"yes"
-lookup_body="$(awk '/^pgcolumnar_lookup_row_group\(/{p=1} p{print} p&&/^}/{exit}' "$SRC/columnar_reader.c")"
+lookup_body="$(fn_body pgcolumnar_lookup_row_group)"
check "the row-group resolution is where the catalog read lives" \
"$(printf '%s\n' "$lookup_body" | grep -c 'PgColumnarReadRowGroupList' | awk '{print ($1>=1)?"yes":"no"}')" \
"yes"
diff --git a/test/native_fold_deferral.sh b/test/native_fold_deferral.sh
index 1df9062fd..2d7b0810c 100755
--- a/test/native_fold_deferral.sh
+++ b/test/native_fold_deferral.sh
@@ -29,9 +29,23 @@ GUC="SET pgcolumnar.enable_ungrouped_vector_agg = on;"
NP=4 # payload columns
ROWS=120000
-sq() { # scalar under this suite's GUC; tail -1 drops the SET tag
- env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -At -c "$GUC $1" 2>/dev/null | tail -1
+# sq SQL -> the result under this suite's GUC, or nothing when the statement failed.
+# It used to be `... | tail -1`, which read a failed statement as whatever line came
+# last; with ON_ERROR_STOP and the exit status, a failure is empty, and every
+# comparison below refuses an empty side (check_text) or a non-number (check_num).
+sq() {
+ local out
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -v ON_ERROR_STOP=1 -c "$GUC $1" 2>/dev/null)" || return 0
+ tail -1 <<<"$out"
+}
+# rq SQL -> the result on the row path (the vectorized aggregate off), the same way.
+rq() {
+ local out
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -v ON_ERROR_STOP=1 \
+ -c "SET pgcolumnar.enable_ungrouped_vector_agg = off; $1" 2>/dev/null)" || return 0
+ tail -1 <<<"$out"
}
explain_line() { # explain_line
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
@@ -42,7 +56,11 @@ explain_line() { # explain_line
psql_run "CREATE TABLE fd_t (q int4, p1 float8, p2 float8, p3 float8, p4 float8) USING pgcolumnar;"
psql_run "SELECT pgcolumnar.set_options('fd_t', stripe_row_limit => 20000);"
psql_run "INSERT INTO fd_t SELECT 1 + g % 100, g, g*2, g*3, g*4 FROM generate_series(1,$ROWS) g;"
-psql_run "CREATE TABLE fd_heap AS SELECT * FROM fd_t;"
+# THE HEAP MIRROR IS WRITTEN FROM THE GENERATOR, NOT COPIED FROM fd_t. A CTAS from
+# fd_t reads it through the scan under test, so a stored value that was wrong was
+# wrong on both sides: with the writer storing one p1 off by one, main passed 13/13.
+psql_run "CREATE TABLE fd_heap (q int4, p1 float8, p2 float8, p3 float8, p4 float8);"
+psql_run "INSERT INTO fd_heap SELECT 1 + g % 100, g, g*2, g*3, g*4 FROM generate_series(1,$ROWS) g;"
# plain aggregate tlist entries: an expression OVER aggregates disqualifies
# the vectorized path entirely (the #602 tlist trap) and would row-path this
@@ -50,7 +68,8 @@ QSEL="SELECT sum(p1), sum(p2), sum(p3), sum(p4) FROM fd_t WHERE q <= 1"
QHI="SELECT sum(p1), sum(p2), sum(p3), sum(p4) FROM fd_t WHERE q <= 99"
# ---- premises ---------------------------------------------------------------
-check_text "premise: the selective query takes the fold (ANALYZE, post-#602)" \
+# Not a premise: with the ANALYZE line reporting "no", it went red (#1339).
+check_text "the selective query takes the fold (ANALYZE, post-#602)" \
"$(explain_line 'Columnar Batch Fold' "$QSEL" | grep -oE 'yes|no')" "yes"
# NGROUPS, never GROUPS: bash's builtin GROUPS (the caller's group-id array)
# silently IGNORES assignments, so a suite variable of that name reads as the
@@ -65,14 +84,13 @@ check "premise: the selective predicate is genuinely selective (<= 2%)" \
# ---- correctness: three-way agreement --------------------------------------
FOLD_SEL="$(sq "$QSEL;")"
-ROW_SEL="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" -At \
- -c "SET pgcolumnar.enable_ungrouped_vector_agg = off" -c "$QSEL" 2>/dev/null | tail -1)"
+ROW_SEL="$(rq "$QSEL;")"
HEAP_SEL="$(q "SELECT sum(p1), sum(p2), sum(p3), sum(p4) FROM fd_heap WHERE q <= 1")"
-check "deferred fold == row path (selective)" "$FOLD_SEL" "$ROW_SEL"
-check "deferred fold == heap mirror (selective)" "$FOLD_SEL" "$HEAP_SEL"
+check_text "deferred fold == row path (selective)" "$FOLD_SEL" "$ROW_SEL"
+check_text "deferred fold == heap mirror (selective)" "$FOLD_SEL" "$HEAP_SEL"
FOLD_HI="$(sq "$QHI;")"
HEAP_HI="$(q "SELECT sum(p1), sum(p2), sum(p3), sum(p4) FROM fd_heap WHERE q <= 99")"
-check "fold == heap mirror (high survival)" "$FOLD_HI" "$HEAP_HI"
+check_text "fold == heap mirror (high survival)" "$FOLD_HI" "$HEAP_HI"
# ---- the work-done identity -------------------------------------------------
LOADS_LINE="$(explain_line 'Columnar Fold Payload Loads' "$QSEL")"
@@ -83,14 +101,17 @@ LOADS="$(grep -oE '[0-9]+$' <<<"$LOADS_LINE")"
check "premise: the predicate has survivors (the identity is not 0 == 0)" \
"$([ "${SURV:-0}" -gt 0 ] && echo yes)" "yes"
check_num "deferred: loads == survivors x payload width (the identity)" \
- "${LOADS:-0}" "$((SURV * NP))"
+ "$LOADS" "$((SURV * NP))"
# cross-instrument premise: the EXPLAIN's own group total must agree with the
# stats() catalog count, and both must be plural -- whichever side misreports,
# this names it with the numbers in hand.
DEFER_Y="$(grep -oE 'of [0-9]+' <<<"$DEFER_LINE" | grep -oE '[0-9]+')"
-check_num "premise: EXPLAIN group total == stats() group count" \
- "${DEFER_Y:-0}" "${NGROUPS:-0}"
-check "premise: the fixture spans several groups" \
+# Not a premise: with the fold counting each group twice, it went red (#1339). The
+# two readings go in raw: two unread counts used to default to 0 and agree.
+check_num "EXPLAIN group total == stats() group count" \
+ "$DEFER_Y" "$NGROUPS"
+# Not a premise: with the writer ignoring stripe_row_limit, it went red (#1339).
+check "the fixture spans several groups" \
"$([ "${DEFER_Y:-0}" -ge 3 ] 2>/dev/null && echo yes)" "yes"
check "deferred: every group deferred at 1% survival" \
"$(grep -oE '[0-9]+ of [0-9]+' <<<"$DEFER_LINE")" "$DEFER_Y of $DEFER_Y"
@@ -98,7 +119,7 @@ check "deferred: every group deferred at 1% survival" \
# ---- the adaptive gate declines high survival -------------------------------
DEFER_HI_N="$(explain_line 'Columnar Fold Deferred Groups' "$QHI" | grep -oE '[0-9]+ of' | grep -oE '[0-9]+')"
check_num "adaptive: only the optimistic first group deferred at 99% survival" \
- "${DEFER_HI_N:-0}" "1"
+ "$DEFER_HI_N" "1"
LOADS_HI="$(grep -oE '[0-9]+$' <<<"$(explain_line 'Columnar Fold Payload Loads' "$QHI")")"
SURV_HI="$(q "SELECT count(*) FROM fd_t WHERE q <= 99")"
check "adaptive: eager groups load more than survivors alone (work measured, not assumed)" \
diff --git a/test/native_late_materialization.sh b/test/native_late_materialization.sh
index a0f57536f..b8723498a 100755
--- a/test/native_late_materialization.sh
+++ b/test/native_late_materialization.sh
@@ -16,6 +16,16 @@
# below assert that nothing was pruned, so a green run cannot be pruning wearing
# this feature's name.
#
+# EVERY READING FAILS CLOSED. A counter that could not be read is "no reading", never 0:
+# four checks want exactly 0, and with the server stopped they used to read an empty
+# counter as 0 and pass (#1341).
+#
+# NOT MEASURABLE HERE: whether a rejected row's payload is actually left unbuilt. With
+# the payload built anyway and the row still counted (mutant "matall"), every check
+# stays green: no EXPLAIN counter reports per-row materialization, and the answers are
+# the same by design. "Columnar Rows Filtered Before Materialization" counts the rows
+# the qual rejected on this path, not the copies avoided.
+#
# Usage: test/native_late_materialization.sh [PG_CONFIG]
# Written fresh for pgColumnar.
@@ -32,7 +42,7 @@ NEEDLE=zqx
# vector keeps a survivor. That last part is deliberate: it isolates 1a's per-row
# materialization from #452 phase 2, which skips a vector's payload DECODE only
# when NO row in it passes. With a survivor in each vector phase 2 rules none out
-# (the "nothing pruned at the vector level" premise below), so what this suite
+# (the "nothing pruned at the vector level" check below), so what this suite
# measures is 1a alone. Phase 2's own vector skipping is proven in
# test/native_decode_gating.sh.
GEN="SELECT g AS id,
@@ -52,46 +62,64 @@ psql_run "ANALYZE h; ANALYZE n;"
Q="SELECT * FROM n WHERE k LIKE '%${NEEDLE}%'"
QH="SELECT * FROM h WHERE k LIKE '%${NEEDLE}%'"
-# One EXPLAIN ANALYZE counter for a query.
-counter() { # counter
- env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -At -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) $2" 2>/dev/null \
- | grep "$1" | grep -oE '[0-9]+' | head -1
+# counter [setting] -> the integer on the plan line holding , or
+# "no reading (...)" when the EXPLAIN failed or printed no such line. These counters are
+# always printed, so a missing line is a failed reading, not 0.
+counter() {
+ local out v
+ if ! out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -v ON_ERROR_STOP=1 ${3:+-c "$3"} \
+ -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) $2" 2>&1)"; then
+ echo "no reading (EXPLAIN failed: $(head -1 <<<"$out"))"
+ return
+ fi
+ v="$(grep -F "$1" <<<"$out" | grep -oE '[0-9]+' | head -1)"
+ if [ -n "$v" ]; then echo "$v"; else echo "no reading (no \"$1\" line)"; fi
}
-scalar() { # scalar
- env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -At -c "$1" 2>/dev/null
+# scalar [setting] -> the last output line, or "" when psql failed (check_num and
+# check_text both refuse an empty side).
+scalar() {
+ local out
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -v ON_ERROR_STOP=1 ${2:+-c "$2"} -c "$1" 2>/dev/null)" || return 0
+ tail -1 <<<"$out"
}
# ---- premises, so a green run cannot mean something else --------------------
-n_rows=$(scalar "SELECT count(*) FROM n;")
-check_num "premise: the fixture loaded every row" "$n_rows" "$ROWS"
+# The two fixture premises read the heap mirror h, which pgcolumnar does not write,
+# so no pgcolumnar change can redden them. The comparison with n is not a premise:
+# a writer that drops one row (one line in pgcolumnar_tuple_insert) reddens it.
+h_rows=$(scalar "SELECT count(*) FROM h;")
+check_num "premise: the fixture loaded every row" "$h_rows" "$ROWS"
n_match=$(scalar "SELECT count(*) FROM n WHERE k LIKE '%${NEEDLE}%';")
h_match=$(scalar "SELECT count(*) FROM h WHERE k LIKE '%${NEEDLE}%';")
-check_num "premise: the needle is selective, not most of the table" "$n_match" "20"
-check_num "premise: heap agrees on how many rows match" "$h_match" "$n_match"
+check_num "premise: the needle is selective, not most of the table" "$h_match" "20"
+check_num "heap agrees on how many rows match" "$h_match" "$n_match"
# The scalar columnar custom scan reports "Columnar Projected Columns"; no other
# node reports it. Without this the counters below would be read off a node that
# is not the one under test.
proj=$(counter "Columnar Projected Columns" "$Q")
-check "premise: the query runs on the columnar custom scan" \
- "$([ -n "$proj" ] && echo yes || echo no)" "yes"
+# Not a premise: with the custom scan off by default (one line of pgcolumnar) it fails.
+check "the query runs on the columnar custom scan" \
+ "$(case "$proj" in ''|*[!0-9]*) echo "no ($proj)" ;; *) echo yes ;; esac)" "yes"
# A leading-wildcard LIKE must not prune. If it ever does, this suite is
# measuring pruning and its headline check means nothing.
+# Not premises: with the custom scan off by default (one line of pgcolumnar) these
+# counters are not printed and both fail as no reading (#1339).
removed=$(counter "Columnar Chunk Groups Removed by Filter" "$Q")
vskip=$(counter "Columnar Vectors Skipped" "$Q")
-check_num "premise: nothing was pruned at the group level" "${removed:-0}" "0"
-check_num "premise: nothing was pruned at the vector level" "${vskip:-0}" "0"
+check_num "nothing was pruned at the group level" "$removed" "0"
+check_num "nothing was pruned at the vector level" "$vskip" "0"
# ---- the feature -----------------------------------------------------------
early=$(counter "Columnar Rows Filtered Before Materialization" "$Q")
check_num "a rejected row does not materialize its payload columns" \
- "${early:-0}" "$((ROWS - n_match))"
+ "$early" "$((ROWS - n_match))"
# ---- the executor's own counter must keep counting -------------------------
#
@@ -105,7 +133,7 @@ check_num "a rejected row does not materialize its payload columns" \
# majors); this pins it here, next to the feature that broke it.
removed=$(counter "Rows Removed by Filter" "$Q")
check_num "the executor still reports the rows the scan filtered" \
- "${removed:-0}" "$((ROWS - n_match))"
+ "$removed" "$((ROWS - n_match))"
# ---- and it may not change a single answer ---------------------------------
#
@@ -120,19 +148,11 @@ check_text "the rows are identical to the heap mirror" "$nn" "$nh"
# With the feature off the counter must be 0 AND the answers must be unchanged.
# Asserting only the first would let a broken implementation that silently
# emitted nothing pass the off-arm.
-psql_run "SET pgcolumnar.enable_late_materialization = off;"
-off_early=$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -At \
- -c "SET pgcolumnar.enable_late_materialization = off;" \
- -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) $Q" 2>/dev/null \
- | grep "Columnar Rows Filtered Before Materialization" | grep -oE '[0-9]+' | head -1)
-check_num "with the feature off nothing is filtered early" "${off_early:-0}" "0"
-
-nn_off=$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -At \
- -c "SET pgcolumnar.enable_late_materialization = off;" \
- -c "SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ($Q) t;" 2>/dev/null \
- | tail -1)
+OFF="SET pgcolumnar.enable_late_materialization = off;"
+off_early=$(counter "Columnar Rows Filtered Before Materialization" "$Q" "$OFF")
+check_num "with the feature off nothing is filtered early" "$off_early" "0"
+
+nn_off=$(scalar "SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ($Q) t;" "$OFF")
check_text "and the answers are the same with it off" "$nn_off" "$nh"
# ---- a volatile qual must refuse the path entirely -------------------------
@@ -153,17 +173,18 @@ QV="SELECT * FROM n WHERE k LIKE '%${NEEDLE}%' AND random() < 2"
QVH="SELECT * FROM h WHERE k LIKE '%${NEEDLE}%' AND random() < 2"
vol_early=$(counter "Columnar Rows Filtered Before Materialization" "$QV")
-check_num "a volatile qual is refused the two-pass path" "${vol_early:-0}" "0"
+check_num "a volatile qual is refused the two-pass path" "$vol_early" "0"
# And refusing it must not change the answer either.
vh=$(scalar "SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ($QVH) t;")
vn=$(scalar "SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ($QV) t;")
check_text "and the volatile query still matches the heap mirror" "$vn" "$vh"
-# The premise that makes the check above mean something: the SAME query without
-# the volatile term does take the path. Without this, "0" would be satisfied by a
-# fixture that never qualified for late materialization at all.
-check_num "premise: the same shape without the volatile term does use it" \
+# What makes the check above mean something: the SAME query without the volatile term
+# does take the path. Without this, "0" would be satisfied by a fixture that never
+# qualified for late materialization at all. Not a premise: the counter dropped, the
+# custom scan off, or a survivor lost each fail it (#1339).
+check_num "the same shape without the volatile term does use it" \
"$(counter "Columnar Rows Filtered Before Materialization" "$Q")" \
"$((ROWS - n_match))"
diff --git a/test/native_lazy_slot.sh b/test/native_lazy_slot.sh
index 24b60f5a1..2cb126d39 100755
--- a/test/native_lazy_slot.sh
+++ b/test/native_lazy_slot.sh
@@ -53,9 +53,27 @@ psql_run "DROP TABLE IF EXISTS lz_c; DROP TABLE IF EXISTS lz_h;
INSERT INTO lz_c SELECT g$sel, 'v' || g FROM generate_series(1, $ROWS) g;
CREATE INDEX lz_c_id ON lz_c (id);
CREATE TABLE lz_h (LIKE lz_c);
- INSERT INTO lz_h SELECT * FROM lz_c;
+ INSERT INTO lz_h SELECT g$sel, 'v' || g FROM generate_series(1, $ROWS) g;
CREATE INDEX lz_h_id ON lz_h (id);" >/dev/null
+# THE HEAP MIRROR IS WRITTEN FROM THE GENERATOR, NOT COPIED FROM lz_c. A copy reads
+# lz_c through the reader under test, so a value the writer stored wrong was wrong on
+# both sides: with the writer storing c40 off by one, every check passed (#1341).
+
+# qv SIDE SQL -> the statement's last output line, or "SIDE_ERROR (first line)" when
+# psql fails or prints nothing. The side is in the failure, so two failed reads are two
+# different values: with the server stopped after setup, five comparisons and the
+# ratio passed on two empty strings.
+qv() {
+ local out
+ if out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -v ON_ERROR_STOP=1 -c "$2" 2>&1)" && [ -n "$out" ]; then
+ printf '%s\n' "$out" | tail -1
+ else
+ printf '%s_ERROR (%s)\n' "$1" "$(printf '%s' "$out" | head -1)"
+ fi
+}
+
# force the index path, and keep the custom scan out of the way so the fetch
# really is index_fetch_tuple
FORCE="SET enable_seqscan = off; SET enable_bitmapscan = off;
@@ -71,27 +89,27 @@ check "the fetch really is an index scan" \
# and count(*) asks for nothing at all.
bad=""
for expr in "c1" "c2" "c20" "c${NCOLS}" "txt" "c1 + c${NCOLS}" "id"; do
- cv="$(q "$FORCE SELECT $expr FROM lz_c WHERE id = 7777;" | tail -1)"
- hv="$(q "$FORCE SELECT $expr FROM lz_h WHERE id = 7777;" | tail -1)"
+ cv="$(qv COL "$FORCE SELECT $expr FROM lz_c WHERE id = 7777;")"
+ hv="$(qv HEAP "$FORCE SELECT $expr FROM lz_h WHERE id = 7777;")"
[ "$cv" = "$hv" ] || bad="$bad [$expr: $cv vs $hv]"
done
check "single-row index fetches match heap for every column position" \
"${bad:-same}" "same"
# a whole-row fetch, which needs the full decode
-check "SELECT * through the index matches heap" \
- "$(q "$FORCE SELECT md5(lz_c::text) FROM lz_c WHERE id = 999;" | tail -1)" \
- "$(q "$FORCE SELECT md5(lz_h::text) FROM lz_h WHERE id = 999;" | tail -1)"
+check_text "SELECT * through the index matches heap" \
+ "$(qv COL "$FORCE SELECT md5(lz_c::text) FROM lz_c WHERE id = 999;")" \
+ "$(qv HEAP "$FORCE SELECT md5(lz_h::text) FROM lz_h WHERE id = 999;")"
# many rows, so the cache is reused across fetches within one statement
-check "a range of rows sums the same as heap" \
- "$(q "$FORCE SELECT sum(c1) + sum(c${NCOLS}) FROM lz_c WHERE id <= 2000;" | tail -1)" \
- "$(q "$FORCE SELECT sum(c1) + sum(c${NCOLS}) FROM lz_h WHERE id <= 2000;" | tail -1)"
+check_text "a range of rows sums the same as heap" \
+ "$(qv COL "$FORCE SELECT sum(c1) + sum(c${NCOLS}) FROM lz_c WHERE id <= 2000;")" \
+ "$(qv HEAP "$FORCE SELECT sum(c1) + sum(c${NCOLS}) FROM lz_h WHERE id <= 2000;")"
# count(*) reads no column at all
-check "count(*) through the index matches heap" \
- "$(q "$FORCE SELECT count(*) FROM lz_c WHERE id <= 2000;" | tail -1)" \
- "$(q "$FORCE SELECT count(*) FROM lz_h WHERE id <= 2000;" | tail -1)"
+check_text "count(*) through the index matches heap" \
+ "$(qv COL "$FORCE SELECT count(*) FROM lz_c WHERE id <= 2000;")" \
+ "$(qv HEAP "$FORCE SELECT count(*) FROM lz_h WHERE id <= 2000;")"
# --- 2. rows that are still buffered ------------------------------------------
@@ -109,11 +127,11 @@ check "a row fetched before it is flushed reads correctly" \
# Each of these goes through a different callback: materialize, copy_heap_tuple
# via a tuplestore, copyslot, and copy_minimal_tuple via a sort.
-check "an ORDER BY over index-fetched rows matches heap" \
- "$(q "$FORCE SELECT string_agg(c1::text, ',' ORDER BY c1)
- FROM (SELECT c1 FROM lz_c WHERE id <= 20) s;" | tail -1)" \
- "$(q "$FORCE SELECT string_agg(c1::text, ',' ORDER BY c1)
- FROM (SELECT c1 FROM lz_h WHERE id <= 20) s;" | tail -1)"
+check_text "an ORDER BY over index-fetched rows matches heap" \
+ "$(qv COL "$FORCE SELECT string_agg(c1::text, ',' ORDER BY c1)
+ FROM (SELECT c1 FROM lz_c WHERE id <= 20) s;")" \
+ "$(qv HEAP "$FORCE SELECT string_agg(c1::text, ',' ORDER BY c1)
+ FROM (SELECT c1 FROM lz_h WHERE id <= 20) s;")"
psql_run "DROP TABLE IF EXISTS lz_copy;" >/dev/null
check "copying index-fetched rows into a heap table keeps every column" \
@@ -160,10 +178,14 @@ build_w() { # table, ncols
INSERT INTO $1 SELECT g$s2 FROM generate_series(1, $CLIFF_ROWS) g;
CREATE INDEX ${1}_id ON $1 (id);" >/dev/null
}
-ms_of() { # table
+ms_of() { # table -> milliseconds, or "no reading" when the statement failed: a
+ # statement that errors at once is fast, and two of them used to pass the ratio
local t0 t1
t0=$(date +%s%N)
- psql_run "$FORCE SELECT count(c1) FROM $1 WHERE id <= 2000;" >/dev/null 2>&1
+ if ! env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" \
+ -At -v ON_ERROR_STOP=1 -c "$FORCE SELECT count(c1) FROM $1 WHERE id <= 2000;" >/dev/null 2>&1; then
+ echo "no reading"; return
+ fi
t1=$(date +%s%N)
echo $(( (t1 - t0) / 1000000 ))
}
@@ -175,7 +197,8 @@ echo "-- 2000 index fetches of one column: 3 cols ${narrow} ms, $((NCOLS + 1)) c
check "a wide table is not in a different class from a narrow one" \
"$(awk -v n="$narrow" -v w="$wide" \
- 'BEGIN { print (n > 0 && w / n < 25) ? "yes" : "no (" n "ms vs " w "ms)" }')" \
+ 'BEGIN { if (n !~ /^[0-9]+$/ || w !~ /^[0-9]+$/) print "no reading (" n " / " w ")";
+ else print (n > 0 && w / n < 25) ? "yes" : "no (" n "ms vs " w "ms)" }')" \
"yes"
pgc_summary
diff --git a/test/native_metadata_flush.sh b/test/native_metadata_flush.sh
index 7e06a1b33..ce332b413 100755
--- a/test/native_metadata_flush.sh
+++ b/test/native_metadata_flush.sh
@@ -49,7 +49,9 @@ opens() { # opens
q "CREATE TABLE tprobe (a int, b int, c int) USING pgcolumnar;" >/dev/null
probe="$(opens "INSERT INTO tprobe SELECT g, g*2, g%7 FROM generate_series(1,2000) g;")"
-check_text "premise: the flush emits the metadata-open witness" \
+# Not a premise: with the flush session never begun (a one-line pgcolumnar change)
+# no witness line is emitted, so this is an ordinary check (#1339).
+check_text "the flush emits the metadata-open witness" \
"$([ -n "$probe" ] && echo yes || echo no)" "yes"
# ---- the batched open count is a small constant -----------------------------
@@ -77,13 +79,24 @@ check_text "a 20-column flush opens a small constant number of metadata relation
"$([ -n "$n_wide" ] && [ "$n_wide" -le 6 ] && echo small || echo "big:[$n_wide]")" \
"small"
+# A floor as well as a ceiling. Every flush writes a row_group row, column_chunk
+# rows and zone_map rows, so it opens at least those three tables. Without the
+# floor a witness that counted nothing (opens=0 for every flush) passed both checks
+# above: 0 equals 0, and 0 is small.
+check_text "and the 20-column flush opens at least the three tables every flush writes" \
+ "$([ -n "$n_wide" ] && [ "$n_wide" -ge 3 ] && echo yes || echo "no:[$n_wide]")" \
+ "yes"
+
# ---- the writes are correct, batched or not ---------------------------------
# The batching must not change what is written. Mirror the wide table in heap and
# compare, so a corrupted or dropped metadata row would show as a count or sum
# mismatch.
q "CREATE TABLE twide_h (LIKE twide);" >/dev/null
-q "INSERT INTO twide_h SELECT * FROM twide;" >/dev/null
+# THE MIRROR IS WRITTEN FROM THE GENERATOR, NOT COPIED FROM twide. Copied, it read
+# twide through the reader under test, so a writer that stored a wrong value put the
+# same wrong value in both and the sum below still agreed.
+q "INSERT INTO twide_h SELECT g,g,g,g,g,g,g,g,g,g,g,g,g,g,g,g,g,g,g,g FROM generate_series(1,2000) g;" >/dev/null
check_num "the wide table's row count survives the batched flush" \
"$(q "SELECT count(*) FROM twide")" "2000"
@@ -96,4 +109,17 @@ check_num "a zone-map-pruned range read matches heap (the batched zone rows are
"$(q "SELECT count(*) FROM twide WHERE c00 BETWEEN 500 AND 1500")" \
"$(q "SELECT count(*) FROM twide_h WHERE c00 BETWEEN 500 AND 1500")"
+# And the zone rows are read: the range excludes the last vector of the stripe
+# (1501..2000 lies above 1500), and the zone row for that vector is what rules it out.
+# Vectors Skipped alone cannot show that, because the reader also rules out vectors
+# by value after decoding the qual column, with or without a zone map. So this reads
+# the zone-map part: Skipped minus Ruled Out by Value. Without it, a flush that wrote
+# no usable min/max still answered every range read correctly.
+plan="$(q "SET max_parallel_workers_per_gather=0;
+ EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM twide WHERE c00 BETWEEN 500 AND 1500;")"
+skipped="$(grep -oE 'Columnar Vectors Skipped: [0-9]+' <<<"$plan" | head -1 | grep -oE '[0-9]+')"
+byvalue="$(grep -oE 'Columnar Vectors Ruled Out by Value: [0-9]+' <<<"$plan" | head -1 | grep -oE '[0-9]+')"
+check_text "and that range read rules out a vector by its zone row alone" \
+ "$([ -n "$skipped" ] && [ -n "$byvalue" ] && [ $((skipped - byvalue)) -ge 1 ] && echo yes || echo "no:[skipped $skipped, by value $byvalue]")" "yes"
+
pgc_summary
diff --git a/test/native_page_offset_bound.sh b/test/native_page_offset_bound.sh
index 8528efefa..28325ce1b 100755
--- a/test/native_page_offset_bound.sh
+++ b/test/native_page_offset_bound.sh
@@ -31,8 +31,48 @@ set -uo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"
-alive() { [ "$(q 'SELECT 1;')" = "1" ] && echo yes || echo no; }
-errors() { if psql_run "$1" >/dev/null 2>&1; then echo no; else echo yes; fi; }
+# A REFUSAL IS THE CONTAINMENT GUARD'S OWN. Every arm wants XX001 with the guard's
+# message ("... lies outside row group"), read as "XX001 (containment guard)". It used to
+# accept any psql failure, so a backend the corrupt offset crashed passed as refused.
+# A BACKEND THAT DIED DOES NOT PASS AS ONE THAT SURVIVED: SELECT 1 answers again once
+# crash recovery has finished, so survival also reads the server log's count of
+# backends killed by a signal since the arm began. AND AN INTACT TABLE STILL READS: a
+# guard over-applied to refuse every chunk, or a legal chunk ending exactly at its
+# group's end, passed every arm below, which all want a refusal.
+GUARD_RE='lies outside row group'
+refusal_of() { # stdin: psql output -> "XX001 (containment guard)", ": ", or what happened
+ local line state msg out; out="$(cat)"
+ line="$(sed -n 's/^ERROR: \([0-9A-Z]\{5\}\): /\1|/p' <<<"$out" | head -1)"
+ if [ -z "$line" ]; then
+ grep -qE 'server closed the connection|connection to server' <<<"$out" \
+ && echo "no refusal: the connection was lost" || echo "no error"
+ return
+ fi
+ state="${line%%|*}"; msg="${line#*|}"
+ if grep -qE -- "$GUARD_RE" <<<"$msg"; then echo "$state (containment guard)"; else echo "$state: $msg"; fi
+}
+refusal() { # refusal SQL [PROJECTION_OFF] -> the refusal reading
+ local pre=""; [ "${2:-}" = off ] && pre="SET pgcolumnar.enable_column_projection = off;"
+ env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA 2>&1 </dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since BEFORE
+ local _up _now _i
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"; return; fi
+ if [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1; return; fi
+ for _i in $(seq 1 60); do [ "$(q 'SELECT 1' 2>/dev/null)" = 1 ] && break; sleep 0.5; done
+ echo "no ([$_up], $(( _now - $1 )) backend crashes)"
+}
sid() { q "SELECT pgcolumnar.get_storage_id('c');"; }
mkc() {
@@ -41,59 +81,68 @@ mkc() {
psql_run "INSERT INTO c SELECT g, (random()*9e18)::bigint, 'v'||(g%5)
FROM generate_series(1,20000) g;"
}
+WANT="XX001 (containment guard)"
-# --- control: the projected read already refuses an out-of-group offset --------
-echo "-- control: page_offset overrun on a PROJECTED scan (SELECT sum(r)) errors"
+# --- controls: an intact table reads every row, on both paths --------------------
+echo "-- control: an intact table reads through the whole-group and the projected read"
+mkc
+# Rows, not an aggregate: count() and sum() over an intact table can be answered
+# without decoding a chunk, and then neither read path is exercised. The OFFSET 0
+# fence keeps the filter a filter, so every chunk of every group is decoded.
+EVERY_1000="$(seq -s, 1000 1000 20000)"
+check_text "an intact table reads every row and value with projection off" \
+ "$(PGOPTIONS="-c pgcolumnar.enable_column_projection=off" q "SELECT string_agg(id::text, ',' ORDER BY id) FROM
+ (SELECT * FROM c WHERE id % 1000 = 0 AND r IS NOT NULL AND t LIKE 'v%' OFFSET 0) s;")" \
+ "$EVERY_1000"
+check_text "an intact table's projected read returns every id" \
+ "$(q "SELECT string_agg(id::text, ',' ORDER BY id) FROM
+ (SELECT id FROM c WHERE id % 1000 = 0 OFFSET 0) s;")" "$EVERY_1000"
+
+# --- control: the projected read already refuses an out-of-group offset ----------
+echo "-- control: page_offset overrun on a PROJECTED scan (SELECT sum(r)) is refused"
mkc
psql_run "UPDATE pgcolumnar.column_chunk SET page_offset = page_offset + 999999999
WHERE storage_id = $(sid) AND column_index = 1;"
-check "projected page_offset overrun errors" "$(errors 'SELECT sum(r) FROM c;')" "yes"
-check "alive after projected overrun" "$(alive)" "yes"
+B="$(crashes)"
+check_text "projected page_offset overrun is refused by the containment guard" \
+ "$(refusal 'SELECT sum(r) FROM c')" "$WANT"
+check "alive after projected overrun" "$(survived "$B")" "1"
-# --- the gap: whole-group read (projection off) with the offset above the group
-echo "-- page_offset overrun on the WHOLE-GROUP read (projection off) must error, not crash"
+# --- the gap: whole-group read (projection off) with the offset above the group --
+echo "-- page_offset overrun on the WHOLE-GROUP read (projection off) must be refused, not crash"
mkc
psql_run "UPDATE pgcolumnar.column_chunk SET page_offset = page_offset + 999999999
WHERE storage_id = $(sid) AND column_index = 1;"
-check "whole-group page_offset overrun errors" \
- "$(errors 'SET pgcolumnar.enable_column_projection=off; SELECT * FROM c;')" "yes"
-check "alive after whole-group overrun" "$(alive)" "yes"
+B="$(crashes)"
+check_text "whole-group page_offset overrun is refused by the containment guard" \
+ "$(refusal 'SELECT * FROM c' off)" "$WANT"
+check "alive after whole-group overrun" "$(survived "$B")" "1"
-# --- the harsher case: offset below fileOffset -> uint64 wrap on the same path -
+# --- the harsher case: offset below fileOffset -> uint64 wrap on the same path ---
echo "-- page_offset driven below the group start (subtraction wraps), projection off"
mkc
psql_run "UPDATE pgcolumnar.column_chunk SET page_offset = 0
WHERE storage_id = $(sid) AND column_index = 2;"
-check "whole-group page_offset=0 errors" \
- "$(errors 'SET pgcolumnar.enable_column_projection=off; SELECT * FROM c;')" "yes"
-check "alive after whole-group wrap" "$(alive)" "yes"
+B="$(crashes)"
+check_text "whole-group page_offset=0 is refused by the containment guard" \
+ "$(refusal 'SELECT * FROM c' off)" "$WANT"
+check "alive after whole-group wrap" "$(survived "$B")" "1"
# --- the overflow case: page_offset = -1 arrives as ~UINT64_MAX, so a naive
-# --- `page_offset + page_length > group_end` test wraps and passes, letting base
-# --- index far outside nativeBuffer. The guard must reject it (the containment
-# --- test is written overflow-safe), and specifically the CONTAINMENT guard must
-# --- fire -- not a downstream decode error that only incidentally catches the
-# --- wrapped read. page_offset is a signed bigint column, so -1 is storable.
-guard_fires() { # guard_fires ; yes iff the containment guard raised
- # capture-then-grep: piping an erroring psql into grep under pipefail would
- # mask the match (see the pipefail-hides-grep lesson).
- local out
- out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" \
- -U postgres -d "$PGC_DB" -c "$1" 2>&1)"
- case "$out" in
- *"lies outside row group"*) echo yes ;;
- *) echo no ;;
- esac
-}
+# --- `page_offset + page_length > group_end` test wraps and passes. The guard is
+# --- written overflow-safe, and it must be the CONTAINMENT guard that fires, not a
+# --- downstream decode error that only incidentally catches the wrapped read.
echo "-- page_offset = -1 (unsigned wrap) must be caught by the containment guard, both paths"
mkc
psql_run "UPDATE pgcolumnar.column_chunk SET page_offset = -1
WHERE storage_id = $(sid) AND column_index = 1;"
-check "whole-group page_offset=-1 hits the containment guard" \
- "$(guard_fires 'SET pgcolumnar.enable_column_projection=off; SELECT * FROM c;')" "yes"
-check "alive after whole-group page_offset=-1" "$(alive)" "yes"
-check "projected page_offset=-1 hits the containment guard" \
- "$(guard_fires 'SELECT sum(r) FROM c;')" "yes"
-check "alive after projected page_offset=-1" "$(alive)" "yes"
+B="$(crashes)"
+check_text "whole-group page_offset=-1 hits the containment guard" \
+ "$(refusal 'SELECT * FROM c' off)" "$WANT"
+check "alive after whole-group page_offset=-1" "$(survived "$B")" "1"
+B="$(crashes)"
+check_text "projected page_offset=-1 hits the containment guard" \
+ "$(refusal 'SELECT sum(r) FROM c')" "$WANT"
+check "alive after projected page_offset=-1" "$(survived "$B")" "1"
pgc_summary
diff --git a/test/native_param_pushdown.sh b/test/native_param_pushdown.sh
index 6fdad2ef8..44d62fc39 100755
--- a/test/native_param_pushdown.sh
+++ b/test/native_param_pushdown.sh
@@ -33,6 +33,24 @@ q "SET pgcolumnar.stripe_row_limit=2000;
psql_c() { env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" -At -c "$1" 2>&1; }
+# A backend that crashed answers "SELECT 1" again once recovery is over, so survival
+# also requires that the server log's count of backends killed by a signal has not
+# moved since the arms began (#1341).
+crashes() {
+ local _n
+ _n="$(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since BEFORE
+ local _up _now _i
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"; return; fi
+ if [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1; return; fi
+ for _i in $(seq 1 60); do [ "$(q 'SELECT 1' 2>/dev/null)" = 1 ] && break; sleep 0.5; done
+ echo "no ([$_up], $(( _now - $1 )) backend crashes)"
+}
+CRASHES_BEFORE="$(crashes)"
+
# Groups read by a generic-plan parameterized scan. Under the fix the last group
# is the only one read; without it, all 20 are.
groups_removed() {
@@ -59,10 +77,44 @@ check "literal count is the expected value" \
# Safety: a correlated PARAM_EXEC (nestloop inner scan) must NOT be frozen, or it
# would mis-prune per outer row. sum over thr(39000, 38000) = 1001 + 2001 = 3002.
+#
+# That answer alone could not tell. The keys are built once, at Begin, before any
+# outer row has set the param, so a scan that DID freeze it froze the param's initial
+# value, and "ts >= " prunes nothing: with the PARAM_EXEC guard removed the
+# lower-bound arm still returned 3002, and every check passed (#1341). So the arm now
+# reads the scan itself -- a correlated param must build no skip predicate -- and asks
+# the opposite bound too, "ts <= thr.lo", where a frozen initial value would rule out
+# every group.
+CORR_PLAN="$(q 'EXPLAIN (ANALYZE, TIMING off, SUMMARY off, COSTS off)
+ SELECT sum(c) FROM thr, LATERAL (SELECT count(*) c FROM t WHERE t.ts >= thr.lo) s;')"
+check "the correlated arm rescans the columnar scan once per outer row" \
+ "$(grep -c 'Custom Scan (PgColumnarScan) on t (actual rows=[0-9.]* loops=2)' <<<"$CORR_PLAN")" "1"
+check_num "a correlated PARAM_EXEC builds no skip predicate" \
+ "$(sed -n 's/.*Columnar Usable Skip Predicates: \([0-9]*\).*/\1/p' <<<"$CORR_PLAN" | head -1)" "0"
check "a correlated PARAM_EXEC is not mis-pruned (results stay correct)" \
"$(q 'SELECT sum(c) FROM thr, LATERAL (SELECT count(*) c FROM t WHERE t.ts >= thr.lo) s;')" \
"3002"
+# ts <= 39000 is 39000 rows and ts <= 38000 is 38000: 77000.
+check "a correlated PARAM_EXEC upper bound is not mis-pruned either" \
+ "$(q 'SELECT sum(c) FROM thr, LATERAL (SELECT count(*) c FROM t WHERE t.ts <= thr.lo) s;')" \
+ "77000"
+
+# A generic plan executed twice in ONE session with two values must freeze each
+# value afresh. Every arm above ran one execution per session, so a frozen value
+# reused by a later execution passed them all (#1341). ts >= 38000 is 2001 rows, and
+# prunes 18 of the 20 groups.
+TWICE="$(psql_c "SET plan_cache_mode=force_generic_plan;
+ DEALLOCATE ALL;
+ PREPARE w(int) AS SELECT count(*) FROM t WHERE ts >= \$1;
+ EXECUTE w(39000);
+ EXECUTE w(38000);
+ EXPLAIN (ANALYZE, TIMING off, SUMMARY off) EXECUTE w(38000);")"
+# The two counts are the output's only all-digit lines (psql also prints the tags).
+check "a second execution in the same session counts with its own value" \
+ "$(grep -xE '[0-9]+' <<<"$TWICE" | sed -n 2p)" "2001"
+check "and prunes with its own value too (18 of 20 removed)" \
+ "$(sed -n 's/.*Chunk Groups Removed by Filter: \([0-9]*\).*/\1/p' <<<"$TWICE" | head -1)" "18"
-check "backend alive" "$(q 'SELECT 1;')" "1"
+check "backend alive" "$(survived "$CRASHES_BEFORE")" "1"
pgc_summary
diff --git a/test/native_parquet_codecs.sh b/test/native_parquet_codecs.sh
index d200687fc..ea246c4ca 100755
--- a/test/native_parquet_codecs.sh
+++ b/test/native_parquet_codecs.sh
@@ -3,10 +3,22 @@
# pgColumnar Parquet compression-codec read coverage (Phase G).
#
# The shared decoder handled only UNCOMPRESSED and SNAPPY. This suite covers the
-# other codecs a foreign Parquet writer commonly uses -- GZIP, ZSTD, and LZ4_RAW
-# -- reading each back and comparing to an uncompressed reference of the same
-# data, so a codec that decompresses to the wrong bytes is caught. It also checks
-# that a codec whose library is not built in fails cleanly rather than crashing.
+# other codecs a foreign Parquet writer commonly uses -- GZIP, ZSTD, and LZ4_RAW --
+# reading each back and comparing it to the rows the generator wrote, so a codec
+# that decompresses to the wrong bytes is caught. It also checks that a crafted
+# page header fails cleanly rather than crashing.
+#
+# THE ORACLE IS THE GENERATOR, NOT THE UNCOMPRESSED FILE (#1341). Every codec used to
+# be compared with c_none.parquet read back through read_parquet, the reader under
+# test. A defect after decompression -- in the page decode every codec shares --
+# corrupts both sides alike: with one decoded byte flipped in every page over 4 KB,
+# all ten checks passed on main. Each file is now compared with the same rows built
+# in SQL from the generator the crafter wrote them from.
+#
+# EACH FILE'S CODEC IS READ, NOT ASSUMED. The crafter used to print which codec each
+# file got to stderr, and nothing read it: a file pyarrow wrote uncompressed would
+# have passed every codec arm. The premises now read the column chunk's compression
+# from the file's own metadata.
#
# Usage: test/native_parquet_codecs.sh [PG_CONFIG]
# Written fresh for pgColumnar.
@@ -22,96 +34,73 @@ fi
W="$PGC_WORKDIR"
psql_run "CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet;"
-# Enough rows and enough repetition that compression actually engages and pages
-# are non-trivial (dictionary + multiple data pages), across a few types.
-python3 - "$W" <<'PY'
-import sys, pyarrow as pa, pyarrow.parquet as pq
-W = sys.argv[1]
-n = 20000
-tbl = pa.table({
- "id": pa.array(range(n), pa.int32()),
- "grp": pa.array([i % 50 for i in range(n)], pa.int32()),
- "val": pa.array([i * 1.5 for i in range(n)], pa.float64()),
- "s": pa.array([f"row-{i % 100}" for i in range(n)]),
-})
-for codec in ["none", "gzip", "zstd", "lz4"]:
- pq.write_table(tbl, f"{W}/c_{codec}.parquet", compression=codec)
-# report which codec each file actually used, so the test knows what it exercises
-for codec in ["gzip", "zstd", "lz4"]:
- c = pq.ParquetFile(f"{W}/c_{codec}.parquet").metadata.row_group(0).column(0).compression
- print(f"{codec} {c}", file=sys.stderr)
-PY
+# Enough rows and enough repetition that compression actually engages and pages are
+# non-trivial (dictionary + multiple data pages), across a few types.
+python3 "$(dirname "${BASH_SOURCE[0]}")/craft_parquet_codecs.py" "$W"
+codec_of() { awk -v c="$1" '$1 == c { print $2 }' "$W/codecs.txt" 2>/dev/null; }
+
+# Backends killed by a signal, from the server log: a number, or nothing when the log
+# can't be read. A fresh connection answers again after crash recovery, so "SELECT 1"
+# alone passes with a backend killed before it; survival also requires this count not
+# to have moved.
+crashes() {
+ local _n
+ _n="$(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since
+ local _up _now
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"
+ elif [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1
+ else echo "no ([$_up], $(( _now - $1 )) backend crashes)"; fi
+}
+
+# pyarrow names the LZ4_RAW codec "LZ4" in its metadata; the reader decodes only LZ4_RAW.
+check "premise: the uncompressed file is UNCOMPRESSED" "$(codec_of none)" "UNCOMPRESSED"
+check "premise: the gzip file is GZIP-compressed" "$(codec_of gzip)" "GZIP"
+check "premise: the zstd file is ZSTD-compressed" "$(codec_of zstd)" "ZSTD"
+check "premise: the lz4 file is LZ4-compressed" "$(codec_of lz4)" "LZ4"
cols="id int, grp int, val float8, s text"
-ref="$(pgc_set_hash "SELECT * FROM pgcolumnar.read_parquet('$W/c_none.parquet') AS t($cols)")"
+gen="$(pgc_set_hash "SELECT g::int, (g % 50)::int, (g * 1.5)::float8, 'row-' || (g % 100)
+ FROM generate_series(0, 19999) g")"
+rp() { pgc_set_hash "SELECT * FROM pgcolumnar.read_parquet('$W/c_$1.parquet') AS t($cols)"; }
-for codec in gzip zstd lz4; do
- got="$(pgc_set_hash "SELECT * FROM pgcolumnar.read_parquet('$W/c_${codec}.parquet') AS t($cols)")"
- check "$codec decodes to the same rows as uncompressed" "$got" "$ref"
-done
+check "uncompressed decodes to the generator's rows" "$(rp none)" "$gen"
+check "gzip decodes to the generator's rows" "$(rp gzip)" "$gen"
+check "zstd decodes to the generator's rows" "$(rp zstd)" "$gen"
+check "lz4 decodes to the generator's rows" "$(rp lz4)" "$gen"
-# a count sanity-check per codec via the FDW surface too
+# a count per codec via the FDW surface too
for codec in gzip zstd lz4; do
psql_run "CREATE FOREIGN TABLE fc_$codec ($cols) SERVER pq OPTIONS (path '$W/c_${codec}.parquet');"
- check "$codec FDW count" "$(q "SELECT count(*) FROM fc_$codec;")" "20000"
done
+check_num "gzip FDW count" "$(q "SELECT count(*) FROM fc_gzip;")" "20000"
+check_num "zstd FDW count" "$(q "SELECT count(*) FROM fc_zstd;")" "20000"
+check_num "lz4 FDW count" "$(q "SELECT count(*) FROM fc_lz4;")" "20000"
-# an aggregate that forces every value through the decoder, not just row counts
-check "gzip sum matches uncompressed" \
- "$(q "SELECT sum(id)::text FROM pgcolumnar.read_parquet('$W/c_gzip.parquet') AS t($cols);")" \
- "$(q "SELECT sum(id)::text FROM pgcolumnar.read_parquet('$W/c_none.parquet') AS t($cols);")"
+# an aggregate that forces every value through the decoder, not just row counts:
+# sum(0 .. 19999) = 199990000
+check_num "gzip sum matches the generator" \
+ "$(q "SELECT sum(id) FROM pgcolumnar.read_parquet('$W/c_gzip.parquet') AS t($cols);")" "199990000"
# ---- crafted page-header size must yield a clean decode error --------------
-# uncompressed_page_size comes from the page header unvalidated; pq_decompress
-# now consumes it. Patch it to -1 (a single-byte in-place edit) so it arrives as
-# an enormous size_t. The guard must reject with the reader's own "could not
-# decode" error, not a generic "invalid string enlargement" from enlargeStringInfo.
-python3 - "$W" <<'PY'
-import sys, pyarrow as pa, pyarrow.parquet as pq
-W = sys.argv[1]
-pq.write_table(pa.table({"a": pa.array([1, 2, 3], pa.int32())}),
- f"{W}/badhdr.parquet", compression="gzip", use_dictionary=False)
-raw = bytearray(open(f"{W}/badhdr.parquet", "rb").read())
-# First PageHeader begins just past the 4-byte "PAR1" magic. Walk its i32 fields
-# to uncompressed_page_size (field 2) and rewrite that one byte to zigzag(-1)=0x01.
-pos = 4
-def field(last):
- global pos
- b = raw[pos]; pos += 1
- t = b & 0x0F; d = b >> 4
- fid = last + d if d else None
- return t, fid
-def zz_span():
- global pos
- start = pos
- while raw[pos] & 0x80: pos += 1
- pos += 1
- return start, pos
-last = 0; patched = False
-for _ in range(6):
- t, fid = field(last)
- if t == 0: break
- last = fid
- s, e = zz_span()
- if fid == 2:
- assert e - s == 1, (s, e, raw[s:e].hex()) # value 18 -> zigzag 36 = 0x24, 1 byte
- raw[s] = 0x01 # zigzag(-1)
- patched = True
- break
-assert patched, "did not find uncompressed_page_size"
-open(f"{W}/badhdr.parquet", "wb").write(bytes(raw))
-print(" patched uncompressed_page_size -> -1", file=sys.stderr)
-PY
-
+# uncompressed_page_size comes from the page header unvalidated; the codec consumes it.
+# The crafter patched it to -1 (zigzag), so it arrives as an enormous size_t. The guard
+# must reject with the reader's own "could not decode" error, not a generic "invalid
+# string enlargement" from enlargeStringInfo -- and not a crash.
errtext() {
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -At -c "$1" 2>&1 | grep -m1 '^ERROR:'
}
-case "$(errtext "SELECT * FROM pgcolumnar.read_parquet('$W/badhdr.parquet') AS t(a int)")" in
+CRASHES_BEFORE="$(crashes)"
+hdr_err="$(errtext "SELECT * FROM pgcolumnar.read_parquet('$W/badhdr.parquet') AS t(a int)")"
+case "$hdr_err" in
*"could not decode"*) hdr_verdict="CLEAN DECODE ERROR" ;;
- *) hdr_verdict="$(errtext "SELECT * FROM pgcolumnar.read_parquet('$W/badhdr.parquet') AS t(a int)")" ;;
+ *) hdr_verdict="${hdr_err:-no error}" ;;
esac
check "crafted uncompressed size gives a clean decode error" "$hdr_verdict" "CLEAN DECODE ERROR"
-check "backend survived the crafted header" "$(q 'SELECT 1;')" "1"
+check "backend survived the crafted header" "$(survived "$CRASHES_BEFORE")" "1"
pgc_summary
diff --git a/test/native_parquet_fdw.sh b/test/native_parquet_fdw.sh
index 114704ed7..f1c978ebe 100755
--- a/test/native_parquet_fdw.sh
+++ b/test/native_parquet_fdw.sh
@@ -15,13 +15,51 @@ set -uo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"
-expect_error() { # expect_error LABEL SQL
- local label="$1" sql="$2"
- if psql_run "$sql" >/dev/null 2>&1; then
- check "$label (expected error)" "succeeded" "error"
- else
- check "$label" "error" "error"
+wait_up() { local _i; for _i in $(seq 1 60); do [ "$(q 'SELECT 1' 2>/dev/null)" = 1 ] && return; sleep 0.5; done; }
+
+# refusal SQL REGEX LABEL -> " (LABEL)" when SQL raised an error whose
+# message matches REGEX, ": " for any other error, "no refusal:
+# the connection was lost" on a crash, or "no error". These arms used to accept ANY
+# psql failure: with the scan's missing-path guard removed the backend crashed, and
+# with the short-file guard removed a later guard refused instead, and main passed
+# both. Each arm now names its own guard.
+refusal_of() { # stdin: psql output -> the refusal reading
+ local re="$1" label="$2" line state msg
+ local out; out="$(cat)"
+ line="$(sed -n 's/^ERROR: \([0-9A-Z]\{5\}\): /\1|/p' <<<"$out" | head -1)"
+ if [ -z "$line" ]; then
+ grep -qE 'server closed the connection|connection to server' <<<"$out" \
+ && echo "no refusal: the connection was lost" || echo "no error"
+ return
fi
+ state="${line%%|*}"; msg="${line#*|}"
+ if grep -qE -- "$re" <<<"$msg"; then echo "$state ($label)"; else echo "$state: $msg"; fi
+}
+refusal() {
+ wait_up
+ env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA 2>&1 </dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since BEFORE
+ local _up _now
+ wait_up
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"; return; fi
+ if [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1; return; fi
+ echo "no ([$_up], $(( _now - $1 )) backend crashes)"
+}
+# explain_field PLAN NAME -> the integer after "NAME: ", or "no reading" when absent
+explain_field() {
+ local v; v="$(sed -n "s/.*$2: \([0-9][0-9]*\).*/\1/p" <<<"$1" | head -1)"
+ [ -n "$v" ] && echo "$v" || echo "no reading"
}
psql_run "CREATE SERVER pqsrv FOREIGN DATA WRAPPER pgcolumnar_parquet;"
@@ -31,42 +69,73 @@ SCALAR="$PGC_WORKDIR/scalar.parquet"
scalar_cols="c_i2 int2, c_i4 int4, c_i8 int8, c_f4 float4, c_f8 float8, c_bool bool,
c_text text, c_bytea bytea, c_date date, c_time time, c_ts timestamp"
-psql_run "CREATE TABLE s ($scalar_cols) USING pgcolumnar;"
-psql_run "INSERT INTO s
- SELECT (g%100)::int2, g, g::int8*1000, (g/2.0)::float4, (g/4.0)::float8, (g%2=0),
+# THE ORACLE IS A HEAP TABLE WRITTEN FROM THE GENERATOR, not the columnar table the
+# file was exported from: the comparisons then hold the file to the rows that were
+# meant, not to whatever the columnar scan returned on the way out.
+GEN="SELECT (g%100)::int2, g, g::int8*1000, (g/2.0)::float4, (g/4.0)::float8, (g%2=0),
'row'||g, decode(lpad(to_hex(g),8,'0'),'hex'), date '2020-01-01' + g,
time '00:00:00' + (g||' seconds')::interval,
timestamp '2020-01-01' + (g||' minutes')::interval
- FROM generate_series(1, 5000) g;"
+ FROM generate_series(1, 5000) g"
+psql_run "CREATE TABLE s ($scalar_cols) USING pgcolumnar;"
+psql_run "INSERT INTO s $GEN;"
psql_run "INSERT INTO s VALUES (NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);"
+psql_run "CREATE TABLE h ($scalar_cols) USING heap;"
+psql_run "INSERT INTO h $GEN;"
+psql_run "INSERT INTO h VALUES (NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);"
check "scalar export rows" "$(q "SELECT pgcolumnar.export_parquet('s', '$SCALAR');")" "5001"
psql_run "CREATE FOREIGN TABLE fs ($scalar_cols) SERVER pqsrv OPTIONS (path '$SCALAR');"
check "full scan: fdw == source" \
- "$(pgc_set_hash "SELECT * FROM fs")" "$(pgc_set_hash "SELECT * FROM s")"
-check "fdw row count" "$(q "SELECT count(*) FROM fs")" "5001"
+ "$(pgc_set_hash "SELECT * FROM fs")" "$(pgc_set_hash "SELECT * FROM h")"
+check_num "fdw row count" "$(q "SELECT count(*) FROM fs")" "5001"
# projection (executor discards other columns)
check "projection: fdw == source" \
- "$(pgc_set_hash "SELECT c_text FROM fs")" "$(pgc_set_hash "SELECT c_text FROM s")"
+ "$(pgc_set_hash "SELECT c_text FROM fs")" "$(pgc_set_hash "SELECT c_text FROM h")"
# predicate applied by the executor
check "predicate: fdw == source" \
"$(pgc_set_hash "SELECT c_i4, c_text FROM fs WHERE c_i4 > 2500 AND c_bool")" \
- "$(pgc_set_hash "SELECT c_i4, c_text FROM s WHERE c_i4 > 2500 AND c_bool")"
+ "$(pgc_set_hash "SELECT c_i4, c_text FROM h WHERE c_i4 > 2500 AND c_bool")"
# self-join forces the scan to be re-executed (ReScan); result must match source
psql_run "SET enable_hashjoin=off; SET enable_mergejoin=off;"
-check "nested-loop self-join (rescan): fdw == source" \
+check_num "nested-loop self-join (rescan): fdw == source" \
"$(q "SELECT count(*) FROM fs a JOIN fs b ON a.c_i4 = b.c_i4 WHERE a.c_i4 < 200")" \
- "$(q "SELECT count(*) FROM s a JOIN s b ON a.c_i4 = b.c_i4 WHERE a.c_i4 < 200")"
+ "$(q "SELECT count(*) FROM h a JOIN h b ON a.c_i4 = b.c_i4 WHERE a.c_i4 < 200")"
psql_run "RESET enable_hashjoin; RESET enable_mergejoin;"
# the plan is a Foreign Scan
check "EXPLAIN shows Foreign Scan" \
"$(q "EXPLAIN (COSTS OFF) SELECT * FROM fs" | grep -c 'Foreign Scan')" "1"
+# ---- pushdown: projection and row-group skipping ---------------------------
+# Both used to go unmeasured: every check compared answers, which an executor
+# recheck and a discarding projection keep right, so a scan that decoded every
+# column, or skipped no row group, passed. EXPLAIN ANALYZE reports both.
+PROJ="$(q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT c_text FROM fs")"
+check "a one-column query reads one of the eleven columns" \
+ "$(explain_field "$PROJ" 'Columns Read')/$(explain_field "$PROJ" 'Columns Total')" "1/11"
+
+# 200,000 rows export as four 65536-row groups, ascending in id, so id > 196608
+# can only match the last group.
+SKF="$PGC_WORKDIR/skip.parquet"
+psql_run "CREATE TABLE sk (id int8) USING pgcolumnar;"
+psql_run "INSERT INTO sk SELECT g FROM generate_series(1, 200000) g;"
+check_num "skip export rows" "$(q "SELECT pgcolumnar.export_parquet('sk', '$SKF');")" "200000"
+psql_run "CREATE FOREIGN TABLE fsk (id int8) SERVER pqsrv OPTIONS (path '$SKF');"
+SKP="$(q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM fsk WHERE id > 196608::int8")"
+check "the skip file has four row groups" "$(explain_field "$SKP" 'Row Groups')" "4"
+check "a selective predicate skips the three row groups it cannot match" \
+ "$(explain_field "$SKP" 'Row Groups Skipped')" "3"
+# The same query the plan above measured, int8 constant and all: a plain 196608 is an
+# int4 constant, a cross-type comparison that skips nothing (#1712), so the count was
+# read from a full scan and stayed right whatever skipping did (#1711 review).
+check_num "and the skipped scan still returns every matching row" \
+ "$(q "SELECT count(*) FROM fsk WHERE id > 196608::int8")" "3392"
+
# ---- nested foreign table (int[], text[], composite) ------------------------
psql_run "CREATE TYPE fdw_npc AS (x int, y text);"
psql_run "CREATE TABLE np (id int, ia int[], ta text[], c fdw_npc) USING pgcolumnar;"
@@ -84,25 +153,36 @@ check "nested full scan: fdw == source" \
"$(pgc_set_hash "SELECT * FROM fnp")" "$(pgc_set_hash "SELECT * FROM np")"
# ---- option validation + scan errors ----------------------------------------
-expect_error "reject invalid table option" \
- "CREATE FOREIGN TABLE fbad ($scalar_cols) SERVER pqsrv OPTIONS (path '$SCALAR', bogus 'x');"
-expect_error "reject empty path option" \
- "CREATE FOREIGN TABLE fempty ($scalar_cols) SERVER pqsrv OPTIONS (path '');"
+CRASHES_BEFORE="$(crashes)"
+check "reject invalid table option (HV00D)" \
+ "$(refusal "CREATE FOREIGN TABLE fbad ($scalar_cols) SERVER pqsrv OPTIONS (path '$SCALAR', bogus 'x')" \
+ 'invalid option "bogus"' 'invalid option')" "HV00D (invalid option)"
+check "reject empty path option (HV00D)" \
+ "$(refusal "CREATE FOREIGN TABLE fempty ($scalar_cols) SERVER pqsrv OPTIONS (path '')" \
+ '"path" option cannot be empty' 'empty path')" "HV00D (empty path)"
# a foreign table with no path errors at scan time
psql_run "CREATE FOREIGN TABLE fnopath ($scalar_cols) SERVER pqsrv;"
-expect_error "scan without path option errors" "SELECT * FROM fnopath"
+check "scan without path option errors (HV00J)" \
+ "$(refusal "SELECT * FROM fnopath" 'foreign table "fnopath" has no "path" option' 'no path')" "HV00J (no path)"
-# a path that is not a Parquet file errors at scan time
+# a path that is not a Parquet file errors at scan time: 11 bytes, too short to be one
+wait_up
printf 'not parquet' > "$PGC_WORKDIR/bad.dat"
chmod 644 "$PGC_WORKDIR/bad.dat"
+wait_up
psql_run "CREATE FOREIGN TABLE fbadfile ($scalar_cols) SERVER pqsrv OPTIONS (path '$PGC_WORKDIR/bad.dat');"
-expect_error "scan of non-parquet file errors" "SELECT * FROM fbadfile"
+check "scan of non-parquet file errors (XX001)" \
+ "$(refusal "SELECT * FROM fbadfile" 'bad\.dat" is not a Parquet file$' 'too short')" "XX001 (too short)"
# a declared type incompatible with the file column errors at scan time
+wait_up
psql_run "CREATE FOREIGN TABLE fwrongtype (c_i2 int2, c_i4 text, c_i8 int8, c_f4 float4,
c_f8 float8, c_bool bool, c_text text, c_bytea bytea, c_date date, c_time time,
c_ts timestamp) SERVER pqsrv OPTIONS (path '$SCALAR');"
-expect_error "scan with incompatible declared type errors" "SELECT * FROM fwrongtype"
+check "scan with incompatible declared type errors (42804)" \
+ "$(refusal "SELECT * FROM fwrongtype" 'physical type is not compatible with target column "c_i4"' 'type mismatch')" "42804 (type mismatch)"
+
+check "backend still up after the refusals" "$(survived "$CRASHES_BEFORE")" "1"
pgc_summary
diff --git a/test/native_parquet_fieldid.sh b/test/native_parquet_fieldid.sh
index 9f30f95c2..ea832f3bc 100755
--- a/test/native_parquet_fieldid.sh
+++ b/test/native_parquet_fieldid.sh
@@ -13,14 +13,55 @@ set -uo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"
-sqlstate_of() {
+# Every reading first waits for the server, so a backend that crashed in one arm
+# fails that arm and not the ones after it, which would otherwise run into crash
+# recovery.
+wait_up() { local _i; for _i in $(seq 1 60); do [ "$(q 'SELECT 1' 2>/dev/null)" = 1 ] && return; sleep 0.5; done; }
+
+# refusal SQL REGEX LABEL -> " (LABEL)" when SQL raised an error whose
+# message matches REGEX, ": " for any other error, "no refusal:
+# the connection was lost" on a crash, or "no error". Four of these refusals share
+# 22023, so the SQLSTATE alone let one stand in for another: with the "no field
+# ids" guard removed, an id-less file fell through to the absent-id refusal, also
+# 22023, and its check passed. Each arm now names its own guard's message.
+refusal_of() { # stdin: psql output -> the refusal reading
+ local re="$1" label="$2" line state msg
+ local out; out="$(cat)"
+ line="$(sed -n 's/^ERROR: \([0-9A-Z]\{5\}\): /\1|/p' <<<"$out" | head -1)"
+ if [ -z "$line" ]; then
+ grep -qE 'server closed the connection|connection to server' <<<"$out" \
+ && echo "no refusal: the connection was lost" || echo "no error"
+ return
+ fi
+ state="${line%%|*}"; msg="${line#*|}"
+ if grep -qE -- "$re" <<<"$msg"; then echo "$state ($label)"; else echo "$state: $msg"; fi
+}
+refusal() {
+ wait_up
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -qtA 2>&1 <&1 </dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since BEFORE
+ local _up _now
+ wait_up
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"; return; fi
+ if [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1; return; fi
+ echo "no ([$_up], $(( _now - $1 )) backend crashes)"
+}
+
python3 -c 'import pyarrow.parquet' 2>/dev/null || pgc_skip pyarrow "pyarrow is needed to write the field-id fixture"
FID="$PGC_WORKDIR/fieldid.parquet" # alpha=7, beta=3, gamma=12
@@ -75,26 +116,33 @@ check "positional read_parquet is unaffected" \
ORDER BY alpha")" \
"$(printf '10|x|100\n20|y|200\n30|z|300')"
-# ---- refusals, each its own SQLSTATE ---------------------------------------
+# ---- refusals, each its own SQLSTATE and its own guard's message -----------
+CRASHES_BEFORE="$(crashes)"
check "an absent field id is refused (22023)" \
- "$(sqlstate_of "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[999]) AS t(x int)")" "22023"
+ "$(refusal "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[999]) AS t(x int)" \
+ 'no Parquet column has field id 999' 'absent id')" "22023 (absent id)"
check "a duplicate field id in the file is refused (42702)" \
- "$(sqlstate_of "SELECT * FROM pgcolumnar.read_parquet('$DUP', ARRAY[5]) AS t(x int)")" "42702"
+ "$(refusal "SELECT * FROM pgcolumnar.read_parquet('$DUP', ARRAY[5]) AS t(x int)" \
+ 'field id 5 is not unique among' 'duplicate in the file')" "42702 (duplicate in the file)"
check "a nested/array output column is refused (0A000)" \
- "$(sqlstate_of "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[7]) AS t(a int[])")" "0A000"
+ "$(refusal "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[7]) AS t(a int[])" \
+ 'supports only scalar columns' 'not scalar')" "0A000 (not scalar)"
check "a field_ids/column-count mismatch is refused (22023)" \
- "$(sqlstate_of "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[7,3]) AS t(a int)")" "22023"
+ "$(refusal "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[7,3]) AS t(a int)" \
+ 'field_ids has 2 element\(s\) but the column definition list has 1 column' 'count mismatch')" "22023 (count mismatch)"
check "a file with no field ids is refused (22023)" \
- "$(sqlstate_of "SELECT * FROM pgcolumnar.read_parquet('$NOID', ARRAY[1]) AS t(x int)")" "22023"
+ "$(refusal "SELECT * FROM pgcolumnar.read_parquet('$NOID', ARRAY[1]) AS t(x int)" \
+ 'carries no field ids' 'no field ids')" "22023 (no field ids)"
# a duplicate REQUESTED id (two output columns asking for one file column) is
# refused up front with a clear cause, not left to fail deep in the decode with a
# message that wrongly blames the file (OffgridwithJD's #638 note).
check "a duplicate requested field id is refused (22023)" \
- "$(sqlstate_of "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[7,7]) AS t(a1 int, a2 int)")" "22023"
+ "$(refusal "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[7,7]) AS t(a1 int, a2 int)" \
+ 'field id 7 is requested more than once' 'requested twice')" "22023 (requested twice)"
check "the duplicate-requested-id error names the id, not the file" \
"$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" -qtA \
-c "SELECT * FROM pgcolumnar.read_parquet('$FID', ARRAY[7,7]) AS t(a1 int, a2 int)" 2>&1 \
| grep -c 'requested more than once')" "1"
-check "backend still up after the refusals" "$(q 'SELECT 1')" "1"
+check "backend still up after the refusals" "$(survived "$CRASHES_BEFORE")" "1"
pgc_summary
diff --git a/test/native_parquet_hardening.sh b/test/native_parquet_hardening.sh
index 9a8a0b756..a0e4f2ade 100755
--- a/test/native_parquet_hardening.sh
+++ b/test/native_parquet_hardening.sh
@@ -14,6 +14,18 @@
# Run on an assert-enabled build so an out-of-bounds access trips an assertion
# rather than passing silently.
#
+# HOW A REFUSAL IS READ. Four of the five range refusals share 22008, so each check
+# wants its guard's own message ("value out of range for type ") as well as the
+# SQLSTATE. Every reading first waits for the server, and survival also reads the
+# server log's count of backends killed by a signal: SELECT 1 answers again once
+# crash recovery has finished, so it alone passes a crash (#1341).
+#
+# HOW AN IN-RANGE BIND IS READ. Each "still reads" check compares the decoded values
+# with the ones the writer stored, not a row count: a decoder one day or one
+# microsecond off returned the right count and passed (#1341). The no-wrap check
+# reads the out-of-range file itself, which must refuse; it used to count a wrapped
+# value in the in-range file, which never holds one, so it could not fail.
+#
# Usage: test/native_parquet_hardening.sh [PG_CONFIG]
# Written fresh for pgColumnar.
@@ -26,15 +38,32 @@ if ! python3 -c 'import pyarrow.parquet' 2>/dev/null; then
fi
W="$PGC_WORKDIR"
+
+wait_up() { local _i; for _i in $(seq 1 60); do [ "$(q 'SELECT 1' 2>/dev/null)" = 1 ] && return; sleep 0.5; done; }
+crashes() {
+ local _n
+ _n="$(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since BEFORE
+ local _up _now
+ wait_up
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"; return; fi
+ if [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1; return; fi
+ echo "no ([$_up], $(( _now - $1 )) backend crashes)"
+}
+# qw SQL -> q's answer, after the server answers: a crash in an earlier arm must
+# fail only that arm, not every reading made during recovery.
+qw() { wait_up; q "$1"; }
+CRASHES_BEFORE="$(crashes)"
psql_run "CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet;"
-# errtext QUERY -> the raised error text, or "NO ERROR"
-errtext() {
- local out
- out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -At -c "$1" 2>&1 | grep -m1 '^ERROR:')"
- [ -z "$out" ] && out="NO ERROR"
- echo "$out"
+# errfull QUERY -> psql's whole output for QUERY (ERROR and DETAIL lines included)
+errfull() {
+ wait_up
+ env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -c "$1" 2>&1
}
# errs QUERY -> "OK" when the statement raises, "NO ERROR: " when it does not
@@ -49,37 +78,7 @@ errs() {
}
# ---- narrowing: out-of-range values must raise, not wrap --------------------
-python3 - "$W" <<'PY'
-import sys, pyarrow as pa, pyarrow.parquet as pq
-W = sys.argv[1]
-# int2 bound over INT32: 40000 does not fit int16 (would wrap to -25536)
-pq.write_table(pa.table({"c": pa.array([30000, 40000], pa.int32())}),
- f"{W}/i2_bad.parquet")
-# same bind, all values in range: must keep working
-pq.write_table(pa.table({"c": pa.array([-30000, 0, 30000], pa.int32())}),
- f"{W}/i2_ok.parquet")
-# time bound over INT64 micros: beyond one day is not a time
-pq.write_table(pa.table({"c": pa.array([0, 86400000000 + 1], pa.int64())}),
- f"{W}/time_bad.parquet")
-pq.write_table(pa.table({"c": pa.array([0, 3600000000], pa.int64())}),
- f"{W}/time_ok.parquet")
-# date bound over INT32 days since epoch. INT32 max does NOT overflow the epoch
-# offset (2147483647 - 10957 stays in range); it is rejected by IS_VALID_DATE.
-pq.write_table(pa.table({"c": pa.array([0, 2147483647], pa.int32())}),
- f"{W}/date_bad.parquet")
-# INT32 min is what overflows the subtraction itself, exercising the other branch
-pq.write_table(pa.table({"c": pa.array([0, -2147483648], pa.int32())}),
- f"{W}/date_ovf.parquet")
-pq.write_table(pa.table({"c": pa.array([0, 19000], pa.int32())}),
- f"{W}/date_ok.parquet")
-# timestamp bound over INT64 micros: INT64 min overflows the epoch-offset
-# subtraction. INT64 max does NOT -- it still lands inside PostgreSQL's timestamp
-# range (year ~294276), so it is deliberately not the case tested here.
-pq.write_table(pa.table({"c": pa.array([0, -9223372036854775808], pa.int64())}),
- f"{W}/ts_bad.parquet")
-pq.write_table(pa.table({"c": pa.array([0, 1700000000000000], pa.int64())}),
- f"{W}/ts_ok.parquet")
-PY
+python3 "$(dirname "${BASH_SOURCE[0]}")/craft_parquet_hardening.py" "$W"
psql_run "CREATE FOREIGN TABLE f_i2_bad (c int2) SERVER pq OPTIONS (path '$W/i2_bad.parquet');"
psql_run "CREATE FOREIGN TABLE f_i2_ok (c int2) SERVER pq OPTIONS (path '$W/i2_ok.parquet');"
@@ -95,164 +94,68 @@ psql_run "CREATE FOREIGN TABLE f_ts_ok (c timestamp) SERVER pq OPTIONS (path
# cannot pass these: 22003 numeric_value_out_of_range, 22008 datetime_field_overflow.
psql_run "CREATE FUNCTION pgc_try(q text) RETURNS text LANGUAGE plpgsql AS \$\$
BEGIN EXECUTE q; RETURN 'NO ERROR';
+ EXCEPTION WHEN OTHERS THEN RETURN SQLSTATE || ': ' || SQLERRM; END \$\$;"
+# pgc_value(q) -> the query's one value as text, or its SQLSTATE when it raises
+psql_run "CREATE FUNCTION pgc_value(q text) RETURNS text LANGUAGE plpgsql AS \$\$
+ DECLARE r text;
+ BEGIN EXECUTE q INTO r; RETURN coalesce(r, '');
EXCEPTION WHEN OTHERS THEN RETURN SQLSTATE; END \$\$;"
-sqlstate() { q "SELECT pgc_try(\$q\$$1\$q\$);"; }
+# refusal SQL -> "SQLSTATE: message" of the error SQL raised, or NO ERROR
+sqlstate() { qw "SELECT pgc_try(\$q\$$1\$q\$);"; }
-check "int2 out of range -> 22003" "$(sqlstate 'SELECT * FROM f_i2_bad')" "22003"
-check "time out of day -> 22008" "$(sqlstate 'SELECT * FROM f_time_bad')" "22008"
-check "date invalid -> 22008" "$(sqlstate 'SELECT * FROM f_date_bad')" "22008"
-check "date offset overflow -> 22008" "$(sqlstate 'SELECT * FROM f_date_ovf')" "22008"
-check "timestamp overflow -> 22008" "$(sqlstate 'SELECT * FROM f_ts_bad')" "22008"
+check "int2 out of range -> 22003" "$(sqlstate 'SELECT * FROM f_i2_bad')" "22003: value out of range for type smallint"
+check "time out of day -> 22008" "$(sqlstate 'SELECT * FROM f_time_bad')" "22008: value out of range for type time"
+check "date invalid -> 22008" "$(sqlstate 'SELECT * FROM f_date_bad')" "22008: value out of range for type date"
+check "date offset overflow -> 22008" "$(sqlstate 'SELECT * FROM f_date_ovf')" "22008: value out of range for type date"
+check "timestamp overflow -> 22008" "$(sqlstate 'SELECT * FROM f_ts_bad')" "22008: value out of range for type timestamp"
# the legitimate binds must still work: the check must not disable them
-check "int2 in range still reads" "$(q "SELECT string_agg(c::text, ' ' ORDER BY c) FROM f_i2_ok;")" "-30000 0 30000"
-check "time in range still reads" "$(q 'SELECT count(*) FROM f_time_ok;')" "2"
-check "date in range still reads" "$(q 'SELECT count(*) FROM f_date_ok;')" "2"
-check "timestamp in range still reads" "$(q 'SELECT count(*) FROM f_ts_ok;')" "2"
+check "int2 in range still reads" "$(qw "SELECT string_agg(c::text, ' ' ORDER BY c) FROM f_i2_ok;")" "-30000 0 30000"
+check "time in range still reads" "$(qw "SELECT string_agg(c::text, ' ' ORDER BY c) FROM f_time_ok;")" "00:00:00 01:00:00"
+check "date in range still reads" "$(qw "SELECT string_agg(c::text, ' ' ORDER BY c) FROM f_date_ok;")" "1970-01-01 2022-01-08"
+check "timestamp in range still reads" "$(qw "SELECT string_agg(c::text, ',' ORDER BY c) FROM f_ts_ok;")" "1970-01-01 00:00:00,2023-11-14 22:13:20"
-# no wrapped value may ever be observable
+# no wrapped value may ever be observable: the out-of-range file itself must refuse
+# rather than answer. Counting -25536 in f_i2_ok, which never holds 40000, could not fail.
check "no wrapped -25536 appears" \
- "$(q "SELECT count(*) FROM f_i2_ok WHERE c = (-25536)::int2;")" "0"
+ "$(qw "SELECT pgc_value(\$q\$SELECT count(*) FROM f_i2_bad WHERE c = (-25536)::int2\$q\$);")" "22003"
# ---- the same decoder is shared by read_parquet and import_parquet ----------
check "read_parquet also raises 22003" \
- "$(sqlstate "SELECT * FROM pgcolumnar.read_parquet('$W/i2_bad.parquet') AS t(c int2)")" "22003"
+ "$(sqlstate "SELECT * FROM pgcolumnar.read_parquet('$W/i2_bad.parquet') AS t(c int2)")" "22003: value out of range for type smallint"
check "read_parquet in range still reads" \
- "$(q "SELECT count(*) FROM pgcolumnar.read_parquet('$W/i2_ok.parquet') AS t(c int2);")" "3"
+ "$(qw "SELECT string_agg(c::text, ' ' ORDER BY c) FROM pgcolumnar.read_parquet('$W/i2_ok.parquet') AS t(c int2);")" "-30000 0 30000"
psql_run "CREATE TABLE imp_bad (c int2);"
check "import_parquet also raises 22003" \
- "$(sqlstate "SELECT pgcolumnar.import_parquet('imp_bad'::regclass, '$W/i2_bad.parquet')")" "22003"
-check "import_parquet left no wrapped rows" "$(q 'SELECT count(*) FROM imp_bad;')" "0"
+ "$(sqlstate "SELECT pgcolumnar.import_parquet('imp_bad'::regclass, '$W/i2_bad.parquet')")" "22003: value out of range for type smallint"
+check_num "import_parquet left no wrapped rows" "$(qw 'SELECT count(*) FROM imp_bad;')" "0"
# ---- malformed row-group chunk list ----------------------------------------
-# Truncate the column-chunk list of a valid two-column file so the row group
-# carries fewer chunks than the schema has leaves. The reader indexes chunks[] by
-# the schema leaf count, so this must be rejected at parse time.
-python3 - "$W" <<'PY'
-import sys, pyarrow as pa, pyarrow.parquet as pq
-W = sys.argv[1]
-pq.write_table(pa.table({"a": pa.array([1, 2, 3], pa.int32()),
- "b": pa.array([4, 5, 6], pa.int32())}),
- f"{W}/two.parquet")
-
-# Walk the Thrift-compact footer to the exact byte holding the row group's
-# `columns` list header, then claim it holds one fewer chunk than it does.
-# Blind byte-searching is not reliable here: the same byte value occurs in the
-# data pages, so the header has to be located structurally.
-raw = bytearray(open(f"{W}/two.parquet", "rb").read())
-flen = int.from_bytes(raw[-8:-4], "little")
-base = len(raw) - 8 - flen # start of FileMetaData
-pos = base
-
-def varint():
- global pos
- shift = 0; out = 0
- while True:
- b = raw[pos]; pos += 1
- out |= (b & 0x7F) << shift
- if not b & 0x80:
- return out
- shift += 7
-
-def zigzag():
- u = varint()
- return (u >> 1) ^ -(u & 1)
-
-def list_hdr():
- global pos
- b = raw[pos]; pos += 1
- size = (b >> 4) & 0x0F
- et = b & 0x0F
- if size == 0x0F:
- size = varint()
- return size, et
-
-def skip(t):
- global pos
- if t in (1, 2): return # bool: value is in the header
- if t == 3: pos += 1 # byte
- elif t in (4, 5, 6): zigzag() # i16/i32/i64
- elif t == 7: pos += 8 # double
- elif t == 8: pos += varint() # binary
- elif t in (9, 10):
- n, et = list_hdr()
- for _ in range(n): skip(et)
- elif t == 12:
- last = 0
- while True:
- ft, _fid, last = field(last)
- if ft == 0: break
- skip(ft)
-
-def field(last):
- global pos
- b = raw[pos]; pos += 1
- if b == 0:
- return 0, 0, last
- t = b & 0x0F
- delta = b >> 4
- fid = last + delta if delta else zigzag()
- return t, fid, fid
-
-# FileMetaData -> field 4 (row_groups) -> first RowGroup -> field 1 (columns)
-target = None
-last = 0
-while True:
- ft, fid, last = field(last)
- if ft == 0:
- break
- if fid == 4 and ft == 9: # row_groups: list
- nrg, _et = list_hdr()
- assert nrg >= 1, nrg
- rlast = 0
- while True:
- rft, rfid, rlast = field(rlast)
- if rft == 0:
- break
- if rfid == 1 and rft == 9: # columns: list
- target = pos # byte holding this list's header
- n, et = list_hdr()
- assert n == 2, f"expected 2 column chunks, got {n}"
- skip(et) # past ColumnChunk[0]
- start2 = pos
- skip(et) # past ColumnChunk[1]
- end2 = pos
- break
- skip(rft)
- break
- skip(ft)
-
-assert target is not None, "row group columns list not found"
-# Remove ColumnChunk[1] outright and say so in the header, so the footer stays a
-# well-formed Thrift struct. Decrementing the count alone would desync the parser
-# and fail for an unrelated reason; the only defect must be that the row group
-# carries fewer chunks than the schema has leaves.
-assert (raw[target] >> 4) == 2, hex(raw[target])
-raw[target] = (1 << 4) | (raw[target] & 0x0F)
-removed = end2 - start2
-del raw[start2:end2]
-raw[-8:-4] = (flen - removed).to_bytes(4, "little")
-open(f"{W}/short_chunks.parquet", "wb").write(bytes(raw))
-print(f" columns list at {target}: 2 chunks -> 1, removed {removed} footer bytes",
- file=sys.stderr)
-PY
-
+# The crafter cut the column-chunk list of a valid two-column file so its row group
+# carries fewer chunks than the schema has leaves (short_chunks.parquet). The reader
+# indexes chunks[] by the schema leaf count, so this must be rejected at parse time.
psql_run "CREATE FOREIGN TABLE f_short (a int4, b int4) SERVER pq OPTIONS (path '$W/short_chunks.parquet');"
# Must be the specific malformed-row-group rejection. Before the guard this read
# chunks[1] out of bounds and failed incidentally, e.g. "invalid memory alloc
# request size", an out-of-bounds read that merely tripped an allocator check.
-case "$(errtext 'SELECT * FROM f_short;')" in
- *"has a malformed row group"*) short_verdict="CLEAN REJECT" ;;
- *) short_verdict="$(errtext 'SELECT * FROM f_short;')" ;;
+#
+# Five guards raise "has a malformed row group", so the message alone let another one
+# stand in: with the chunk-count guard removed, a later guard rejected the file and
+# this passed 18/18 (#1341). The verdict wants THIS guard's detail.
+short_out="$(errfull 'SELECT * FROM f_short;')"
+case "$short_out" in
+ *"has a malformed row group"*"carries 1 column chunks, but the schema has 2 leaf columns"*)
+ short_verdict="CLEAN REJECT" ;;
+ *) short_verdict="$(printf '%s' "$short_out" | grep -m1 -E '^(ERROR|DETAIL):' || echo "NO ERROR")" ;;
esac
check "short chunk list rejected with a specific error" "$short_verdict" "CLEAN REJECT"
-check "backend survived short chunk list" "$(q 'SELECT 1;')" "1"
+check "backend survived short chunk list" "$(survived "$CRASHES_BEFORE")" "1"
# The validation gates the read paths, not parsing, so the diagnostic function
# still works on exactly the file one would be diagnosing.
check "parquet_schema still describes a bad chunk list" \
- "$(q "SELECT count(*) FROM pgcolumnar.parquet_schema('$W/short_chunks.parquet');")" "2"
+ "$(qw "SELECT count(*) FROM pgcolumnar.parquet_schema('$W/short_chunks.parquet');")" "2"
check "valid two-column file still reads" \
- "$(q "SELECT count(*) FROM pgcolumnar.read_parquet('$W/two.parquet') AS t(a int4, b int4);")" "3"
+ "$(qw "SELECT string_agg(a || '|' || b, ' ' ORDER BY a) FROM pgcolumnar.read_parquet('$W/two.parquet') AS t(a int4, b int4);")" "1|4 2|5 3|6"
pgc_summary
diff --git a/test/native_parquet_partition.sh b/test/native_parquet_partition.sh
index 340cb0a0b..9dad84cdf 100755
--- a/test/native_parquet_partition.sh
+++ b/test/native_parquet_partition.sh
@@ -48,28 +48,75 @@ psql_run "CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet;"
psql_run "CREATE FOREIGN TABLE ev (id int, v text, dt date, region text)
SERVER pq OPTIONS (path '$ROOT', partition_columns 'dt,region');"
-files_for() { # files_for WHERE -> files actually read
+wait_up() { local _i; for _i in $(seq 1 60); do [ "$(q 'SELECT 1' 2>/dev/null)" = 1 ] && return; sleep 0.5; done; }
+
+# explain WHERE -> the EXPLAIN ANALYZE of a count over ev under WHERE, or nothing on failure
+explain() {
+ wait_up
q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
- SELECT count(*) FROM ev WHERE $1" \
- | grep -E '^\s*Files:' | grep -oE '[0-9]+' | head -1
+ SELECT count(*) FROM ${2:-ev} WHERE $1"
+}
+# files_for WHERE [TABLE] -> files actually read, or "no reading" when the plan has no
+# Files line (a failed EXPLAIN printed nothing, which used to read as an empty count)
+files_for() {
+ local out; out="$(explain "$1" "${2:-ev}")"
+ grep -qE '^\s*Files:' <<<"$out" || { echo "no reading (no Files line)"; return; }
+ grep -E '^\s*Files:' <<<"$out" | grep -oE '[0-9]+' | head -1
+}
+# pruned_for WHERE [TABLE] -> files dropped before opening. EXPLAIN prints "Files Pruned"
+# only when it is above 0, so a missing line is 0 ONLY when the plan's Files line shows
+# the scan ran. It used to default to 0 on any failure, so with the server stopped the
+# four "prunes nothing" checks passed on no reading at all.
+pruned_for() {
+ local out; out="$(explain "$1" "${2:-ev}")"
+ grep -qE '^\s*Files:' <<<"$out" || { echo "no reading (no Files line)"; return; }
+ local n; n="$(grep 'Files Pruned' <<<"$out" | grep -oE '[0-9]+' | head -1)"
+ echo "${n:-0}"
+}
+
+# refusal SQL REGEX LABEL -> " (LABEL)" when SQL raised an error whose message
+# matches REGEX, ": " for any other error, "no refusal: the connection
+# was lost" on a crash, or "no error". The old errs() took ANY error: with the
+# unknown-column guard removed, a later guard's error stood in and main passed 35/35,
+# and with the explicit encoded-NUL guard removed, the encoding check's error did.
+refusal_of() { # stdin: psql output -> the refusal reading
+ local re="$1" label="$2" line state msg
+ local out; out="$(cat)"
+ line="$(sed -n 's/^ERROR: \([0-9A-Z]\{5\}\): /\1|/p' <<<"$out" | head -1)"
+ if [ -z "$line" ]; then
+ grep -qE 'server closed the connection|connection to server' <<<"$out" \
+ && echo "no refusal: the connection was lost" || echo "no error"
+ return
+ fi
+ state="${line%%|*}"; msg="${line#*|}"
+ if grep -qE -- "$re" <<<"$msg"; then echo "$state ($label)"; else echo "$state: $msg"; fi
+}
+refusal() {
+ wait_up
+ env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA 2>&1 < files dropped before opening
- local out
- out="$(q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
- SELECT count(*) FROM ev WHERE $1" \
- | grep 'Files Pruned' | grep -oE '[0-9]+' | head -1)"
- [ -z "$out" ] && out=0
- echo "$out"
+
+# Backends killed by a signal, from the server log: a number, or nothing when the log
+# can't be read. A bare "SELECT 1" answers again once crash recovery is over: with the
+# unknown-column refusal turned into abort(), the old survival check passed.
+crashes() {
+ local _n
+ _n="$(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
}
-errs() {
- local out
- out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" \
- -U postgres -d "$PGC_DB" -At -c "$1" 2>&1)"
- case "$out" in
- *ERROR*) echo OK ;;
- *) echo "NO ERROR" ;;
- esac
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since BEFORE
+ local _up _now
+ wait_up
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"; return; fi
+ if [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1; return; fi
+ echo "no ([$_up], $(( _now - $1 )) backend crashes)"
}
+CRASHES_BEFORE="$(crashes)"
# ---- the values are real columns -------------------------------------------
check "all four partitions read" "$(q 'SELECT count(*) FROM ev;')" "4000"
@@ -101,6 +148,7 @@ check "a predicate on a file column prunes no files" "$(pruned_for 'id < 10')" "
# The point of pruning is that the file is never opened, so its row groups must
# not appear in the row-group counter either.
+wait_up
groups_all="$(q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM ev" \
| grep -E '^\s*Row Groups:' | grep -oE '[0-9]+' | head -1)"
groups_one="$(q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
@@ -149,12 +197,13 @@ check "a stable clause still prunes" \
mkdir -p "$ROOT/dt=2026-01-03"
cp "$ROOT/dt=2026-01-01/region=eu/part.parquet" "$ROOT/dt=2026-01-03/loose.parquet"
check "a file missing a declared partition component errors" \
- "$(errs 'SELECT count(*) FROM ev;')" "OK"
+ "$(refusal 'SELECT count(*) FROM ev' 'has no "region=" directory component' 'its own message')" "HV004 (its own message)"
rm -rf "$ROOT/dt=2026-01-03"
# Only components between the root and the file are partition components, so a
# file named like one does not set a column from its own basename.
cp "$ROOT/dt=2026-01-01/region=eu/part.parquet" "$ROOT/dt=2026-01-01/region=eu/dt=1999-01-01.parquet"
+wait_up
check "a file whose basename looks like a partition component is unaffected" \
"$(q "SELECT count(*) FROM ev WHERE dt = DATE '1999-01-01';")" "0"
rm -f "$ROOT/dt=2026-01-01/region=eu/dt=1999-01-01.parquet"
@@ -162,20 +211,20 @@ rm -f "$ROOT/dt=2026-01-01/region=eu/dt=1999-01-01.parquet"
psql_run "CREATE FOREIGN TABLE ev_bad (id int, v text, dt date, region text)
SERVER pq OPTIONS (path '$ROOT', partition_columns 'dt,nosuch');"
check "an unknown partition column errors rather than being ignored" \
- "$(errs 'SELECT count(*) FROM ev_bad;')" "OK"
+ "$(refusal 'SELECT count(*) FROM ev_bad' '"nosuch" named in "partition_columns" does not exist' 'its own message')" "42703 (its own message)"
check "an empty partition_columns option is rejected" \
- "$(errs "CREATE FOREIGN TABLE ev_empty (id int) SERVER pq
- OPTIONS (path '$ROOT', partition_columns '');")" "OK"
+ "$(refusal "CREATE FOREIGN TABLE ev_empty (id int) SERVER pq
+ OPTIONS (path '$ROOT', partition_columns '')" '"partition_columns" option cannot be empty' 'its own message')" "HV00D (its own message)"
-# A declared partition column is not bound against the file's leaves, so the
-# file's own column count still has to match what is left. Declaring a column
-# that the file DOES carry as a partition column must therefore fail.
+# Declaring a column the file DOES carry as a partition column must fail. Measured:
+# what refuses it is the missing-component guard (no "v=" directory on the path),
+# which runs before the file's leaves are bound, so this check names that message.
psql_run "CREATE FOREIGN TABLE ev_dup (id int, v text, dt date, region text)
SERVER pq OPTIONS (path '$ROOT', partition_columns 'dt,region,v');"
check "declaring a file column as a partition column errors" \
- "$(errs 'SELECT count(*) FROM ev_dup;')" "OK"
-check "backend survived the bad declarations" "$(q 'SELECT 1;')" "1"
+ "$(refusal 'SELECT count(*) FROM ev_dup' 'has no "v=" directory component' 'its own message')" "HV004 (its own message)"
+check "backend survived the bad declarations" "$(survived "$CRASHES_BEFORE")" "1"
# ---- Hive value encoding ---------------------------------------------------
#
@@ -215,10 +264,7 @@ check "all three partitions still read" "$(q 'SELECT count(*) FROM ev_enc;')" "3
# A null partition value prunes like any other: IS NOT NULL excludes exactly the
# file whose directory carries the marker.
-check "a null partition value prunes" \
- "$(q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
- SELECT count(*) FROM ev_enc WHERE region IS NOT NULL" \
- | grep 'Files Pruned' | grep -oE '[0-9]+' | head -1)" "1"
+check "a null partition value prunes" "$(pruned_for "region IS NOT NULL" ev_enc)" "1"
# Percent-encoding can carry any byte, and the value becomes a PostgreSQL text
# datum, so two byte sequences have to be refused rather than passed through.
@@ -229,15 +275,17 @@ mkdir -p "$ENC/region=a%00b" "$ENC/region=a%FFb"
cp "$ENC/region=plain/part.parquet" "$ENC/region=a%00b/part.parquet"
cp "$ENC/region=plain/part.parquet" "$ENC/region=a%FFb/part.parquet"
# The NUL case is caught by the encoding validation as well as by the explicit
-# check, so this asserts the pair rather than either one; removing the explicit
-# check leaves it passing, which was verified rather than assumed.
+# check. Any error used to pass, so with the explicit check removed the encoding
+# check's error stood in and main passed 35/35. The check now wants the explicit
+# check's own message, so it fails when that guard is gone.
check "an encoded null byte in a partition value is rejected" \
- "$(errs 'SELECT count(*) FROM ev_enc;')" "OK"
+ "$(refusal 'SELECT count(*) FROM ev_enc' 'contains an encoded null byte' 'its own message')" "HV004 (its own message)"
rm -rf "$ENC/region=a%00b"
check "a byte invalid in the server encoding is rejected" \
- "$(errs 'SELECT count(*) FROM ev_enc;')" "OK"
+ "$(refusal 'SELECT count(*) FROM ev_enc' 'invalid byte sequence for encoding' 'its own message')" "22021 (its own message)"
rm -rf "$ENC/region=a%FFb"
+wait_up
check "the tree reads again once both are gone" "$(q 'SELECT count(*) FROM ev_enc;')" "300"
-check "backend survived the malformed partition values" "$(q 'SELECT 1;')" "1"
+check "backend survived the malformed partition values" "$(survived "$CRASHES_BEFORE")" "1"
pgc_summary
diff --git a/test/native_parquet_projection.sh b/test/native_parquet_projection.sh
index 8213fd7d7..1e7933c40 100755
--- a/test/native_parquet_projection.sh
+++ b/test/native_parquet_projection.sh
@@ -9,6 +9,18 @@
# for SELECT *), and -- the load-bearing proof -- that a column which would error
# on decode is not read when it is projected out.
#
+# THE ORACLE IS THE GENERATOR, NOT THE READER (#1341). h used to be filled with
+# read_parquet of the same file, and read_parquet shares the scan's decoders: with a
+# text decoder dropping a byte, or an INT64 decoder off by one, both sides read the
+# same wrong values and every projection agreed (main passed 15/15 under each). h is
+# now built in SQL from the generator the crafter wrote the file from.
+#
+# A DROPPED COLUMN SHIFTS ATTRIBUTE NUMBERS. Projection matches each target column's
+# attribute number against the planner's needed set. With no dropped column the
+# attribute number equals the target's index, so a reader matching by index passed
+# 15/15. ftd drops a column ahead of the others, and its projection must still read
+# exactly the column asked for.
+#
# Usage: test/native_parquet_projection.sh [PG_CONFIG]
# Written fresh for pgColumnar.
@@ -23,28 +35,25 @@ fi
W="$PGC_WORKDIR"
psql_run "CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet;"
-python3 - "$W" <<'PY'
-import sys, pyarrow as pa, pyarrow.parquet as pq
-W = sys.argv[1]
-n = 5000
-pq.write_table(pa.table({
- "a": pa.array(range(n), pa.int32()),
- "b": pa.array([i * 2 for i in range(n)], pa.int64()),
- "c": pa.array([f"s{i%20}" for i in range(n)]),
- "d": pa.array([i / 3.0 for i in range(n)], pa.float64()),
-}), f"{W}/proj.parquet", row_group_size=1000)
-PY
+python3 "$(dirname "${BASH_SOURCE[0]}")/craft_parquet_projection.py" "$W"
cols="a int, b int8, c text, d float8"
psql_run "CREATE FOREIGN TABLE ft ($cols) SERVER pq OPTIONS (path '$W/proj.parquet');"
psql_run "CREATE TABLE h ($cols);"
-psql_run "INSERT INTO h SELECT * FROM pgcolumnar.read_parquet('$W/proj.parquet') AS t($cols);"
+# the generator the crafter wrote proj.parquet from, in SQL: never read back through
+# the reader under test
+psql_run "INSERT INTO h SELECT g, g * 2, 's' || (g % 20), g::float8 / 3.0 FROM generate_series(0, 4999) g;"
+# the same file behind a table whose first declared column is dropped: every later
+# column's attribute number is one more than its position among the live columns
+psql_run "CREATE FOREIGN TABLE ftd (z int, $cols) SERVER pq OPTIONS (path '$W/proj.parquet');"
+psql_run "ALTER FOREIGN TABLE ftd DROP COLUMN z;"
# ---- correctness: projected columns match the oracle -----------------------
-for sel in "a" "c" "a, d" "b, c, d" "*"; do
- check "projection [$sel] == oracle" \
- "$(pgc_set_hash "SELECT $sel FROM ft")" "$(pgc_set_hash "SELECT $sel FROM h")"
-done
+check "projection [a] == oracle" "$(pgc_set_hash "SELECT a FROM ft")" "$(pgc_set_hash "SELECT a FROM h")"
+check "projection [c] == oracle" "$(pgc_set_hash "SELECT c FROM ft")" "$(pgc_set_hash "SELECT c FROM h")"
+check "projection [a, d] == oracle" "$(pgc_set_hash "SELECT a, d FROM ft")" "$(pgc_set_hash "SELECT a, d FROM h")"
+check "projection [b, c, d] == oracle" "$(pgc_set_hash "SELECT b, c, d FROM ft")" "$(pgc_set_hash "SELECT b, c, d FROM h")"
+check "projection [*] == oracle" "$(pgc_set_hash "SELECT * FROM ft")" "$(pgc_set_hash "SELECT * FROM h")"
# a qual column that is not selected must still be read for the recheck
check "qual on unselected column == oracle" \
"$(pgc_set_hash "SELECT a FROM ft WHERE c = 's7'")" \
@@ -59,6 +68,11 @@ check "one column reads 1" "$(cols_read "SELECT a FROM ft")" "1"
check "two columns read 2" "$(cols_read "SELECT a, d FROM ft")" "2"
check "select star reads 4" "$(cols_read "SELECT * FROM ft")" "4"
check "count(*) reads 0" "$(cols_read "SELECT count(*) FROM ft")" "0"
+
+# ---- a dropped column ahead of the others ------------------------------------
+check "after a dropped column, projection [d] == oracle" \
+ "$(pgc_set_hash "SELECT d FROM ftd")" "$(pgc_set_hash "SELECT d FROM h")"
+check "after a dropped column, one column reads 1" "$(cols_read "SELECT d FROM ftd")" "1"
# a qual column counts as read even when not selected
check "selected 1 + qual 1 reads 2" "$(cols_read "SELECT a FROM ft WHERE c = 's7'")" "2"
@@ -68,22 +82,7 @@ check "selected 1 + qual 1 reads 2" "$(cols_read "SELECT a FROM ft WHERE c = 's7
# first column must succeed (the second is never decoded), while reading the
# second must fail. If projection did not skip decode, selecting only the first
# would also hit the corruption.
-python3 - "$W" <<'PY'
-import sys, pyarrow as pa, pyarrow.parquet as pq
-W = sys.argv[1]
-pq.write_table(pa.table({"g": pa.array(range(1000), pa.int32()),
- "bad": pa.array(range(1000), pa.int32())}),
- f"{W}/corrupt.parquet", row_group_size=1000, use_dictionary=False)
-md = pq.ParquetFile(f"{W}/corrupt.parquet").metadata.row_group(0).column(1)
-off = md.data_page_offset
-raw = bytearray(open(f"{W}/corrupt.parquet", "rb").read())
-# smash the second column's page header + start of its data so decode fails,
-# leaving the first column and the footer intact.
-for i in range(off, off + 32):
- raw[i] = 0xFF
-open(f"{W}/corrupt.parquet", "wb").write(bytes(raw))
-print(f" corrupted column 'bad' at offset {off}", file=sys.stderr)
-PY
+# corrupt.parquet was written by the crafter above, its second column's page smashed.
psql_run "CREATE FOREIGN TABLE ftc (g int, bad int) SERVER pq OPTIONS (path '$W/corrupt.parquet');"
psql_run "CREATE FUNCTION pgc_try_proj(q text) RETURNS text LANGUAGE plpgsql AS \$\$
diff --git a/test/native_parquet_stack.sh b/test/native_parquet_stack.sh
index 1269b8d83..799f35f94 100755
--- a/test/native_parquet_stack.sh
+++ b/test/native_parquet_stack.sh
@@ -101,10 +101,31 @@ open(f"{W}/stack_walk.parquet", "wb").write(walk_chain(20000))
open(f"{W}/stack_walk_ok.parquet", "wb").write(walk_chain(2))
PY
+# Survival reads the server log, not a bare SELECT 1, which answers again once crash
+# recovery is over (jdatcmd's #1708 review: with an abort() in the Thrift guard and a 3 s
+# delay, both survival checks after the aborts passed). Each arm takes its own baseline
+# right before its trigger, and every reading first waits for the server, so a crash
+# fails only its own arm and its own survival check (the pattern of #1701 and #1705).
+wait_up() { local _i; for _i in $(seq 1 60); do [ "$(q 'SELECT 1' 2>/dev/null)" = 1 ] && return; sleep 0.5; done; }
+crashes() {
+ local _n
+ _n="$(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since BEFORE
+ local _up _now
+ wait_up
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"; return; fi
+ if [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1; return; fi
+ echo "no ([$_up], $(( _now - $1 )) backend crashes)"
+}
+
# sqlstate QUERY -> the SQLSTATE of the error it raises, or 'NOERR'. A DO block so
# the error is caught in-session and the connection is never actually torn down by
-# a controlled error (only a real crash would drop it, which the next check sees).
+# a controlled error (only a real crash would drop it, which the survival check sees).
sqlstate() {
+ wait_up
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -At -c "SET max_stack_depth='100kB';
DO \$\$ BEGIN PERFORM count(*) FROM pgcolumnar.parquet_schema('$1');
@@ -117,31 +138,37 @@ sqlstate() {
# 54001 is statement_too_complex, what check_stack_depth raises. Pinning it rather
# than accepting any error means an unrelated failure cannot pass this: before the
# guard, this input did not raise 54001, it SIGSEGV'd.
+B="$(crashes)"
check "nested-struct footer raises stack-depth, not SIGSEGV" \
"$(sqlstate "$W/stack_nested.parquet")" "54001"
-check "backend survived the nested-struct footer" "$(q 'SELECT 1;')" "1"
+check "backend survived the nested-struct footer" "$(survived "$B")" "1"
# --- vector 2: nested schema tree (the one a single guard misses) -------------
+B="$(crashes)"
check "schema-chain footer raises stack-depth, not SIGSEGV" \
"$(sqlstate "$W/stack_walk.parquet")" "54001"
-check "backend survived the schema-chain footer" "$(q 'SELECT 1;')" "1"
+check "backend survived the schema-chain footer" "$(survived "$B")" "1"
# --- the guard must not reject valid nesting ----------------------------------
# The same walk_schema path, one level deep, must still describe the file. If the
# guard were keyed on nesting rather than on stack exhaustion this would fail.
+B="$(crashes)"
+wait_up
check "a shallow valid schema chain still reads" \
"$(q "SELECT count(*) FROM pgcolumnar.parquet_schema('$W/stack_walk_ok.parquet');")" "1"
-check "backend survived the valid file" "$(q 'SELECT 1;')" "1"
+check "backend survived the valid file" "$(survived "$B")" "1"
# --- the same parser backs read_parquet, so the guard covers it too -----------
# read_parquet reaches the identical footer walk. It must reject the crash input
# the same way rather than becoming a way around the guard.
+B="$(crashes)"
+wait_up
check "read_parquet rejects the nested-struct footer the same way" \
"$(errs_state="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -At -c "SET max_stack_depth='100kB';
DO \$\$ BEGIN PERFORM count(*) FROM pgcolumnar.read_parquet('$W/stack_nested.parquet') AS t(c int);
EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'SQLSTATE=%', SQLSTATE; END \$\$;" 2>&1 \
| grep -m1 -oE 'SQLSTATE=[0-9A-Z]+' | sed 's/SQLSTATE=//')"; echo "$errs_state")" "54001"
-check "backend survived read_parquet on the crash input" "$(q 'SELECT 1;')" "1"
+check "backend survived read_parquet on the crash input" "$(survived "$B")" "1"
pgc_summary
diff --git a/test/native_parquet_streaming.sh b/test/native_parquet_streaming.sh
index 4acec1cae..b9f5af3bf 100755
--- a/test/native_parquet_streaming.sh
+++ b/test/native_parquet_streaming.sh
@@ -27,310 +27,106 @@ fi
W="$PGC_WORKDIR"
-# A real small file, and an oversized sparse one carrying that file's footer.
-if ! python3 - "$W" <<'PY'
-import os, sys
-import pyarrow as pa, pyarrow.parquet as pq
-
-W = sys.argv[1]
-src = os.path.join(W, "small.parquet")
-pq.write_table(pa.table({"id": pa.array([1, 2, 3], pa.int32()),
- "v": pa.array(["a", "b", "c"])}), src)
-
-data = open(src, "rb").read()
-metalen = int.from_bytes(data[-8:-4], "little")
-meta = data[-8 - metalen:-8]
-
-# comfortably over MaxAllocSize (1GB - 1), which is what palloc() rejected
-SIZE = 1600 * 1024 * 1024
-st = os.statvfs(W)
-if st.f_bavail * st.f_frsize < 64 * 1024 * 1024:
- sys.exit(3) # no room even for a sparse file's metadata
-
-big = os.path.join(W, "huge_sparse.parquet")
-with open(big, "wb") as f:
- f.write(b"PAR1")
- f.truncate(SIZE) # the hole; never read by the footer path
- f.seek(SIZE - 8 - metalen)
- f.write(meta)
- f.write(metalen.to_bytes(4, "little"))
- f.write(b"PAR1")
+# Every file is written by test/craft_parquet_streaming.py, shared with the twin; what
+# each crafted file breaks, and why it is built that way, is described beside it there.
+python3 "$(dirname "${BASH_SOURCE[0]}")/craft_parquet_streaming.py" "$W"
+craft_rc=$?
+case "$craft_rc" in
+ 0) ;;
+ 3 | 4)
+ # An environment that cannot build the oversized sparse file is a box that
+ # cannot gate this suite, not a major without the feature.
+ pgc_skip sparse_file "could not build the oversized sparse file" ;;
+ *)
+ # A crafted file that was never written would make every refusal below pass
+ # for the wrong reason ("could not open file" is still an error).
+ check "crafted files were built" "build failed (exit $craft_rc)" "built"
+ pgc_summary ;;
+esac
-if os.path.getsize(big) != SIZE:
- sys.exit(4)
-PY
-then
- # An environment that cannot build the file is a box that cannot gate this
- # suite, not a major without the feature. The pyarrow gate thirty lines above
- # was converted and this one was missed, so a low-space or non-sparse
- # filesystem quietly took the 1GB-palloc ceiling and the 1600MB-hole guard out
- # of the run while the major still reported PASS.
- pgc_skip sparse_file "could not build the oversized sparse file"
-fi
+# wait_up: a crash in one arm must fail that arm, not every reading made while the
+# server recovers. survived reads the server log's count of killed backends as well
+# as SELECT 1, since SELECT 1 answers again once crash recovery has finished: with a
+# crash in place of the dictionary refusal, the bare SELECT 1 check passed.
+wait_up() { local _i; for _i in $(seq 1 60); do [ "$(q 'SELECT 1' 2>/dev/null)" = 1 ] && return; sleep 0.5; done; }
+crashes() {
+ local _n
+ _n="$(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since BEFORE
+ local _up _now
+ wait_up
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"; return; fi
+ if [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1; return; fi
+ echo "no ([$_up], $(( _now - $1 )) backend crashes)"
+}
+qw() { wait_up; q "$1"; }
# errtext QUERY -> the raised ERROR line, or "NO ERROR"
errtext() {
local out
+ wait_up
out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" \
-U postgres -d "$PGC_DB" -At -c "$1" 2>&1 | grep -m1 '^ERROR:')"
[ -z "$out" ] && out="NO ERROR"
echo "$out"
}
-errs() {
+# decodes QUERY -> "OK" when QUERY raised the page loop's refusal ("could not decode
+# Parquet column"), else the ERROR line it did raise, or "NO ERROR". Any ERROR used to
+# count: a refusal from some other layer passed as the page loop's.
+decodes() {
local out
- out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" \
- -U postgres -d "$PGC_DB" -At -c "$1" 2>&1)"
+ out="$(errtext "$1")"
case "$out" in
- *ERROR*) echo OK ;;
- *) echo "NO ERROR" ;;
+ "ERROR: could not decode Parquet column "*) echo OK ;;
+ *) echo "$out" ;;
esac
}
+CRASHES_BEFORE="$(crashes)"
+
# The headline: the footer path never allocates the file, so a file over the old
# ceiling describes itself. This FAILS on the pre-streaming code with
# "invalid memory alloc request size".
check "parquet_schema reads a file over the 1GB palloc ceiling" \
- "$(q "SELECT count(*) FROM pgcolumnar.parquet_schema('$W/huge_sparse.parquet');")" "2"
+ "$(qw "SELECT count(*) FROM pgcolumnar.parquet_schema('$W/huge_sparse.parquet');")" "2"
check "parquet_schema reports the columns of the oversized file" \
- "$(q "SELECT string_agg(column_name, ',' ORDER BY column_name) FROM pgcolumnar.parquet_schema('$W/huge_sparse.parquet');")" \
+ "$(qw "SELECT string_agg(column_name, ',' ORDER BY column_name) FROM pgcolumnar.parquet_schema('$W/huge_sparse.parquet');")" \
"id,v"
# The same file's footer describes pages that are not there. A value read must
# report that, not crash the backend or return rows.
check "value read of the sparse file fails cleanly" \
- "$(errs "SELECT * FROM pgcolumnar.read_parquet('$W/huge_sparse.parquet') AS t(id int, v text)")" "OK"
-check "backend survived the failed read" "$(q 'SELECT 1;')" "1"
+ "$(decodes "SELECT * FROM pgcolumnar.read_parquet('$W/huge_sparse.parquet') AS t(id int, v text)")" "OK"
+check "backend survived the failed read" "$(survived "$CRASHES_BEFORE")" "1"
+CRASHES_MID="$(crashes)"
# The ordinary path is unchanged.
check "parquet_schema still reads a normal file" \
- "$(q "SELECT count(*) FROM pgcolumnar.parquet_schema('$W/small.parquet');")" "2"
+ "$(qw "SELECT count(*) FROM pgcolumnar.parquet_schema('$W/small.parquet');")" "2"
check "read_parquet still reads a normal file" \
- "$(q "SELECT count(*) FROM pgcolumnar.read_parquet('$W/small.parquet') AS t(id int, v text);")" "3"
+ "$(qw "SELECT count(*) FROM pgcolumnar.read_parquet('$W/small.parquet') AS t(id int, v text);")" "3"
# ---- crafted files: the guards the page loop added -------------------------
-#
-# Each of these keeps a valid footer (so the file binds and the read starts) and
-# breaks something the page loop must not trust. The footer is taken from a real
-# uncompressed file, so a zeroed page area really does parse as page headers
-# rather than failing in the codec first.
-
-if ! python3 - "$W" <<'PY'
-import os, sys
-import pyarrow as pa, pyarrow.parquet as pq
-
-W = sys.argv[1]
-plain = os.path.join(W, "plain.parquet")
-pq.write_table(pa.table({"id": pa.array(list(range(200)), pa.int32())}),
- plain, compression="none")
-def parts(path):
- raw = open(path, "rb").read()
- n = int.from_bytes(raw[-8:-4], "little")
- return raw[-8 - n:-8], n, raw[4:-8 - n] # footer, its length, page area
-
-meta, metalen, body = parts(plain)
-
-def build(name, body_bytes, m=None, ml=None):
- with open(os.path.join(W, name), "wb") as f:
- f.write(b"PAR1")
- f.write(body_bytes)
- f.write(m if m is not None else meta)
- f.write((ml if ml is not None else metalen).to_bytes(4, "little"))
- f.write(b"PAR1")
-
-# 1. a large zero-filled page area. A zero byte is a Thrift STOP, so each
-# "header" parses as a v1 data page with num_values 0 and compressed_size 0:
-# a page that consumes one byte and produces nothing. Without the num_values
-# guard the loop advances one byte at a time, so the work is proportional to
-# the size of the zero region rather than to the data.
-#
-# Two things make this file exercise that and not something else. The column
-# is REQUIRED, so the page carries no definition levels; with a nullable
-# column, decoding levels out of a zero-length page fails first and the file
-# is rejected before the loop can spin. And the zero region is large (sparse,
-# so it costs almost nothing on disk); a small one merely reaches end of file
-# and is caught by the closing short-chunk check, which is a different guard.
-req = os.path.join(W, "plain_req.parquet")
-pq.write_table(pa.table({"id": pa.array(list(range(200)), pa.int32())},
- schema=pa.schema([pa.field("id", pa.int32(),
- nullable=False)])),
- req, compression="none")
-rmeta, rmetalen, rbody = parts(req)
-
-ZSIZE = 1600 * 1024 * 1024
-with open(os.path.join(W, "zero_pages.parquet"), "wb") as f:
- f.write(b"PAR1")
- f.truncate(ZSIZE - 8 - rmetalen) # the zero region, sparse
- f.seek(ZSIZE - 8 - rmetalen)
- f.write(rmeta)
- f.write(rmetalen.to_bytes(4, "little"))
- f.write(b"PAR1")
-
-# 2. a page whose header declares a compressed_size far past end of file.
-#
-# Truncating the body cannot produce this: the footer we append always lands
-# after the cut, so the bytes are still "there" as far as the page loop can
-# tell, and the decode reads footer bytes as page data instead of failing.
-# The size has to be a lie in the page header itself, so the header is built
-# by hand here (Thrift compact, PageHeader with a DataPageHeader) and written
-# over the start of the chunk the footer points at.
-def zz(v):
- u = (v << 1) ^ (v >> 63) if v < 0 else (v << 1)
- out = bytearray()
- while True:
- b = u & 0x7F
- u >>= 7
- out.append(b | (0x80 if u else 0))
- if not u:
- return bytes(out)
-
-def fh(delta, t):
- return bytes([(delta << 4) | t])
-
-I32, STRUCT, STOP = 5, 12, b"\x00"
-BIG = 500 * 1024 * 1024 # past end of file, but under MaxAllocSize
-
-synth = (fh(1, I32) + zz(0) # type = DATA_PAGE
- + fh(1, I32) + zz(64) # uncompressed_page_size
- + fh(1, I32) + zz(BIG) # compressed_page_size: the lie
- + fh(2, STRUCT) # field 5: DataPageHeader
- + fh(1, I32) + zz(200) # num_values
- + fh(1, I32) + zz(0) # encoding PLAIN
- + fh(1, I32) + zz(3) # definition_level_encoding RLE
- + fh(1, I32) + zz(3) # repetition_level_encoding RLE
- + STOP + STOP)
-build("bad_size.parquet", synth + body[len(synth):])
-
-# 3. the page area is random noise, so the page header does not parse at all
-# and the window has already reached end of file.
-build("noise_pages.parquet", bytes((i * 37 + 11) & 0xFF for i in range(len(body))))
-
-# 4. the footer points a column chunk past end of file.
-#
-# This needs surgery on the footer, not the body: any truncation still leaves
-# the appended footer after the cut, so the offset stays inside the file. A
-# Thrift compact struct has no length prefix, so an offset varint can be
-# replaced by a longer one in place, as long as the 4-byte footer length at
-# the end of the file is updated to match.
-#
-# Two facts decide which offset to patch. The page loop starts at a chunk's
-# first page and then walks pages sequentially, so only the chunk's STARTING
-# offset is ever read from the footer; patching a later one would change
-# nothing. And the first column chunk always starts at offset 4, whose varint
-# is a single byte that occurs all over the footer. So the file has two
-# columns and the SECOND column's start offset is patched: it is large enough
-# to encode uniquely. Uniqueness is asserted rather than assumed.
-def enc_i64(v):
- u = (v << 1) ^ (v >> 63)
- out = bytearray()
- while True:
- b = u & 0x7F
- u >>= 7
- out.append(b | (0x80 if u else 0))
- if not u:
- return bytes(out)
-
-two = os.path.join(W, "two.parquet")
-pq.write_table(pa.table({"id": pa.array(list(range(200)), pa.int32()),
- "v": pa.array(["r%04d" % i for i in range(200)])}),
- two, compression="none", use_dictionary=False)
-tmeta, tmetalen, tbody = parts(two)
-tcol = pq.ParquetFile(two).metadata.row_group(0).column(1)
-tstart = tcol.dictionary_page_offset or tcol.data_page_offset
-
-# The offset is patched NEGATIVE, not merely large. A start offset past end
-# of file is already handled by the page loop's own "pos < len" condition,
-# which exits and lets the closing short-chunk check reject the file, so a
-# huge offset would prove nothing about this guard. A negative offset passes
-# that condition and reaches the header read, which is the case the guard
-# exists for (and which would otherwise seek backwards past the file start).
-NEG = -(1 << 20)
-pat = enc_i64(tstart)
-if tmeta.count(pat) != 1:
- sys.exit(5) # ambiguous: refuse to guess
-patched = tmeta.replace(pat, enc_i64(NEG))
-build("bad_offset.parquet", tbody, patched, len(patched))
-
-# 5. two dictionary pages in one chunk. The format allows at most one, and that
-# rule is what keeps a crafted run of them from walking the file now that an
-# empty dictionary page is legitimate (an all-null column has one). Built by
-# repeating the real dictionary page, whose length is the gap between the
-# chunk start and the first data page. The loop walks pages sequentially, so
-# the footer's now-stale data_page_offset is not consulted.
-dmd = pq.ParquetFile(plain).metadata.row_group(0).column(0)
-if not dmd.dictionary_page_offset or dmd.data_page_offset <= dmd.dictionary_page_offset:
- sys.exit(7) # no dictionary page to duplicate
-dlen = dmd.data_page_offset - dmd.dictionary_page_offset
-doff = dmd.dictionary_page_offset - 4 # into body, which starts at 4
-build("two_dicts.parquet",
- body[:doff] + body[doff:doff + dlen] * 2 + body[doff + dlen:])
-
-# 6. a DATA_PAGE_V2 whose declared level lengths exceed the page.
-#
-# Honest scope: this check asserts the file is rejected cleanly, and it does
-# NOT isolate the v2 level guard. Verified, not assumed: with the guard removed
-# the same file is still rejected with the same message, because the level
-# decode fails on the garbage it reads and, on the value path,
-# compressed_size - levLen goes negative into a size_t so valbuf + vallen wraps
-# and every bounds check fails closed.
-#
-# An AddressSanitizer build does not separate them either: palloc sub-allocates
-# from a larger block, so a read past a 32-byte page stays inside the context's
-# own allocation and ASAN never sees it. A PostgreSQL built with USE_VALGRIND
-# marks palloc chunks, and would.
-#
-# The guard stays because reading level bytes past the page and converting a
-# negative length to size_t are both wrong regardless of how they happen to
-# end today, and because the page buffer is now a tight allocation. But it is
-# recorded here as argued from the code, not proven by this check.
-V2 = 3
-DLEN = 64 # > compressed_size below
-BOOL_FALSE = 2
-synth_v2 = (fh(1, I32) + zz(V2) # type = DATA_PAGE_V2
- + fh(1, I32) + zz(64) # uncompressed_page_size
- + fh(1, I32) + zz(32) # compressed_page_size
- + fh(5, STRUCT) # field 8: DataPageHeaderV2
- + fh(1, I32) + zz(200) # num_values
- + fh(1, I32) + zz(0) # num_nulls
- + fh(1, I32) + zz(200) # num_rows
- + fh(1, I32) + zz(0) # encoding PLAIN
- + fh(1, I32) + zz(DLEN) # definition_levels_byte_length: the lie
- + fh(1, I32) + zz(0) # repetition_levels_byte_length
- + fh(1, BOOL_FALSE) # is_compressed = false, so levels and
- # values are read straight from the page
- + STOP + STOP)
-build("v2_levels.parquet", synth_v2 + body[len(synth_v2):])
-
-for name in ("zero_pages.parquet", "bad_size.parquet", "noise_pages.parquet",
- "bad_offset.parquet", "two_dicts.parquet", "v2_levels.parquet"):
- if not os.path.exists(os.path.join(W, name)):
- sys.exit(6)
-
-for name in ("zero_pages.parquet", "bad_size.parquet", "noise_pages.parquet",
- "bad_offset.parquet"):
- if not os.path.exists(os.path.join(W, name)):
- sys.exit(6)
-PY
-then
- # A crafted file that was never written would make every check below pass for
- # the wrong reason: the read fails with "could not open file", which is still
- # an error. Recorded through check() so it counts as a real failure, not just
- # a message, and the suite stops here rather than reporting vacuous passes.
- check "crafted files were built" "build failed" "built"
- pgc_summary
-fi
+# Each keeps a valid footer, so the file binds and the read starts, and breaks one
+# thing the page loop must not trust (see test/craft_parquet_streaming.py).
# The timeout is the assertion for the first one: without the guard the page loop
# runs once per byte of a 1600MB zero region, so a regression hangs rather than
# errors, and the timeout is what turns that into a failure.
+wait_up
zero_out="$(timeout 60 env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" \
-U postgres -d "$PGC_DB" -At \
-c "SELECT count(*) FROM pgcolumnar.read_parquet('$W/zero_pages.parquet') AS t(id int)" 2>&1)"
zero_rc=$?
case "$zero_rc:$zero_out" in
124:*) zero_verdict="TIMED OUT (page loop made no progress)" ;;
- *ERROR*) zero_verdict="CLEAN REJECT" ;;
+ *"ERROR: could not decode Parquet column "*) zero_verdict="CLEAN REJECT" ;;
+ *ERROR*) zero_verdict="$(grep -m1 '^ERROR:' <<<"$zero_out")" ;;
*) zero_verdict="NO ERROR: $zero_out" ;;
esac
check "zero-filled page area is rejected, not walked byte by byte" "$zero_verdict" "CLEAN REJECT"
@@ -349,7 +145,7 @@ check "negative page offset is rejected by the offset check" \
"$(errtext "SELECT * FROM pgcolumnar.read_parquet('$W/bad_offset.parquet') AS t(id int, v text)")" \
"ERROR: could not decode Parquet column 1 in row group 0"
check "unparseable page header is rejected" \
- "$(errs "SELECT * FROM pgcolumnar.read_parquet('$W/noise_pages.parquet') AS t(id int)")" "OK"
+ "$(decodes "SELECT * FROM pgcolumnar.read_parquet('$W/noise_pages.parquet') AS t(id int)")" "OK"
check "a second dictionary page in a chunk is rejected" \
"$(errtext "SELECT * FROM pgcolumnar.read_parquet('$W/two_dicts.parquet') AS t(id int)")" \
"ERROR: could not decode Parquet column 0 in row group 0"
@@ -361,22 +157,14 @@ check "v2 level lengths past the page are rejected" \
# so a progress guard that rejects any page with no values rejects a file
# pyarrow produces routinely. Nothing else in the matrix covers an all-null
# Parquet read, which is why that regression was invisible.
-python3 - "$W" <<'PYNULL'
-import os, sys
-import pyarrow as pa, pyarrow.parquet as pq
-W = sys.argv[1]
-pq.write_table(pa.table({"c": pa.array([None] * 100, pa.int32())}),
- os.path.join(W, "allnull.parquet"),
- use_dictionary=True, compression="none")
-PYNULL
check "all-null column (empty dictionary page) still reads" \
- "$(q "SELECT count(*), count(c) FROM pgcolumnar.read_parquet('$W/allnull.parquet') AS t(c int);" | tr '|' ' ')" \
+ "$(qw "SELECT count(*), count(c) FROM pgcolumnar.read_parquet('$W/allnull.parquet') AS t(c int);" | tr '|' ' ')" \
"100 0"
-check "backend survived the crafted page areas" "$(q 'SELECT 1;')" "1"
+check "backend survived the crafted page areas" "$(survived "$CRASHES_MID")" "1"
# the legitimate file the crafted ones were built from must still read
check "the uncompressed source file still reads" \
- "$(q "SELECT count(*), sum(id) FROM pgcolumnar.read_parquet('$W/plain.parquet') AS t(id int);" | tr '|' ' ')" \
+ "$(qw "SELECT count(*), sum(id) FROM pgcolumnar.read_parquet('$W/plain.parquet') AS t(id int);" | tr '|' ' ')" \
"200 19900"
# ---- #620: the FDW streams row groups; a LIMIT decodes fewer ----------------
@@ -388,14 +176,6 @@ check "the uncompressed source file still reads" \
# model this counter did not exist and every group was decoded up front, so this
# is the streaming proof. A four-row-group file (1000 rows each).
STREAMP="$W/stream_groups.parquet"
-python3 - "$STREAMP" <<'PY'
-import sys, pyarrow as pa, pyarrow.parquet as pq
-n = 4000
-tbl = pa.table({"id": pa.array(range(n), pa.int32()),
- "v": pa.array([i * 2 for i in range(n)], pa.int64())})
-pq.write_table(tbl, sys.argv[1], row_group_size=1000)
-assert pq.ParquetFile(sys.argv[1]).num_row_groups == 4
-PY
psql_run "CREATE SERVER pqs FOREIGN DATA WRAPPER pgcolumnar_parquet;"
psql_run "CREATE FOREIGN TABLE fts (id int4, v int8) SERVER pqs OPTIONS (path '$STREAMP');"
@@ -405,7 +185,7 @@ decoded_for() { # decoded_for SQL -> the Row Groups Decoded count for that plan
}
check "streaming: full scan content is correct" \
- "$(q 'SELECT count(*), sum(id), sum(v) FROM fts;' | tr '|' ' ')" \
+ "$(qw 'SELECT count(*), sum(id), sum(v) FROM fts;' | tr '|' ' ')" \
"4000 7998000 15996000"
check "streaming: a full scan decodes all four row groups" \
"$(decoded_for 'SELECT * FROM fts')" "4"
diff --git a/test/native_parquet_units.sh b/test/native_parquet_units.sh
index b1b91b88e..136c60cc2 100755
--- a/test/native_parquet_units.sh
+++ b/test/native_parquet_units.sh
@@ -49,16 +49,12 @@ pq.write_table(pa.table({"t": pa.array([9223372036854776], pa.int64()).cast(pa.t
# Nanos truncation. pq_scale_to_usecs divides by 1000 with C semantics (toward
# zero), which the code comment reasons about but the round-number cases above do
# not exercise: they are exact multiples of 1000 and non-negative. Write a value
-# with sub-microsecond digits, and a pre-epoch (negative) one, as nanos; write the
-# toward-zero-truncated microseconds directly as the reference. Reading the two as
-# timestamp must agree, which pins the rounding direction.
-def trunc_us(ns): # toward zero, unlike Python's floor //
- return -(-ns // 1000) if ns < 0 else ns // 1000
+# with sub-microsecond digits, and a pre-epoch (negative) one, as nanos. The checks
+# below compare each with its toward-zero microseconds as a LITERAL: a reference
+# file read back through the same reader agreed with it on two failed reads.
for tag, ns in [("frac", 1784851200_123_456_789), ("neg", -1784851200_123_456_789)]:
pq.write_table(pa.table({"t": pa.array([ns], pa.int64()).cast(pa.timestamp('ns'))}),
f"{W}/tsns_{tag}.parquet", version="2.6")
- pq.write_table(pa.table({"t": pa.array([trunc_us(ns)], pa.int64()).cast(pa.timestamp('us'))}),
- f"{W}/tsus_{tag}.parquet", version="1.0")
# Row-group skipping over a unit-scaled column: two groups of millis timestamps
# with disjoint ranges. The FDW decodes each group's min/max through the same
@@ -84,10 +80,9 @@ psql_run "CREATE FOREIGN TABLE ftime_ms (t time) SERVER pq OPTIONS (path '$W/tim
# ---- the same instant, whatever the unit -----------------------------------
# Before the unit was honoured, millis read 1000x small (1970-01-21 15:47:31.2)
# and nanos declared as timestamp read 1000x large, both silently.
-for u in ms us ns; do
- check "ts_$u decodes to the right instant" \
- "$(q "SELECT t::text FROM fts_$u;")" "2026-07-24 00:00:00"
-done
+check "ts_ms decodes to the right instant" "$(q "SELECT t::text FROM fts_ms;")" "2026-07-24 00:00:00"
+check "ts_us decodes to the right instant" "$(q "SELECT t::text FROM fts_us;")" "2026-07-24 00:00:00"
+check "ts_ns decodes to the right instant" "$(q "SELECT t::text FROM fts_ns;")" "2026-07-24 00:00:00"
check "time micros decodes to the right time" \
"$(q 'SELECT t::text FROM ftime;')" "01:02:03.000004"
@@ -150,15 +145,17 @@ check "nanos == micros" \
"$(pgc_set_hash 'SELECT t FROM fts_ns')" "$(pgc_set_hash 'SELECT t FROM fts_us')"
# ---- nanos truncation direction (toward zero) ------------------------------
-# The nanos value carries sub-microsecond digits the micros reference cannot, so
-# equality holds only if the reader truncates toward zero exactly as the reference
-# was computed. The negative case is the one that distinguishes toward-zero from
-# floor.
-for tag in frac neg; do
- check "nanos sub-us truncates toward zero [$tag]" \
- "$(q "SELECT t::text FROM pgcolumnar.read_parquet('$W/tsns_$tag.parquet') AS t(t timestamp);")" \
- "$(q "SELECT t::text FROM pgcolumnar.read_parquet('$W/tsus_$tag.parquet') AS t(t timestamp);")"
-done
+# The nanos value carries sub-microsecond digits, so the reading equals its
+# toward-zero microseconds only if the reader truncates that way. The negative case
+# is the one that distinguishes toward-zero from floor. The want is a literal, and
+# check_text refuses an empty reading: comparing with a second file read through the
+# same reader passed both checks with the server stopped (two empty reads agree).
+check_text "nanos sub-us truncates toward zero [frac]" \
+ "$(q "SELECT t::text FROM pgcolumnar.read_parquet('$W/tsns_frac.parquet') AS t(t timestamp);")" \
+ "2026-07-24 00:00:00.123456"
+check_text "nanos sub-us truncates toward zero [neg]" \
+ "$(q "SELECT t::text FROM pgcolumnar.read_parquet('$W/tsns_neg.parquet') AS t(t timestamp);")" \
+ "1913-06-10 23:59:59.876544"
# ---- pushdown over a unit-scaled column ------------------------------------
# The predicate falls entirely in the second group's range, so the first group's
@@ -173,9 +170,17 @@ skipped_ms() {
# 2025-06-01 is inside group 1 only.
check "millis-scaled stats skip the non-matching group" \
"$(skipped_ms "t >= timestamp '2025-06-01'")" "1"
-# correctness: the skip must not drop a matching row (oracle = a heap mirror)
-psql_run "CREATE TABLE hmsg (t timestamp);"
-psql_run "INSERT INTO hmsg SELECT t FROM pgcolumnar.read_parquet('$W/ms_groups.parquet') AS t(t timestamp);"
+# correctness: the skip must not drop a matching row. The oracle is the GENERATOR,
+# the same hourly series the fixture wrote, not the file read back through
+# read_parquet: that mirror shared the reader under test, so a decode that shifted
+# every row after a page's first to the next dictionary entry agreed with itself and
+# passed (dictshift, 19/19 on main).
+psql_run "CREATE TABLE hmsg AS
+ SELECT timestamp '2024-01-01' + g * interval '1 hour' AS t FROM generate_series(0, 999) g
+ UNION ALL
+ SELECT timestamp '2025-07-01' + g * interval '1 hour' FROM generate_series(0, 999) g;"
+check "the millis file reads back exactly as written" \
+ "$(pgc_set_hash "SELECT t FROM fmsg")" "$(pgc_set_hash "SELECT t FROM hmsg")"
check "millis pushdown result == oracle" \
"$(pgc_set_hash "SELECT t FROM fmsg WHERE t >= timestamp '2025-06-01'")" \
"$(pgc_set_hash "SELECT t FROM hmsg WHERE t >= timestamp '2025-06-01'")"
diff --git a/test/native_read_parquet.sh b/test/native_read_parquet.sh
index b56c0998c..df7623a88 100755
--- a/test/native_read_parquet.sh
+++ b/test/native_read_parquet.sh
@@ -4,9 +4,22 @@
# a server-side Parquet file's rows in place, with a caller-supplied column
# definition list. This suite is the round-trip differential: it exports a columnar
# table (scalars, then nested int[]/text[]/composite), reads the file back with
-# read_parquet, and asserts the rows are identical to the source. The exporter and
-# the scan core share the decode, so a faithful reader reproduces the source rows.
-# It also checks the argument-validation errors (no column list, wrong count/type).
+# read_parquet, and asserts the rows are the rows that were generated. It also checks
+# the argument-validation errors (no column list, wrong count/type, not Parquet) and
+# the function's own pg_read_server_files gate.
+#
+# THE ORACLE IS THE GENERATOR, NOT THE TABLE (#1341). The source table s is read
+# through the columnar scan twice: once by export_parquet, to write the file, and
+# once by the old comparison, as "the source". A scan that decoded one value wrong
+# (int4 777 read as 778) wrote 778 into the file AND read 778 as the source, so the
+# round trip agreed with itself: measured, main passed 15/15 under that mutant. Each
+# comparison now hashes the generator's own rows, built in SQL, never through s.
+#
+# A REFUSAL IS ITS OWN GUARD'S (#1341). Each refusal used to count any psql failure,
+# a crash included. Two of them share SQLSTATE 42804 (the leaf count and the physical
+# type), so each now requires its SQLSTATE and its own message, waits for the server
+# first, and the refusals end with a survival check that reads the server log's count
+# of killed backends.
#
# Usage: test/native_read_parquet.sh [PG_CONFIG]
# Written fresh for pgColumnar.
@@ -15,14 +28,43 @@ set -uo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"
-expect_error() { # expect_error LABEL SQL
- local label="$1" sql="$2"
- if psql_run "$sql" >/dev/null 2>&1; then
- check "$label (expected error)" "succeeded" "error"
- else
- check "$label" "error" "error"
- fi
+# Backends killed by a signal, from the server log: a number, or nothing when the log
+# can't be read, so that a blind count never reads as "no crashes".
+rp_crashes() {
+ local _n
+ _n="$(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+# wait (bounded) until the server answers again, so a crash in one arm fails only
+# that arm and the survival check, not every later reading
+rp_wait_up() {
+ local _i
+ for _i in $(seq 1 40); do
+ [ "$(q "SELECT 1;" 2>/dev/null)" = 1 ] && return 0
+ sleep 0.5
+ done
+ return 1
}
+# refusal_of SQL PHRASE LABEL -> "" when SQL raised with PHRASE in its
+# message, the bare SQLSTATE when the message lacks it, or what happened instead.
+refusal_of() {
+ local out state
+ rp_wait_up
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA 2>&1 < record-returning function has no shape
-expect_error "reject missing column definition list" \
- "SELECT * FROM pgcolumnar.read_parquet('$SCALAR')"
+# no column definition list: the record-returning function has no shape. The parser
+# refuses this before read_parquet runs (its own guard is unreachable from SQL), so
+# this pins PostgreSQL's 42601, not a pgColumnar guard.
+check "premise: reject missing column definition list" \
+ "$(refusal_of "SELECT * FROM pgcolumnar.read_parquet('$SCALAR')" \
+ "a column definition list is required" " (no column list)")" \
+ "42601 (no column list)"
-# too few columns declared (file has 11 leaves)
-expect_error "reject fewer columns than file" \
- "SELECT * FROM pgcolumnar.read_parquet('$SCALAR') AS t(c_i2 int2)"
+# too few columns declared (the file has 11 leaves)
+check "reject fewer columns than file" \
+ "$(refusal_of "SELECT * FROM pgcolumnar.read_parquet('$SCALAR') AS t(c_i2 int2)" \
+ "Parquet file has 11 leaf columns, target table expands to 1" " by the leaf-count guard")" \
+ "42804 by the leaf-count guard"
# incompatible declared type (c_i4 is INT32, not text)
-expect_error "reject incompatible declared type" \
- "SELECT * FROM pgcolumnar.read_parquet('$SCALAR')
+check "reject incompatible declared type" \
+ "$(refusal_of "SELECT * FROM pgcolumnar.read_parquet('$SCALAR')
AS t(c_i2 int2, c_i4 text, c_i8 int8, c_f4 float4, c_f8 float8, c_bool bool,
- c_text text, c_bytea bytea, c_date date, c_time time, c_ts timestamp)"
+ c_text text, c_bytea bytea, c_date date, c_time time, c_ts timestamp)" \
+ "physical type is not compatible with target column \"c_i4\"" " by the physical-type guard")" \
+ "42804 by the physical-type guard"
# not a Parquet file
printf 'not parquet' > "$PGC_WORKDIR/bad.dat"
chmod 644 "$PGC_WORKDIR/bad.dat"
-expect_error "reject non-parquet file" \
- "SELECT * FROM pgcolumnar.read_parquet('$PGC_WORKDIR/bad.dat') AS t(a int)"
+check "reject non-parquet file" \
+ "$(refusal_of "SELECT * FROM pgcolumnar.read_parquet('$PGC_WORKDIR/bad.dat') AS t(a int)" \
+ "is not a Parquet file" " by the magic check")" \
+ "XX001 by the magic check"
+
+# ---- the function's own file gate ---------------------------------------------
+# The role holds USAGE on the schema, so what refuses it is read_parquet's own
+# pg_read_server_files check, not the schema (which would also say 42501).
+psql_run "DROP ROLE IF EXISTS t_rp_none;"
+psql_run "DROP ROLE IF EXISTS t_rp_files;"
+psql_run "CREATE ROLE t_rp_none NOSUPERUSER LOGIN;"
+psql_run "CREATE ROLE t_rp_files NOSUPERUSER LOGIN;"
+psql_run "GRANT USAGE ON SCHEMA pgcolumnar TO t_rp_none, t_rp_files;"
+psql_run "GRANT pg_read_server_files TO t_rp_files;"
+check "a role without pg_read_server_files is refused by read_parquet's own gate" \
+ "$(refusal_of "SET ROLE t_rp_none; SELECT count(*) FROM pgcolumnar.read_parquet('$SCALAR') AS t($scalar_cols)" \
+ "must be superuser or a member of the pg_read_server_files role to read a server file" \
+ " by the file gate")" \
+ "42501 by the file gate"
+check_num "and a member of pg_read_server_files reads every row" \
+ "$(rp_q "SET ROLE t_rp_files; SELECT count(*) FROM pgcolumnar.read_parquet('$SCALAR') AS t($scalar_cols);" | tail -1)" "5001"
+
+# Answering a query is not enough: a fresh connection answers again once crash recovery
+# has finished, so the check also requires that no backend has died since the start.
+rp_wait_up
+rp_up="$(rp_q "SELECT 'alive';")"
+rp_now="$(rp_crashes)"
+check "backend still up after the refusals" \
+ "$(if ! [[ "$RP_CRASHES_BEFORE" =~ ^[0-9]+$ && "$rp_now" =~ ^[0-9]+$ ]]; then
+ echo "no (crash count unreadable)"
+ elif [ "$rp_up" = alive ] && [ "$rp_now" = "$RP_CRASHES_BEFORE" ]; then
+ echo alive
+ else
+ echo "no ([$rp_up], $(( rp_now - RP_CRASHES_BEFORE )) backend crashes)"
+ fi)" "alive"
# ---- import parity: read_parquet == import_parquet of the same file ---------
psql_run "CREATE TABLE imp ($scalar_cols) USING pgcolumnar;"
-check "import parity rows" "$(q "SELECT pgcolumnar.import_parquet('imp', '$SCALAR');")" "5001"
+check "import parity rows" "$(rp_q "SELECT pgcolumnar.import_parquet('imp', '$SCALAR');")" "5001"
check "read_parquet == import_parquet" \
- "$(rp_scalar)" "$(pgc_set_hash "SELECT * FROM imp")"
+ "$(rp_scalar)" "$(rp_hash "SELECT * FROM imp")"
pgc_summary
diff --git a/test/native_reclaim_reconcile.sh b/test/native_reclaim_reconcile.sh
index b94a5f4b3..a21216ce2 100755
--- a/test/native_reclaim_reconcile.sh
+++ b/test/native_reclaim_reconcile.sh
@@ -63,13 +63,28 @@ check "stale overlapping free row injected" "$(stalerows)" "1"
psql_run "SELECT pgcolumnar.compact_rewrite('n', 0.1);"
check "compact_rewrite reconciled the stale row" "$(stalerows)" "0"
check "data intact after reconcile + rewrite" "$(hash_n)" "$(hash_h)"
-check "row count intact" "$(q 'SELECT count(*) FROM n;')" "$(q 'SELECT count(*) FROM h;')"
+# check_num: two failed reads are two empty strings, which plain check would call equal.
+check_num "row count intact" "$(q 'SELECT count(*) FROM n;')" "$(q 'SELECT count(*) FROM h;')"
# a PLAIN compact must also self-heal it: it asserts the no-overlap invariant but
# does not reuse, so it must reconcile too (fails without that call).
+#
+# And it must drop ONLY the overlapping row. The checks above count the stale row
+# alone, so a reconcile that dropped EVERY free row (leaking all reusable space)
+# passed all of them: measured, 11/11. Plain compact does not reuse, so the
+# legitimate free ranges the earlier rewrite left must come through it unchanged.
+legit() { q "SELECT count(*) || '|' || coalesce(sum(byte_length), 0) FROM pgcolumnar.free_space
+ WHERE storage_id = $sid AND freed_xid <> 1;"; }
inject
+legit_before="$(legit)"
+# Not a premise: the ranges exist only because compact_rewrite recorded them, and a
+# record_free_space that drops its insert (one line) leaks them all and reddens this.
+check "compact_rewrite recorded the free ranges it left" \
+ "$(case "$legit_before" in ''|0\|*) echo "no ($legit_before)" ;; *) echo yes ;; esac)" "yes"
psql_run "SELECT pgcolumnar.compact('n');"
check "plain compact reconciled the stale row" "$(stalerows)" "0"
+check_text "plain compact kept the legitimate free ranges, which overlap no live group" \
+ "$(legit)" "$legit_before"
check "data intact after compact reconcile" "$(hash_n)" "$(hash_h)"
# a truncate must likewise self-heal it (its start purge only removes rows at or
@@ -80,11 +95,14 @@ check "truncate reconciled the stale row" "$(stalerows)" "0"
check "data intact after truncate reconcile" "$(hash_n)" "$(hash_h)"
# reuse keeps working correctly afterward.
-for r in 1 2; do
- psql_run "DELETE FROM h WHERE id % 5 = $r;"
- psql_run "DELETE FROM n WHERE id % 5 = $r;"
- psql_run "SELECT pgcolumnar.compact_rewrite('n', 0.0);"
- check "parity after reuse cycle $r" "$(hash_n)" "$(hash_h)"
-done
+# Unrolled, so each check name is a literal the parity grader can read.
+psql_run "DELETE FROM h WHERE id % 5 = 1;"
+psql_run "DELETE FROM n WHERE id % 5 = 1;"
+psql_run "SELECT pgcolumnar.compact_rewrite('n', 0.0);"
+check "parity after reuse cycle 1" "$(hash_n)" "$(hash_h)"
+psql_run "DELETE FROM h WHERE id % 5 = 2;"
+psql_run "DELETE FROM n WHERE id % 5 = 2;"
+psql_run "SELECT pgcolumnar.compact_rewrite('n', 0.0);"
+check "parity after reuse cycle 2" "$(hash_n)" "$(hash_h)"
pgc_summary
diff --git a/test/native_rewrite.sh b/test/native_rewrite.sh
index 9fec35bab..376e2098b 100755
--- a/test/native_rewrite.sh
+++ b/test/native_rewrite.sh
@@ -34,7 +34,7 @@ deletedrows() { q "SELECT COALESCE(sum(deletedrows), 0) FROM pgcolumnar.stats('n
psql_run "DELETE FROM h WHERE id % 3 = 0;"
psql_run "DELETE FROM n WHERE id % 3 = 0;"
-check "row count after delete" "$(q 'SELECT count(*) FROM n;')" "$(q 'SELECT count(*) FROM h;')"
+check_num "row count after delete" "$(q 'SELECT count(*) FROM n;')" "$(q 'SELECT count(*) FROM h;')"
check "there are deleted rows to reclaim" "$([ "$(deletedrows)" -gt 0 ] && echo yes || echo no)" "yes"
check "parity after delete" \
"$(pgc_set_hash 'SELECT id, v, payload FROM n ORDER BY id')" \
@@ -50,31 +50,47 @@ check "dead-row space reclaimed" "$(deletedrows)" "0"
check "parity after rewrite" \
"$(pgc_set_hash 'SELECT id, v, payload FROM n ORDER BY id')" \
"$(pgc_set_hash 'SELECT id, v, payload FROM h ORDER BY id')"
-check "row count after rewrite" \
+check_num "row count after rewrite" \
"$(q 'SELECT count(*) FROM n;')" \
"$(q 'SELECT count(*) FROM h;')"
+# The threshold, measured from both sides. A rewrite with nothing deleted must leave
+# every group alone, and a group deleted below min_deleted_fraction is not a
+# candidate. With every undeleted group rewritten, or the threshold ignored, all 17
+# checks above and below passed on main.
+check_num "a second rewrite with nothing deleted rewrites no group" \
+ "$(q "SELECT pgcolumnar.compact_rewrite('n', 0.2);")" "0"
+psql_run "DELETE FROM h WHERE id = 2;"
+psql_run "DELETE FROM n WHERE id = 2;"
+check_num "a group deleted below the threshold is not rewritten" \
+ "$(q "SELECT pgcolumnar.compact_rewrite('n', 0.2);")" "0"
+
# Index-scan correctness: force an index scan and confirm each live row is
# returned exactly once (no duplicate from lingering old-number index entries).
-idxcount() {
+idxcount() { # [UPPER] -> live rows with id in 1..UPPER read through the index, or nothing
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -At 2>/dev/null -c \
"SET enable_seqscan=off; SET pgcolumnar.enable_custom_scan=off;
- SELECT count(*) FROM n WHERE id BETWEEN 1 AND 8192;" | tail -1
+ SELECT count(*) FROM n WHERE id BETWEEN 1 AND ${1:-8192};" | tail -1
}
-check "index scan returns each live row once" "$(idxcount)" "$(q 'SELECT count(*) FROM h;')"
+check_num "index scan returns each live row once" "$(idxcount)" "$(q 'SELECT count(*) FROM h;')"
# Point lookups via the unique index.
check "live id present via index" "$(q 'SELECT count(*) FROM n WHERE id = 4097;')" "1"
check "deleted id absent via index" "$(q 'SELECT count(*) FROM n WHERE id = 4098;')" "0"
# Uniqueness still enforced after the rewrite (no stale live duplicate).
+# dupfails -> "yes" only when the insert is refused by n_id_uq itself (23505 and its
+# message). Any other failure is not enforcement: with the server stopped, the old
+# "psql failed, so yes" passed.
dupfails() {
- if env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -At -v ON_ERROR_STOP=1 -c "INSERT INTO n VALUES (4097, 0, 'x');" >/dev/null 2>&1; then
- echo no
- else
- echo yes
- fi
+ local out
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -v ON_ERROR_STOP=1 -v VERBOSITY=verbose \
+ -c "INSERT INTO n VALUES (4097, 0, 'x');" 2>&1)" && { echo "no (the duplicate was inserted)"; return; }
+ case "$out" in
+ *'23505'*'duplicate key value violates unique constraint "n_id_uq"'*) echo yes ;;
+ *) echo "no ($(head -1 <<<"$out"))" ;;
+ esac
}
check "unique constraint still enforced" "$(dupfails)" "yes"
@@ -138,7 +154,22 @@ check "vacuum rewrites into a fresh storage id" \
"$([ "$sid_after" != "$sid_before" ] && echo yes || echo no)" "yes"
# And the rows survive all of it, which is what the invariants are for.
-check "every live row still reads back after rewrite and vacuum" \
+check_num "every live row still reads back after rewrite and vacuum" \
"$(q 'SELECT count(*) FROM n;')" "$(q 'SELECT count(*) FROM h;')"
+# vacuum assigned every row a fresh number, so the indexes had to be rebuilt to point
+# at them. A stale entry still fetches A row, just the wrong one, and over the whole
+# table the stale entries land on every live row once: with the rebuild skipped, a
+# count and a hash of everything read through the index both still matched, and every
+# check above passed on main. A narrow range is what tells: its entries fetch rows
+# from elsewhere in the table (id 4097 read back as 6146).
+idxids() { # LO HI -> the ids an index range read of n returns, or nothing
+ env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At 2>/dev/null -c \
+ "SET enable_seqscan=off; SET enable_bitmapscan=off; SET pgcolumnar.enable_custom_scan=off;
+ SELECT string_agg(id::text, ',' ORDER BY id) FROM n WHERE id BETWEEN $1 AND $2;" | tail -1
+}
+check_text "an index range read after vacuum returns the rows asked for" \
+ "$(idxids 4090 4100)" \
+ "$(q "SELECT string_agg(id::text, ',' ORDER BY id) FROM h WHERE id BETWEEN 4090 AND 4100;")"
pgc_summary
diff --git a/test/native_rewrite_conc.sh b/test/native_rewrite_conc.sh
index f2a684ba3..55c53fb89 100755
--- a/test/native_rewrite_conc.sh
+++ b/test/native_rewrite_conc.sh
@@ -39,12 +39,12 @@ runsql() {
# Compactor loop: hammer the online rewrite + fully-dead retirement concurrently.
STOP="$PGC_WORKDIR/stop"
-rm -f "$STOP"
+rm -f "$STOP" "$PGC_WORKDIR/compactor.out"
(
while [ ! -f "$STOP" ]; do
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -q -c "SELECT pgcolumnar.compact_rewrite('t_col', 0.0);" \
- >/dev/null 2>>"$PGC_WORKDIR/compactor.err" || true
+ -d "$PGC_DB" -qAt -c "SELECT pgcolumnar.compact_rewrite('t_col', 0.0);" \
+ >>"$PGC_WORKDIR/compactor.out" 2>>"$PGC_WORKDIR/compactor.err" || true
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -q -c "SELECT pgcolumnar.compact('t_col');" \
>/dev/null 2>>"$PGC_WORKDIR/compactor.err" || true
@@ -78,6 +78,15 @@ touch "$STOP"
wait "$comp_pid" 2>/dev/null || true
check "all workers succeeded (retried past serialization failures)" "$fail" "0"
+# THE RACE MUST HAVE HAPPENED, or the oracle comparison below proves nothing about
+# it. While the workers ran, the compactor must have moved rows (compact_rewrite
+# returns the live rows it moved) -- a rewrite that never ran alongside the writers
+# leaves the conflict protocol untested.
+moved="$(awk '/^[0-9]+$/ {s += $1} END {print s + 0}' "$PGC_WORKDIR/compactor.out" 2>/dev/null)"
+echo "-- compactor moved $moved rows during the workers' window; serialization retries: $(cat "$PGC_WORKDIR"/w.*.err 2>/dev/null | grep -c 'row group was compacted concurrently')"
+check "the compactor rewrote groups while the workers were writing" \
+ "$([ -n "$moved" ] && [ "$moved" -gt 0 ] 2>/dev/null && echo yes || echo "no (moved [$moved])")" "yes"
+
# A final settling pass, then the columnar table must equal the heap oracle.
q "SELECT pgcolumnar.compact_rewrite('t_col', 0.0);" >/dev/null
q "SELECT pgcolumnar.compact('t_col');" >/dev/null
@@ -87,4 +96,40 @@ diff_query "conc-rewrite count/sum" "SELECT count(*), sum(v), count(distinct cat
diff_query "conc-rewrite range" "SELECT count(*) FROM %T WHERE id BETWEEN 4000 AND 12000"
check "row count sane" "$([ "$(q 'SELECT count(*) FROM t_col;')" -gt 0 ] && echo ok)" "ok"
+# ---- the online rewrite does not block readers or writers --------------------
+#
+# The point of F3b is ShareUpdateExclusiveLock: a rewrite runs alongside the
+# workload. Nothing above could see a rewrite that took a stronger lock -- the
+# workers would simply wait for it and still finish, and the oracle would still
+# match. So a compact_rewrite is held mid-verb with its lock taken
+# (pgcolumnar.maintenance_hold_ms), and a reader and a writer, each with a short
+# lock_timeout, must finish while it still holds it.
+BEFORE="$(q 'SELECT count(*) FROM t_col;')"
+(
+ env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" \
+ -qAt -v ON_ERROR_STOP=1 -c "SET pgcolumnar.maintenance_hold_ms = 12000;" \
+ -c "SELECT pgcolumnar.compact_rewrite('t_col', 0.0);" >"$PGC_WORKDIR/holder.out" 2>&1
+ echo "rc=$?" >>"$PGC_WORKDIR/holder.out"
+) &
+holder_pid=$!
+# the holder's lock on t_col, by any backend running compact_rewrite: wait (bounded) until it is taken
+held() {
+ q "SELECT count(*) FROM pg_locks l JOIN pg_stat_activity a USING (pid)
+ WHERE l.relation = 't_col'::regclass AND l.granted AND a.query LIKE '%compact_rewrite%'
+ AND a.pid <> pg_backend_pid();"
+}
+for _ in $(seq 1 50); do [ "$(held)" = "1" ] && break; sleep 0.2; done
+check "a compact_rewrite holds its lock on t_col mid-verb" "$(held)" "1"
+check_num "a reader with a 3 s lock_timeout finishes while the rewrite holds its lock" \
+ "$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" \
+ -qAt -v ON_ERROR_STOP=1 -c "BEGIN; SET LOCAL lock_timeout = '3s'; SELECT count(*) FROM t_col; COMMIT;" 2>/dev/null | head -1)" \
+ "$BEFORE"
+check "a writer with a 3 s lock_timeout inserts while the rewrite holds its lock" \
+ "$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" \
+ -qAt -v ON_ERROR_STOP=1 -c "BEGIN; SET LOCAL lock_timeout = '3s'; INSERT INTO t_col VALUES (-1, 0, 0, 'probe'); COMMIT;" \
+ >/dev/null 2>&1 && echo inserted || echo refused)" "inserted"
+check "and the rewrite still held its lock when both had finished" "$(held)" "1"
+wait "$holder_pid"
+check "the held rewrite itself completed" "$(tail -1 "$PGC_WORKDIR/holder.out")" "rc=0"
+
pgc_summary
diff --git a/test/native_sort_by.sh b/test/native_sort_by.sh
index 4bf855d77..2eddc1ee5 100755
--- a/test/native_sort_by.sh
+++ b/test/native_sort_by.sh
@@ -9,6 +9,16 @@
# This suite proves: heap-parity, that sorting drives group skipping, the
# declared-key round-trip, set/reset, multi-column keys, and clean errors for a
# stale/dropped key, a bad column, a virtual generated column, and no-key.
+#
+# THE PHYSICAL ORDER IS READ, not inferred. Group skipping on host was the only
+# evidence of a sort, and a sort that kept hosts together but in the wrong order
+# (descending, or on host alone with ts unordered) skipped just as well. The rows
+# are now read in scan order and every adjacent pair is compared on (host, ts).
+#
+# A REFUSAL READS ITS OWN GUARD: the SQLSTATE and a phrase of the guard's message.
+# "psql failed" was enough before, and a backend that crashed in place of the
+# refusal failed psql too. Survival reads the server log's count of backends
+# killed by a signal, and every reading after a refusal first waits for the server.
# Written fresh for pgColumnar.
#
# Usage: test/native_sort_by.sh [PG_CONFIG]
@@ -33,16 +43,65 @@ psql_run "SELECT pgcolumnar.set_options('n', stripe_row_limit => 2048, chunk_gro
psql_run "INSERT INTO h $GEN;"
psql_run "INSERT INTO n $GEN;"
-# chunk groups removed by the zone maps for a query, from EXPLAIN ANALYZE
+# chunk groups removed by the zone maps for a query, from EXPLAIN ANALYZE; empty
+# when the plan could not be read or printed no such line
skipped() {
env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -At -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) $1" 2>/dev/null \
| grep 'Columnar Chunk Groups Removed by Filter' | grep -oE '[0-9]+' | head -1
}
-fails() {
- if env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -At -v ON_ERROR_STOP=1 -c "$1" >/dev/null 2>&1; then echo no; else echo yes; fi
+# wait, at most 60 s, until the server answers again (after a crash's recovery)
+await_up() {
+ local i
+ for i in $(seq 1 120); do
+ [ "$(q 'SELECT 1')" = 1 ] && return 0
+ sleep 0.5
+ done
+ return 1
+}
+# refusal_of SQL PHRASE -> " " when SQL raised with PHRASE in its
+# message, " other: " for a different refusal, "no error" when
+# it ran, or "no ()" when the connection was lost (a crash)
+refusal_of() {
+ local out state first
+ await_up
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA -v ON_ERROR_STOP=1 -v VERBOSITY=verbose -c "$1" 2>&1)" && { echo "no error"; return; }
+ state="$(sed -n 's/^ERROR: \([0-9A-Z]\{5\}\): .*/\1/p' <<<"$out" | head -1)"
+ first="$(head -1 <<<"$out")"
+ if [ -z "$state" ]; then echo "no ($first)"
+ elif grep -qF "$2" <<<"$out"; then echo "$state $2"
+ else echo "$state other: $first"; fi
+}
+# ran_ok SQL -> "ok" when SQL ran to the end, or what happened instead
+ran_ok() {
+ local out
+ await_up
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA -v ON_ERROR_STOP=1 -c "$1" 2>&1)" && { echo ok; return; }
+ echo "no ($(head -1 <<<"$out"))"
}
+crashes() { # -> backends killed by a signal so far, or nothing when the log is unreadable
+ local n
+ [ -r "$PGC_LOGFILE" ] || return 0
+ n="$(grep -c 'terminated by signal' "$PGC_LOGFILE" 2>/dev/null)" # prints 0, exit 1, on no match
+ printf '%s' "$n"
+}
+survived() { # survived BEFORE -> 1, or why not
+ local up now
+ await_up
+ up="$(q 'SELECT 1')"; now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ ]] || ! [[ "$now" =~ ^[0-9]+$ ]]; then
+ echo "no (crash count unreadable)"
+ elif [ "$up" = 1 ] && [ "$now" = "$1" ]; then echo 1
+ else echo "no ([$up], $((now - $1)) backend crashes)"; fi
+}
+# the scan-order pairs that break (host, ts) ascending; a number, or empty on failure
+INVERSIONS="SELECT count(*) FROM (SELECT host, ts, lag(host) OVER () AS ph, lag(ts) OVER () AS pt
+ FROM (SELECT host, ts FROM n OFFSET 0) s) x
+ WHERE ph IS NOT NULL AND (ph, pt) > (host, ts)"
+# the (host, ts) sequence in scan order, as one digest
+LAYOUT="SELECT md5(string_agg(host || '|' || ts::text, ',')) FROM (SELECT host, ts FROM n OFFSET 0) s"
EQ="host = 'h007'"
@@ -53,7 +112,12 @@ check "equality parity (pre-sort)" \
"$(pgc_set_hash "SELECT id, host, ts, payload FROM n WHERE $EQ")" \
"$(pgc_set_hash "SELECT id, host, ts, payload FROM h WHERE $EQ")"
-before="$(skipped "SELECT id FROM n WHERE $EQ")"; before="${before:-0}"
+# The generator cycles hosts, so the inserted order is far from (host, ts) order;
+# without this the order check below could pass on a table that was never sorted.
+check "premise: before sorting, the scan order does not follow (host, ts)" \
+ "$(inv="$(q "$INVERSIONS")"; pgc_is_number "$inv" && [ "$inv" -gt 0 ] && echo yes || echo "no [$inv]")" "yes"
+
+before="$(skipped "SELECT id FROM n WHERE $EQ")"
# ---- declare the key, then apply it with the zero-arg form ----
psql_run "SELECT pgcolumnar.set_options('n', sort_by => ARRAY['host','ts']);"
@@ -71,55 +135,73 @@ check "equality parity (post-sort)" \
"$(pgc_set_hash "SELECT id, host, ts, payload FROM n WHERE $EQ")" \
"$(pgc_set_hash "SELECT id, host, ts, payload FROM h WHERE $EQ")"
-after="$(skipped "SELECT id FROM n WHERE $EQ")"; after="${after:-0}"
+after="$(skipped "SELECT id FROM n WHERE $EQ")"
echo " (chunk groups skipped for $EQ: before=$before after=$after)"
check "sorting a text key enables group skipping" \
- "$([ "$after" -gt "$before" ] && [ "$after" -gt 0 ] && echo yes || echo no)" "yes"
+ "$(if ! pgc_is_number "$before" || ! pgc_is_number "$after"; then
+ echo "no reading (before [$before], after [$after])"
+ elif [ "$after" -gt "$before" ] && [ "$after" -gt 0 ]; then echo yes; else echo no; fi)" "yes"
+check_num "the scan order follows the declared key (host, ts)" "$(q "$INVERSIONS")" "0"
+DECLARED_LAYOUT="$(q "$LAYOUT")"
# ---- explicit columns still work and equal the declared-key result ----
psql_run "SELECT pgcolumnar.vacuum_sorted('n', 'host', 'ts');"
check "explicit-column sort parity" \
"$(pgc_set_hash 'SELECT id, host, ts, payload FROM n ORDER BY id')" \
"$(pgc_set_hash 'SELECT id, host, ts, payload FROM h ORDER BY id')"
+check_text "explicit columns lay the rows out as the declared key does" \
+ "$(q "$LAYOUT")" "${DECLARED_LAYOUT:-no declared layout read}"
# ---- reset clears the key; zero-arg then errors ----
psql_run "SELECT pgcolumnar.reset_options('n', sort_by => true);"
check "sort_by cleared" \
"$(q "SELECT sort_by IS NULL FROM pgcolumnar.options WHERE regclass = 'n'::regclass")" "t"
+CRASHES_BEFORE="$(crashes)"
check "zero-arg errors when no key declared" \
- "$(fails "SELECT pgcolumnar.vacuum_sorted('n')")" "yes"
+ "$(refusal_of "SELECT pgcolumnar.vacuum_sorted('n')" "has no declared sort_by key")" \
+ "22023 has no declared sort_by key"
# ---- validation errors ----
check "set_options rejects a nonexistent sort_by column" \
- "$(fails "SELECT pgcolumnar.set_options('n', sort_by => ARRAY['nope'])")" "yes"
+ "$(refusal_of "SELECT pgcolumnar.set_options('n', sort_by => ARRAY['nope'])" "cannot be used in sort_by")" \
+ "P0001 cannot be used in sort_by"
# stale declared key: declare, drop the column, then the zero-arg apply must error
psql_run "SELECT pgcolumnar.set_options('n', sort_by => ARRAY['payload']);"
psql_run "ALTER TABLE n DROP COLUMN payload;"
check "zero-arg errors on a dropped declared column" \
- "$(fails "SELECT pgcolumnar.vacuum_sorted('n')")" "yes"
+ "$(refusal_of "SELECT pgcolumnar.vacuum_sorted('n')" "declared sort_by column \"payload\" does not exist")" \
+ "42703 declared sort_by column \"payload\" does not exist"
psql_run "SELECT pgcolumnar.reset_options('n', sort_by => true);"
# ---- edge cases: empty and single-row tables sort cleanly ----
psql_run "CREATE TABLE e (id int, host text) USING pgcolumnar;"
-check "empty table sorts without error" \
- "$(fails "SELECT pgcolumnar.vacuum_sorted('e', 'host')")" "no"
+check "empty table sorts without error" "$(ran_ok "SELECT pgcolumnar.vacuum_sorted('e', 'host')")" "ok"
psql_run "INSERT INTO e VALUES (1, 'x');"
-check "single-row table sorts without error" \
- "$(fails "SELECT pgcolumnar.vacuum_sorted('e', 'host')")" "no"
+check "single-row table sorts without error" "$(ran_ok "SELECT pgcolumnar.vacuum_sorted('e', 'host')")" "ok"
check "single-row parity" "$(q 'SELECT count(*) FROM e')" "1"
-# ---- virtual generated column rejected as a key (PG18+; inert earlier) ----
-PGV="$("$PGC_BINDIR/pg_config" --version | grep -oE '[0-9]+' | head -1)"
-if [ "${PGV:-0}" -ge 18 ]; then
+# ---- virtual generated column rejected as a key (PG18+) ----
+# The major is asserted first, so an unreadable version is not mistaken for an old
+# one. Before PG18 the arm is recorded as a SKIP rather than printed and left out.
+if ! pgc_is_number "${PGC_MAJOR:-}"; then
+ pgc_fail "could not read the server major, so the virtual arm cannot be gated" \
+ "got [${PGC_MAJOR:-}]"
+elif [ "$PGC_MAJOR" -ge 18 ]; then
psql_run "CREATE TABLE v (id int, k int, kv int GENERATED ALWAYS AS (k * 2) VIRTUAL) USING pgcolumnar;"
psql_run "INSERT INTO v (id, k) SELECT g, g % 10 FROM generate_series(1, 100) g;"
check "set_options rejects a virtual generated sort_by column" \
- "$(fails "SELECT pgcolumnar.set_options('v', sort_by => ARRAY['kv'])")" "yes"
+ "$(refusal_of "SELECT pgcolumnar.set_options('v', sort_by => ARRAY['kv'])" "cannot be used in sort_by")" \
+ "P0001 cannot be used in sort_by"
check "vacuum_sorted rejects an explicit virtual generated column" \
- "$(fails "SELECT pgcolumnar.vacuum_sorted('v', 'kv')")" "yes"
+ "$(refusal_of "SELECT pgcolumnar.vacuum_sorted('v', 'kv')" "is a virtual generated column and cannot be a sort key")" \
+ "22023 is a virtual generated column and cannot be a sort key"
else
- echo " (skipping virtual-generated-column cases: PostgreSQL < 18)"
+ check_skip "virtual generated sort keys" \
+ "SKIP VIRTUAL generated columns arrived in PostgreSQL 18; this server is $PGC_MAJOR" \
+ "needs VIRTUAL generated columns, PG18+"
fi
+check "backend still up after the refusals" "$(survived "$CRASHES_BEFORE")" "1"
+
pgc_summary
diff --git a/test/native_vecdecode.sh b/test/native_vecdecode.sh
index 7d3e18f52..c772bd95a 100755
--- a/test/native_vecdecode.sh
+++ b/test/native_vecdecode.sh
@@ -26,10 +26,14 @@ pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"
# is per-vector. id is monotonic, so each vector's zone map is tight and
# non-overlapping, which is what lets a narrow range rule most of them out.
ROWS=32768
-GEN="SELECT g AS id, (g % 97) AS v FROM generate_series(1, $ROWS) g"
+# w is NULL on every seventh row, so a vector carries nulls and the packed stream is
+# indexed by presence rather than by row: a reader that ignores the validity bitmap
+# reads the wrong value for every row after the first null (#1341 port; with no
+# nulls, a fold that ignored the bitmap passed every check).
+GEN="SELECT g AS id, (g % 97) AS v, CASE WHEN g % 7 = 0 THEN NULL ELSE g % 89 END AS w FROM generate_series(1, $ROWS) g"
-psql_run "CREATE TABLE h (id int, v int);"
-psql_run "CREATE TABLE n (id int, v int) USING pgcolumnar;"
+psql_run "CREATE TABLE h (id int, v int, w int);"
+psql_run "CREATE TABLE n (id int, v int, w int) USING pgcolumnar;"
psql_run "SELECT pgcolumnar.set_options('n', stripe_row_limit => 65536, chunk_group_row_limit => 1024);"
psql_run "INSERT INTO h $GEN;"
psql_run "INSERT INTO n $GEN;"
@@ -47,6 +51,7 @@ counter_in() { # counter_in
}
has_line() { grep -qF "$2" <<<"$1" && echo yes || echo no; }
+isnum() { [[ "$1" =~ ^[0-9]+$ ]]; }
# The narrow range lives inside a single vector; the wide one spans them all.
NARROW="SELECT id, v FROM n WHERE id BETWEEN 2000 AND 2050"
@@ -55,30 +60,33 @@ WIDE="SELECT id, v FROM n WHERE id BETWEEN 1 AND $ROWS"
PLAN_NARROW="$(explain_of "$NARROW")"
PLAN_WIDE="$(explain_of "$WIDE")"
-# ---- premises -------------------------------------------------------------
+# ---- the ground --------------------------------------------------------------
#
# Every number below is read out of these two plans. If the planner stops
# choosing the columnar custom scan, or the group splits, the comparisons start
# reporting a decode regression that is really a plan or a layout change. Assert
-# the ground first so a failure names its own cause.
+# the ground first so a failure names its own cause. None of these is a premise:
+# each reads pgcolumnar's plan or its writer's row_group catalog, and a one-line
+# pgcolumnar change reddens it (the custom scan off by default; a writer that
+# reads stripe_row_limit wrong).
-check "premise: the narrow plan is a columnar custom scan" \
+check "the narrow plan is a columnar custom scan" \
"$(has_line "$PLAN_NARROW" 'Columnar Projected Columns')" "yes"
-check "premise: the wide plan is a columnar custom scan" \
+check "the wide plan is a columnar custom scan" \
"$(has_line "$PLAN_WIDE" 'Columnar Projected Columns')" "yes"
-check "premise: one row group, so every difference is per-vector" \
+check "one row group, so every difference is per-vector" \
"$(q "SELECT count(*) FROM pgcolumnar.row_group WHERE storage_id = pgcolumnar.get_storage_id('n');")" \
"1"
-check "premise: no whole group is pruned on the narrow query" \
+check "no whole group is pruned on the narrow query" \
"$(counter_in "$PLAN_NARROW" 'Columnar Chunk Groups Removed by Filter')" "0"
# The two arms must differ in what the zone maps rule out, or the central check
# compares a number with itself and passes against any implementation.
SKIP_NARROW="$(counter_in "$PLAN_NARROW" 'Columnar Vectors Skipped')"
SKIP_WIDE="$(counter_in "$PLAN_WIDE" 'Columnar Vectors Skipped')"
-check "premise: the narrow range skips vectors" \
- "$([ "${SKIP_NARROW:-0}" -gt 0 ] && echo yes || echo no)" "yes"
-check "premise: the wide range skips none, so the arms really differ" \
+check "the narrow range skips vectors" \
+ "$(isnum "$SKIP_NARROW" && { [ "$SKIP_NARROW" -gt 0 ] && echo yes || echo no; } || echo "no reading [$SKIP_NARROW]")" "yes"
+check "the wide range skips none, so the arms really differ" \
"$SKIP_WIDE" "0"
# ---- the observable -------------------------------------------------------
@@ -95,14 +103,16 @@ check "and it is not stuck at zero when a full scan decodes everything" \
DEC_NARROW="$(counter_in "$PLAN_NARROW" 'Columnar Vectors Decoded')"
check "a ruled-out vector is not decoded" \
- "$([ "${DEC_NARROW:-0}" -lt "${DEC_WIDE:-0}" ] && echo yes || echo no)" "yes"
+ "$(isnum "$DEC_NARROW" && isnum "$DEC_WIDE" && { [ "$DEC_NARROW" -lt "$DEC_WIDE" ] && echo yes || echo no; } || echo "no reading [$DEC_NARROW] [$DEC_WIDE]")" "yes"
# The check that has teeth. "Decodes fewer" is satisfied by decoding one vector
# less out of thirty-two, which is what a half-working mask does. Every vector
# the zone maps ruled out must go undecoded, so the two counters must account
# for the whole group between them.
+# All three counters must be READ: with the plans failing, 0 + 0 = 0 used to pass.
check "and EVERY ruled-out vector is: decoded plus skipped is the group's vectors" \
- "$(( ${DEC_NARROW:-0} + ${SKIP_NARROW:-0} ))" "${DEC_WIDE:-0}"
+ "$(isnum "$DEC_NARROW" && isnum "$SKIP_NARROW" && echo $(( DEC_NARROW + SKIP_NARROW )) || echo "no reading [$DEC_NARROW] [$SKIP_NARROW]")" \
+ "$(isnum "$DEC_WIDE" && echo "$DEC_WIDE" || echo "no wide reading [$DEC_WIDE]")"
# ---- correctness, which is the risk this change carries -------------------
#
@@ -123,14 +133,20 @@ check "boundary range parity, a range starting exactly on a vector edge" \
"$(pgc_set_hash 'SELECT id, v FROM n WHERE id BETWEEN 1025 AND 2048')" \
"$(pgc_set_hash 'SELECT id, v FROM h WHERE id BETWEEN 1025 AND 2048')"
+check "premise: the nullable column has nulls inside the ranges read below" \
+ "$(q 'SELECT count(*) - count(w) > 0 FROM h WHERE id BETWEEN 2000 AND 2050;')" "t"
+check "cross-vector parity of a nullable column, nulls included" \
+ "$(pgc_set_hash 'SELECT id, w FROM n WHERE id BETWEEN 3000 AND 9000')" \
+ "$(pgc_set_hash 'SELECT id, w FROM h WHERE id BETWEEN 3000 AND 9000')"
+
# The aggregate path is the one that reads the raw buffer directly.
-check "aggregate over a skipping range matches heap" \
+check_text "aggregate over a skipping range matches heap" \
"$(q 'SELECT count(*), sum(v) FROM n WHERE id BETWEEN 2000 AND 2050;')" \
"$(q 'SELECT count(*), sum(v) FROM h WHERE id BETWEEN 2000 AND 2050;')"
-check "aggregate over a range spanning many skipped vectors matches heap" \
+check_text "aggregate over a range spanning many skipped vectors matches heap" \
"$(q 'SELECT count(*), sum(v), min(v), max(v) FROM n WHERE id BETWEEN 5000 AND 5200;')" \
"$(q 'SELECT count(*), sum(v), min(v), max(v) FROM h WHERE id BETWEEN 5000 AND 5200;')"
-check "a range matching no row at all is still correct" \
+check_num "a range matching no row at all is still correct" \
"$(q 'SELECT count(*) FROM n WHERE id BETWEEN 100000 AND 200000;')" "0"
# ---- the fold, which is the path that reads the holes ----------------------
@@ -160,11 +176,11 @@ FQ="SELECT count(*), sum(v) FROM n WHERE id BETWEEN 2000 AND 2050"
# Without this the two checks below are unreachable and would pass forever while
# guarding nothing: the fold declines most shapes, and a declined fold answers
# from the scalar path that was never at risk.
-check "premise: the aggregate really reaches the batch fold" \
+check "the aggregate really reaches the batch fold" \
"$(fold_plan "$FQ" | grep -oE 'Columnar Batch Fold: [a-z]+' | head -1)" \
"Columnar Batch Fold: yes"
-check "the fold answers correctly over a range that skips vectors" \
+check_text "the fold answers correctly over a range that skips vectors" \
"$(fold_run "$FQ")" \
"$(q 'SELECT count(*), sum(v) FROM h WHERE id BETWEEN 2000 AND 2050;')"
@@ -177,11 +193,22 @@ check "the fold answers correctly over a range that skips vectors" \
# The first range above starts in vector 1 and cannot show this: one leading
# skipped vector is too few for a misalignment to be obvious, and its answer
# could be right by luck.
+# A nullable column through the fold: the packed stream holds only present values,
+# so the fold must advance its present index by the validity bitmap. With the bitmap
+# ignored it reads a shifted value for every row after the first null.
+FQN="SELECT count(w), sum(w) FROM n WHERE id BETWEEN 2000 AND 2050"
+check "a nullable aggregate reaches the fold" \
+ "$(fold_plan "$FQN" | grep -oE 'Columnar Batch Fold: [a-z]+' | head -1)" \
+ "Columnar Batch Fold: yes"
+check_text "the fold answers count and sum of a nullable column over a skipping range" \
+ "$(fold_run "$FQN")" \
+ "$(q 'SELECT count(w), sum(w) FROM h WHERE id BETWEEN 2000 AND 2050;')"
+
FQ2="SELECT count(*), sum(v) FROM n WHERE id BETWEEN 20000 AND 20050"
-check "premise: the deep range reaches the fold too" \
+check "the deep range reaches the fold too" \
"$(fold_plan "$FQ2" | grep -oE 'Columnar Batch Fold: [a-z]+' | head -1)" \
"Columnar Batch Fold: yes"
-check "and it agrees with heap after many leading skipped vectors" \
+check_text "and it agrees with heap after many leading skipped vectors" \
"$(fold_run "$FQ2")" \
"$(q 'SELECT count(*), sum(v) FROM h WHERE id BETWEEN 20000 AND 20050;')"
@@ -203,7 +230,7 @@ check "and it agrees with heap after many leading skipped vectors" \
# On a non-assert build there is no poison and this check is merely another
# parity check. The matrix runs assert builds, which is where it has teeth.
POISONQ="SELECT count(*), sum(v) FROM n WHERE id BETWEEN -2000000000 AND 3000"
-check "premise: the poison-accepting range reaches the fold" \
+check "the poison-accepting range reaches the fold" \
"$(fold_plan "$POISONQ" | grep -oE 'Columnar Batch Fold: [a-z]+' | head -1)" \
"Columnar Batch Fold: yes"
# Measured on the FOLD plan, not on a bare projection beside it.
@@ -221,7 +248,7 @@ check "premise: the poison-accepting range reaches the fold" \
# in the first three vectors of 1024, so three is the whole of what this query
# needs and the other 29 are the saving.
FOLD_DEC="$(counter_in "$(fold_plan "$POISONQ")" 'Columnar Vectors Decoded')"
-check "premise: the FOLD ARM really decodes only the vectors it needs" "$FOLD_DEC" "3"
+check_num "the FOLD ARM really decodes only the vectors it needs" "$FOLD_DEC" "3"
# The counter must be capable of reading high on this arm, or the check above is
# satisfied by a number that is simply always small. This is the case that would
@@ -230,7 +257,7 @@ check "premise: the FOLD ARM really decodes only the vectors it needs" "$FOLD_DE
check "control: and the same counter reads every vector when nothing is skipped" \
"$(counter_in "$(fold_plan 'SELECT count(*), sum(v) FROM n WHERE id >= 0')" 'Columnar Vectors Decoded')" \
"32"
-check "the fold does not count rows from a vector it never decoded" \
+check_text "the fold does not count rows from a vector it never decoded" \
"$(fold_run "$POISONQ")" \
"$(q 'SELECT count(*), sum(v) FROM h WHERE id BETWEEN -2000000000 AND 3000;')"
diff --git a/test/native_zonemap.sh b/test/native_zonemap.sh
index fc2373e1d..c76d8e06c 100755
--- a/test/native_zonemap.sh
+++ b/test/native_zonemap.sh
@@ -19,10 +19,15 @@ pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"
# 6144 rows, a 2048-row group limit and the fixed 1024 vector length give a clean
# 3 groups x 2 vectors per column chunk. Four orderable columns with distinct null
# patterns exercise value_count/null_count and min/max.
+#
+# v is NOT ascending (#1341). With every column ascending, a vector's first value is
+# always its minimum, so a writer that never lowered its min past the first value
+# stored exactly the right bytes, and every check here passed 18/18 (nominupd).
+# A permutation of 1..6150 keeps v's values, nulls and counts what they were.
GEN="SELECT g,
(g * 10)::bigint,
'lbl-' || (g % 50),
- CASE WHEN g % 7 = 0 THEN NULL ELSE g END
+ CASE WHEN g % 7 = 0 THEN NULL ELSE ((g::bigint * 7919) % 6151)::int END
FROM generate_series(1, 6144) g"
psql_run "CREATE TABLE h (id int, k bigint, label text, v int);"
@@ -34,6 +39,36 @@ psql_run "INSERT INTO n $GEN;"
SID="$(q "SELECT pgcolumnar.get_storage_id('n');")"
zq() { q "SELECT $1 FROM pgcolumnar.zone_map WHERE storage_id = $SID;"; }
+# A fixed-width bound is stored as the value's own bytes (PgColumnarEncodeValue),
+# native order: little-endian on every platform CI runs. zm_le reads such a bound
+# back as an integer, so the bounds can be compared with the heap's.
+psql_run "CREATE FUNCTION zm_le(b bytea) RETURNS bigint LANGUAGE sql IMMUTABLE AS
+ \$\$SELECT sum((get_byte(b, i)::bigint) << (8 * i))::bigint
+ FROM generate_series(0, length(b) - 1) i\$\$;"
+# bounds_match KIND MINMAX -> how many zone rows of id (0), k (1) and v (3) hold the
+# heap's own bound for their group and vector. KIND is "vector" (vector_index >= 0,
+# 18 rows: 3 groups x 2 vectors x 3 columns) or "chunk" (vector_index = -1, 9 rows).
+bounds_match() {
+ local vfilter="vector_index >= 0" side="$2"
+ [ "$1" = chunk ] && vfilter="vector_index = -1"
+ q "WITH z AS (
+ SELECT dense_rank() OVER (ORDER BY group_number) - 1 AS g, column_index AS c,
+ vector_index AS vi, zm_le($side) AS got
+ FROM pgcolumnar.zone_map
+ WHERE storage_id = $SID AND column_index IN (0, 1, 3) AND $vfilter),
+ hv AS (
+ SELECT (id - 1) / 2048 AS g, ((id - 1) % 2048) / 1024 AS vi,
+ min(id) AS min0, max(id) AS max0, min(k) AS min1, max(k) AS max1,
+ min(v) AS min3, max(v) AS max3
+ FROM h GROUP BY 1, 2
+ UNION ALL
+ SELECT (id - 1) / 2048, -1, min(id), max(id), min(k), max(k), min(v), max(v)
+ FROM h GROUP BY 1)
+ SELECT count(*) FROM z JOIN hv USING (g, vi)
+ WHERE z.got = CASE z.c WHEN 0 THEN hv.${side:0:3}0 WHEN 1 THEN hv.${side:0:3}1
+ ELSE hv.${side:0:3}3 END;"
+}
+
check "row count" "$(q 'SELECT count(*) FROM n;')" "6144"
check "read parity (no regression)" \
"$(pgc_set_hash 'SELECT id, k, label, v FROM n')" \
@@ -54,10 +89,10 @@ check "per-vector value+null = 1024" \
# Counts must match the heap oracle exactly, summed over the whole-chunk rows.
# column_index is 0-based: id=0, k=1, label=2, v=3.
-check "value_count sums (v, nullable)" \
+check_num "value_count sums (v, nullable)" \
"$(zq 'sum(value_count) FILTER (WHERE vector_index = -1 AND column_index = 3)')" \
"$(q 'SELECT count(v) FROM h;')"
-check "null_count sums (v, nullable)" \
+check_num "null_count sums (v, nullable)" \
"$(zq 'sum(null_count) FILTER (WHERE vector_index = -1 AND column_index = 3)')" \
"$(q 'SELECT count(*) - count(v) FROM h;')"
check "value_count sums (id, no nulls)" \
@@ -84,6 +119,20 @@ check "id (int4) min width = 4 bytes" \
check "k (int8) max width = 8 bytes" \
"$(zq 'count(*) FILTER (WHERE column_index = 1 AND octet_length(maximum) <> 8)')" "0"
+# The bounds' VALUES, not only their presence and width (#1341). Presence and width
+# held for a max never raised past a vector's first value (nomaxupd), a min never
+# lowered (nominupd) and a chunk max never folded past its first vector (foldmax):
+# main passed 18/18 under each.
+check "premise: v is not ascending within a vector, so a stale minimum would show" \
+ "$(q "SELECT count(*) > 0 FROM (
+ SELECT (id - 1) / 1024 AS vec, min(v) AS mn,
+ (array_agg(v ORDER BY id) FILTER (WHERE v IS NOT NULL))[1] AS first
+ FROM h GROUP BY 1) s WHERE first <> mn;")" "t"
+check_num "every vector's minimum of id, k and v is the heap's" "$(bounds_match vector minimum)" "18"
+check_num "every vector's maximum of id, k and v is the heap's" "$(bounds_match vector maximum)" "18"
+check_num "every whole-chunk minimum of id, k and v is the heap's" "$(bounds_match chunk minimum)" "9"
+check_num "every whole-chunk maximum of id, k and v is the heap's" "$(bounds_match chunk maximum)" "9"
+
# D5b populates sum for int2/int4 columns (id, v) as an exact numeric; other
# types (k bigint, label text) keep a null sum. column_index: id=0, v=3, k=1, label=2.
check "sum present for int4 columns" \
@@ -92,7 +141,7 @@ check "sum present for int4 columns" \
check "sum null for non-int4 columns" \
"$(zq 'count(*) FILTER (WHERE column_index IN (1,2) AND sum IS NOT NULL)')" \
"0"
-check "whole-chunk sum(id) matches heap" \
+check_num "whole-chunk sum(id) matches heap" \
"$(zq 'sum(sum) FILTER (WHERE column_index = 0 AND vector_index = -1)')" \
"$(q 'SELECT sum(id) FROM h;')"
diff --git a/test/native_zonemap_narrow.sh b/test/native_zonemap_narrow.sh
index 87f9baa76..3fa46000e 100755
--- a/test/native_zonemap_narrow.sh
+++ b/test/native_zonemap_narrow.sh
@@ -53,8 +53,10 @@ echo "-- zone_map idx_tup_fetch: wide(30 col)=$WF narrow(2 col)=$NF"
# Per-column: the 30-column table reads essentially the same zone_map tuples as
# the 2-column table (only c1's). Allow a small margin. Before the fix WF was
# about 15x NF (30 columns vs 2), so this fails on the whole-group read.
+# Both readings must be numbers: an unread one is "no reading", never compared (#1341).
check "zone_map reads scale with predicates, not table width (wide ~= narrow)" \
- "$([ "$WF" -le $((NF * 2)) ] && echo yes || echo no)" "yes"
+ "$(if ! [[ "$WF" =~ ^[0-9]+$ && "$NF" =~ ^[0-9]+$ ]]; then echo "no reading (wide [$WF], narrow [$NF])"
+ elif [ "$WF" -le $((NF * 2)) ]; then echo yes; else echo no; fi)" "yes"
# Correctness: skipping unread columns' zone maps must not change results.
check "wide scan result is correct" "$(q 'SELECT count(*) FROM w WHERE c1 >= 0;')" "20000"
@@ -64,4 +66,19 @@ check "narrow scan result is correct" "$(q 'SELECT count(*) FROM n WHERE c1 >= 0
check "a pruning predicate still prunes on the wide table" \
"$(q 'SELECT count(*) FROM w WHERE c1 > 19000;')" "1000"
+# The answer alone does not show a group was skipped: a scan that read the zone maps
+# but never excluded a group, or never consulted them, returned the same 1000 rows,
+# and main passed all four checks under each (#1341). The scan's own counter says how
+# many of the ten groups the zone map removed: c1 > 19000 leaves only the last.
+# removed TABLE PRED -> "Columnar Chunk Groups Removed by Filter", or "no reading".
+removed() {
+ local plan n
+ plan="$(q "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT * FROM $1 WHERE $2;")"
+ n="$(sed -n 's/.*Columnar Chunk Groups Removed by Filter: \([0-9]*\).*/\1/p' <<<"$plan" | head -1)"
+ [ -n "$n" ] && echo "$n" || echo "no reading ($(head -1 <<<"$plan"))"
+}
+check "the zone map removes the nine groups c1 > 19000 cannot match, on the wide table" \
+ "$(removed w 'c1 > 19000')" "9"
+check "and on the narrow table" "$(removed n 'c1 > 19000')" "9"
+
pgc_summary
diff --git a/test/native_zonemap_session.sh b/test/native_zonemap_session.sh
index 9920439e9..158cc0b57 100755
--- a/test/native_zonemap_session.sh
+++ b/test/native_zonemap_session.sh
@@ -64,10 +64,12 @@ psql_run "DROP TABLE IF EXISTS zs;
SELECT pgcolumnar.set_options('zs', stripe_row_limit => 10000);
INSERT INTO zs SELECT g, g, g FROM generate_series(1, $ROWS) g;" >/dev/null 2>&1
-# The group count comes FROM THE DATA, never from rows/stripe_row_limit.
+# The group count comes FROM THE DATA, never from rows/stripe_row_limit. Not a
+# premise: it reads the writer's own row_group catalog, and a writer that reads the
+# stripe_row_limit option wrong (one line) reddens it, as zs2's check below.
groups=$(q "SELECT count(*) FROM pgcolumnar.row_group
WHERE storage_id = pgcolumnar.get_storage_id('zs');")
-check "premise: the fixture really has more than one chunk group" \
+check "the fixture has more than one chunk group" \
"$([ "${groups:-0}" -gt 1 ] && echo yes || echo no)" "yes"
witness() { # witness "" -> "probes=N opens=M"
@@ -81,10 +83,10 @@ probes="$(printf '%s' "$w" | grep -oE 'probes=[0-9]+' | grep -oE '[0-9]+')"
opens="$(printf '%s' "$w" | grep -oE 'opens=[0-9]+' | grep -oE '[0-9]+')"
echo "-- one predicate column over $groups groups: $w"
-# The premise first: if the scan probed nothing, "opens=1" would be trivially
+# The probe count first: if the scan probed nothing, "opens=1" would be trivially
# satisfied by a scan that did no work at all, which is the shape that let
# zonemap_cost price pruning for a year while pruning zero groups.
-check "premise: the scan really probed the zone maps, once per group" \
+check "the scan really probed the zone maps, once per group" \
"$probes" "$groups"
check "the scan resolves zone_map ONCE, not once per group (#744)" "$opens" "1"
@@ -105,10 +107,24 @@ psql_run "DROP TABLE IF EXISTS zs2;
SELECT pgcolumnar.set_options('zs2', stripe_row_limit => 1000);
INSERT INTO zs2 SELECT g, g FROM generate_series(1,20000) g;" >/dev/null 2>&1
-nested=$(q "SET enable_indexscan=off; SET max_parallel_workers_per_gather=0;
- SELECT count(*) FROM zs2 a JOIN zs2 b ON a.k = b.k
- WHERE a.k <= 1000 AND b.k <= 1000;" | tail -1)
-check "nested columnar scans each hold their own handle" "$nested" "1000"
+groups2=$(q "SELECT count(*) FROM pgcolumnar.row_group
+ WHERE storage_id = pgcolumnar.get_storage_id('zs2');")
+check "zs2 has more than one chunk group" \
+ "$([ "${groups2:-0}" -gt 1 ] 2>/dev/null && echo yes || echo no)" "yes"
+
+# Both scans of a join stay open until ExecutorEnd, so a session shared between
+# them (the static the first implementation used) adds one scan's probes to the
+# other's and leaves the second report empty. The answer cannot see that: the
+# shared handle reads correctly. The witness can: each scan must report ITS OWN
+# groups, opened once.
+nestout="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -v ON_ERROR_STOP=1 \
+ -c "SET client_min_messages=debug1; SET enable_indexscan=off; SET max_parallel_workers_per_gather=0;" \
+ -c "SELECT count(*) FROM zs2 a JOIN zs2 b ON a.k = b.k WHERE a.k <= 1000 AND b.k <= 1000;" 2>&1)"
+check "nested columnar scans return the right answer" \
+ "$(printf '%s\n' "$nestout" | grep -E '^[0-9]+$' | tail -1)" "1000"
+check "nested columnar scans each hold their own handle" \
+ "$(printf '%s\n' "$nestout" | grep -c "zone map read: probes=${groups2:-x} opens=1\$")" "2"
# An erroring scan never runs its end hook. What must be true afterwards is that
# the NEXT scan opens for itself rather than inheriting anything.
@@ -126,7 +142,7 @@ SELECT count(*) FROM zs2 WHERE k <= 1000;
SQL
out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -At -v ON_ERROR_STOP=0 -f "$PGC_WORKDIR/zsub.sql" 2>&1)"
-check "premise: the middle scan really did error inside the subtransaction" \
+check "the middle scan really did error inside the subtransaction" \
"$(printf '%s' "$out" | grep -c 'division by zero')" "1"
# THREE scans complete: before, after ROLLBACK TO, after COMMIT. Each must
# report. Two would mean one of them ran on state left behind by the error,
@@ -135,6 +151,10 @@ check "every scan around an aborted one reports its own session (#744)" \
"$(printf '%s' "$out" | grep -c 'zone map read: probes=')" "3"
check "and each of them opened exactly once" \
"$(printf '%s' "$out" | grep -c 'zone map read: probes=[0-9]* opens=1')" "3"
+# And each probed ITS OWN groups: a session carried over from the aborted scan
+# would report that scan's probes on top of its own.
+check "and each of them probed exactly its own groups" \
+ "$(printf '%s\n' "$out" | grep -c "zone map read: probes=${groups2:-x} opens=1\$")" "3"
check "the scan after the subtransaction abort returns the right answer" \
"$(printf '%s' "$out" | grep -cE '^1000$')" "3"
diff --git a/test/objstore_addressing.sh b/test/objstore_addressing.sh
index c43189210..eb9d1730a 100755
--- a/test/objstore_addressing.sh
+++ b/test/objstore_addressing.sh
@@ -56,6 +56,16 @@ python3 - "$PGC_WORKDIR/$BUCKET/vh.parquet" <<'PY'
import sys, pyarrow as pa, pyarrow.parquet as pq
pq.write_table(pa.table({"id": pa.array([1, 2, 3, 4], pa.int64())}), sys.argv[1])
PY
+# A key a reader must URI-encode: a space, '+' and '='. The fixture decodes the request
+# path once to find the file, and verifies SigV4 over the raw path, so a key sent with
+# '+' for a space, or with '+'/'=' left bare, either misses the file or shows up in the
+# request log below (#621).
+ENCKEY="dir sp/a+b=c.parquet"
+mkdir -p "$PGC_WORKDIR/$BUCKET/dir sp"
+python3 - "$PGC_WORKDIR/$BUCKET/$ENCKEY" <<'PY'
+import sys, pyarrow as pa, pyarrow.parquet as pq
+pq.write_table(pa.table({"id": pa.array([5, 6, 7], pa.int64())}), sys.argv[1])
+PY
python3 "$(dirname "${BASH_SOURCE[0]}")/objstore_http_server.py" \
--dir "$PGC_WORKDIR" --port "$S3_PORT" --log "$S3_LOG" \
@@ -76,6 +86,11 @@ RD="SELECT count(*), sum(id) FROM pgcolumnar.read_parquet"
check "s3:// path-style read" "$(q "$RD('s3://$BUCKET/vh.parquet') AS t(id int8)" | tr '|' ' ')" "4 10"
check "path-style put the bucket in the request path" \
"$([ "$(gets | grep -c '^/pgc-bucket/vh.parquet$')" -ge 1 ] && echo yes)" "yes"
+: > "$S3_LOG"
+check "a key with a space, '+' and '=' reads back (path-style)" \
+ "$(q "$RD('s3://$BUCKET/$ENCKEY') AS t(id int8)" | tr '|' ' ')" "3 18"
+check "and its path-style request path is the key URI-encoded once" \
+ "$([ "$(gets | grep -c '^/pgc-bucket/dir%20sp/a%2Bb%3Dc.parquet$')" -ge 1 ] && echo yes)" "yes"
# gs:// round-trips through the interop API against the same overridden endpoint
: > "$S3_LOG"
@@ -94,6 +109,12 @@ check "virtual-host read == path-style read (same object, different addressing)"
-c "$RD('s3://$BUCKET/vh.parquet') AS t(id int8)" | tr '|' ' ')" "4 10"
check "virtual-host put the KEY ALONE in the path (no bucket segment)" \
"$([ "$(gets | grep -c '^/vh.parquet$')" -ge 1 ] && [ "$(gets | grep -c '^/pgc-bucket/vh.parquet$')" -eq 0 ] && echo yes)" "yes"
+: > "$S3_LOG"
+check "a key with a space, '+' and '=' reads back (virtual-host)" \
+ "$(qset -c "SET pgcolumnar.objstore_s3_addressing='virtual'" \
+ -c "$RD('s3://$BUCKET/$ENCKEY') AS t(id int8)" | tr '|' ' ')" "3 18"
+check "and its virtual-host request path is the key alone, URI-encoded once" \
+ "$([ "$(gets | grep -c '^/dir%20sp/a%2Bb%3Dc.parquet$')" -ge 1 ] && echo yes)" "yes"
# removal proof of the addressing switch: path-style (default) keeps the bucket
: > "$S3_LOG"
check "removal proof: with addressing=path the bucket is back in the path" \
@@ -106,7 +127,11 @@ check "and the request path carried the bucket again" \
pg_restart_env "AWS_ACCESS_KEY_ID='$AKID'" "AWS_SECRET_ACCESS_KEY='$SECRET'" "AWS_REGION='$REGION'"
check "s3:// with no endpoint demands one (names AWS_ENDPOINT_URL)" \
"$([ "$(msg_of "$RD('s3://$BUCKET/vh.parquet') AS t(id int8)" | grep -c 'AWS_ENDPOINT_URL')" -ge 1 ] && echo yes)" "yes"
-check "gs:// with no endpoint uses the storage.googleapis.com default (does not demand AWS_ENDPOINT_URL)" \
- "$([ "$(msg_of "$RD('gs://$BUCKET/vh.parquet') AS t(id int8)" | grep -c 'AWS_ENDPOINT_URL')" -eq 0 ] && echo yes)" "yes"
+# The default is storage.googleapis.com, and this cluster's allow-list holds only
+# s3.local, so the read must be refused by the allow-list naming that host, before any
+# connection. The old check only wanted the message NOT to name AWS_ENDPOINT_URL, which
+# a mistyped default, any other error, or a crash (no message at all) also satisfied.
+check "gs:// with no endpoint goes to storage.googleapis.com (the allow-list refuses that host by name)" \
+ "$(msg_of "$RD('gs://$BUCKET/vh.parquet') AS t(id int8)" | grep -c 'endpoint "storage.googleapis.com:443" is not in pgcolumnar.objstore_allowed_endpoints')" "1"
pgc_summary
diff --git a/test/objstore_http_read.sh b/test/objstore_http_read.sh
index 8540afec9..521870bf0 100755
--- a/test/objstore_http_read.sh
+++ b/test/objstore_http_read.sh
@@ -10,12 +10,17 @@
# arithmetic from the fixture's structure (open = 1 HEAD + 3 ranged GETs,
# data = one GET per needed chunk); unbuffered (dev GUC off) >= 10*K.
# Removal proof: force the GUC off and the buffered check must go red.
-# C failure taxonomy on SQLSTATE: unreachable port and 404 name the URL and
-# produce no rows; a Range-ignoring server is an error, never a silent
-# whole-object read.
+# C failure taxonomy on SQLSTATE AND message: unreachable port and 404 name
+# the URL and produce no rows; a Range-ignoring server is an error, never a
+# silent whole-object read; a short 206 body and EOF mid-body are refused;
+# a keep-alive connection the server dropped is retried once, and a fresh
+# connection the server hangs up on is not. Many guards share 08006 and
+# 08P01, so each refusal must also carry its own guard's message.
# D cancel: statement_timeout fires within bound while the server stalls
# mid-body. Removal proof: with the module's WaitLatchOrSocket wait arm
# deleted, this check hangs or reds.
+# Survival: the server still answers and no backend died across the arms (the
+# server log's "terminated by signal" count, read after waiting for it).
#
# The fixture server (test/objstore_http_server.py) logs one line per request;
# that log is seam B. The suite tears the server down through a chained trap
@@ -87,15 +92,53 @@ psql_run "CREATE SERVER osrv FOREIGN DATA WRAPPER pgcolumnar_parquet;"
psql_run "CREATE FOREIGN TABLE flocal ($COLDEFS) SERVER osrv OPTIONS (path '$PGC_WORKDIR/fixture.parquet');"
psql_run "CREATE FOREIGN TABLE fhttp ($COLDEFS) SERVER osrv OPTIONS (path '$BASE/fixture.parquet');"
-# SQLSTATE of a failing statement (psql VERBOSITY sqlstate prints the bare code).
-sqlstate_of() {
- env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -qtA 2>&1 < what SELECT 1 last returned. A
+# crash restarts the server, so every reading after one waits for it: otherwise
+# the next reading fails on recovery instead of measuring its own guard.
+await_server() {
+ local up=""
+ for _ in $(seq 1 60); do
+ up="$(q 'SELECT 1')"
+ [ "$up" = 1 ] && break
+ sleep 0.5
+ done
+ echo "$up"
+}
+
+# refusal_of SQL PHRASE -> "SQLSTATE: PHRASE" when SQL failed with PHRASE in its
+# primary message, else the SQLSTATE and the message it did give, or what came
+# back instead of an error.
+refusal_of() {
+ local out code err
+ await_server >/dev/null
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA 2>&1 </dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+CRASHES0="$(crashes)"
+
# Requests logged since the last truncation.
reqs() { wc -l < "$HTTP_LOG" | tr -d ' '; }
@@ -159,12 +202,35 @@ psql_run "CREATE FOREIGN TABLE fdead ($COLDEFS) SERVER osrv OPTIONS (path 'http:
psql_run "CREATE FOREIGN TABLE f404 ($COLDEFS) SERVER osrv OPTIONS (path '$BASE/no_such.parquet');"
psql_run "CREATE FOREIGN TABLE fnorange ($COLDEFS) SERVER osrv OPTIONS (path '$BASE/norange/fixture.parquet');"
-check "C: unreachable endpoint SQLSTATE" "$(sqlstate_of "SELECT count(*) FROM fdead")" "08006"
-check "C: http 404 SQLSTATE" "$(sqlstate_of "SELECT count(*) FROM f404")" "58P01"
-check "C: range-ignoring server SQLSTATE" "$(sqlstate_of "SELECT count(*) FROM fnorange")" "08P01"
+psql_run "CREATE FOREIGN TABLE fshort ($COLDEFS) SERVER osrv OPTIONS (path '$BASE/short/fixture.parquet');"
+psql_run "CREATE FOREIGN TABLE ftrunc ($COLDEFS) SERVER osrv OPTIONS (path '$BASE/trunc/fixture.parquet');"
+psql_run "CREATE FOREIGN TABLE fdropka ($COLDEFS) SERVER osrv OPTIONS (path '$BASE/dropka/fixture.parquet');"
+psql_run "CREATE FOREIGN TABLE fhangup ($COLDEFS) SERVER osrv OPTIONS (path '$BASE/hangup/fixture.parquet');"
+
+check "C: unreachable endpoint SQLSTATE" \
+ "$(refusal_of "SELECT count(*) FROM fdead" "could not connect to")" "08006: could not connect to"
+check "C: http 404 SQLSTATE" \
+ "$(refusal_of "SELECT count(*) FROM f404" "does not exist (HTTP 404)")" "58P01: does not exist (HTTP 404)"
+check "C: range-ignoring server SQLSTATE" \
+ "$(refusal_of "SELECT count(*) FROM fnorange" "ignored a Range request")" "08P01: ignored a Range request"
+check "C: a short 206 body is refused" \
+ "$(refusal_of "SELECT count(*) FROM fshort" "requested bytes")" "08P01: requested bytes"
+check "C: EOF mid-body is refused" \
+ "$(refusal_of "SELECT count(*) FROM ftrunc" "closed mid-body")" "08006: closed mid-body"
+# The server closes after every response without saying so, so every request
+# after the first on a connection meets the keep-alive race and must be retried.
+await_server >/dev/null
+check "C: a dropped keep-alive connection is retried (http == local)" \
+ "$(pgc_set_hash "SELECT c2, c5 FROM fdropka")" "$(pgc_set_hash "SELECT c2, c5 FROM flocal")"
+: > "$HTTP_LOG"
+check "C: a server that hangs up on a fresh connection is refused" \
+ "$(refusal_of "SELECT count(*) FROM fhangup" "failed before a response")" "08006: failed before a response"
+check_num "C: and that fresh connection is not retried (one request logged)" \
+ "$(wc -l < "$HTTP_LOG" | tr -d ' ')" "1"
# ---- arm D: cancel while the server stalls mid-body ------------------------
psql_run "CREATE FOREIGN TABLE fstall ($COLDEFS) SERVER osrv OPTIONS (path '$BASE/stall/fixture.parquet');"
+await_server >/dev/null
D_START=$(date +%s)
D_STATE="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
-d "$PGC_DB" -qtA 2>&1 </dev/null && echo 1 || echo 0)" "1"
+# ---- survival ----------------------------------------------------------------
+# A crash is restarted from, so a bare SELECT 1 alone passes once recovery ends:
+# wait for the server, then require the crash count unmoved since the start.
+UP="$(await_server)"
+CRASHES1="$(crashes)"
+check "survival: the server answers and no backend crashed" \
+ "$(if ! [[ "$CRASHES0" =~ ^[0-9]+$ && "$CRASHES1" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"
+ elif [ "$UP" = 1 ] && [ "$CRASHES1" = "$CRASHES0" ]; then echo 1
+ else echo "no ([$UP], $(( CRASHES1 - CRASHES0 )) backend crashes)"; fi)" "1"
+
pgc_summary
diff --git a/test/objstore_http_server.py b/test/objstore_http_server.py
index 0a23be1a2..d1901d0b7 100755
--- a/test/objstore_http_server.py
+++ b/test/objstore_http_server.py
@@ -13,6 +13,17 @@
# /stall/ answer the first ranged GET's headers, send half the body,
# then sleep: the cancel arm proves statement_timeout gets the
# backend back while the transfer is wedged.
+# /short/ answer each ranged GET with an honest 206 one byte SHORT of
+# the range asked for (Content-Range and Content-Length agree):
+# the client must refuse the short body, never pad or use it.
+# /trunc/ answer each ranged GET's headers with the full length, send
+# half the body, then close: EOF mid-body is an error.
+# /dropka/ serve normally, but close the connection after every
+# response WITHOUT a Connection: close header: the client's
+# next request on it fails before a status line, and must be
+# retried once on a fresh connection (the keep-alive race).
+# /hangup/ close every connection without answering at all: on a fresh
+# connection that is the server's answer, not a race to retry.
#
# SigV4 verification mode (#393 M2): with --sigv4-key/--sigv4-secret/
# --sigv4-region, every request must carry a valid AWS Signature Version 4
@@ -34,6 +45,7 @@
import re
import ssl
import sys
+import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -114,7 +126,7 @@ def _resolve(self):
# query never names a file.
path = urllib.parse.unquote(self.path.partition("?")[0])
mode = "normal"
- for prefix in ("/norange/", "/stall/"):
+ for prefix in ("/norange/", "/stall/", "/short/", "/trunc/", "/dropka/", "/hangup/"):
if path.startswith(prefix):
mode = prefix.strip("/")
path = "/" + path[len(prefix):]
@@ -206,9 +218,15 @@ def do_HEAD(self):
self._log_line()
if not self._sigv4_check():
return
- size = self._head_common(self._resolve()[1])
+ mode, local = self._resolve()
+ if mode == "hangup":
+ self.close_connection = True
+ return
+ size = self._head_common(local)
if size < 0:
return
+ if mode == "dropka":
+ self.close_connection = True
self.send_response(200)
self.send_header("Content-Length", str(size))
self.send_header("Accept-Ranges", "bytes")
@@ -222,6 +240,18 @@ def _do_list_v2(self, query):
# Fuzz hook: if /__listing_override__ exists, its raw bytes are
# returned verbatim as the listing body, so a harness can feed the C
# parser arbitrary (malformed) XML and check the backend survives.
+ #
+ # Status hook: if /__listing_status__ exists, its content is an
+ # HTTP status every listing is answered with, an empty body, so a harness
+ # can prove a failed LIST is refused rather than read as an empty prefix.
+ status = os.path.join(self.server.rootdir, "__listing_status__")
+ if os.path.exists(status):
+ with open(status) as f:
+ code = int(f.read().strip())
+ self.send_response(code)
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+ return
override = os.path.join(self.server.rootdir, "__listing_override__")
if os.path.exists(override):
with open(override, "rb") as f:
@@ -285,9 +315,14 @@ def do_GET(self):
self._do_list_v2(_query)
return
mode, local = self._resolve()
+ if mode == "hangup":
+ self.close_connection = True
+ return
size = self._head_common(local)
if size < 0:
return
+ if mode == "dropka":
+ self.close_connection = True
rng = self.headers.get("Range")
m = RANGE_RE.match(rng.replace(" ", "")) if rng else None
@@ -313,6 +348,8 @@ def do_GET(self):
self.end_headers()
return
+ if mode == "short" and hi > lo:
+ hi -= 1
n = hi - lo + 1
with open(local, "rb") as f:
f.seek(lo)
@@ -321,6 +358,11 @@ def do_GET(self):
self.send_header("Content-Range", "bytes %d-%d/%d" % (lo, hi, size))
self.send_header("Content-Length", str(n))
self.end_headers()
+ if mode == "trunc":
+ self.wfile.write(body[: n // 2])
+ self.wfile.flush()
+ self.close_connection = True
+ return
if mode == "stall":
self.wfile.write(body[: n // 2])
self.wfile.flush()
@@ -330,6 +372,15 @@ def do_GET(self):
# ---- write side (#394): plain PUT, and a faithful multipart emulation --
+ def _open_uploads(self):
+ # /__open_uploads__ lists the key of every upload created and
+ # neither completed nor aborted, one per line, rewritten on each change:
+ # the billable orphans a real store would keep. Call with uploads_lock.
+ root = self.server.rootdir
+ with open(os.path.join(root, "__open_uploads__"), "w") as f:
+ for up in self.server.uploads.values():
+ f.write(os.path.relpath(up["local"], root) + "\n")
+
def _qdict(self):
q = {}
for part in self.path.partition("?")[2].split("&"):
@@ -349,6 +400,17 @@ def do_PUT(self):
if up is None or up["local"] != local:
self._deny(404, "NoSuchUpload")
return
+ # Part-failure hook: if /__part_status__ exists, it holds
+ # "STATUS FROM": every part numbered FROM or above is answered with
+ # STATUS and not stored, so a harness can prove a failed part fails
+ # the export rather than completing a short object.
+ pstat = os.path.join(self.server.rootdir, "__part_status__")
+ if os.path.exists(pstat):
+ with open(pstat) as f:
+ code, first = (int(x) for x in f.read().split())
+ if int(q["partNumber"]) >= first:
+ self._deny(code, "InternalError")
+ return
up["parts"][int(q["partNumber"])] = self._body
etag = hashlib.md5(self._body).hexdigest()
self.send_response(200)
@@ -375,8 +437,11 @@ def do_POST(self):
# in the canonical query or its signature diverges. With this id the
# multipart round trip passes ONLY if that encoding is correct
# (#394 review turned from "unproven" to pinned).
- uid = "up/%d+x" % (len(self.server.uploads) + 1)
- self.server.uploads[uid] = {"local": local, "parts": {}}
+ with self.server.uploads_lock:
+ self.server.upload_seq += 1
+ uid = "up/%d+x" % self.server.upload_seq
+ self.server.uploads[uid] = {"local": local, "parts": {}}
+ self._open_uploads()
body = (""
"%s "
" " % uid).encode()
@@ -386,13 +451,32 @@ def do_POST(self):
self.wfile.write(body)
return
if "uploadId" in q:
- up = self.server.uploads.pop(q["uploadId"], None)
- if up is None or up["local"] != local:
- self._deny(404, "NoSuchUpload")
- return
+ # As S3 does, the object is the parts the request LISTS, in order,
+ # each checked against its stored ETag; an unlisted part is
+ # dropped, and a listed part that was never stored fails the
+ # complete and leaves the upload open (abortable).
+ listed = [(int(n), e.replace(b""", b"").strip(b'"')) for n, e in re.findall(
+ rb"\s*(\d+)\s* \s*([^<]*) ",
+ self._body)]
+ with self.server.uploads_lock:
+ up = self.server.uploads.get(q["uploadId"])
+ if up is None or up["local"] != local:
+ self._deny(404, "NoSuchUpload")
+ return
+ nums = [n for n, _ in listed]
+ if not listed or nums != sorted(set(nums)):
+ self._deny(400, "InvalidPartOrder" if listed else "MalformedXML")
+ return
+ for n, e in listed:
+ if n not in up["parts"] or (e and e.decode() !=
+ hashlib.md5(up["parts"][n]).hexdigest()):
+ self._deny(400, "InvalidPart")
+ return
+ del self.server.uploads[q["uploadId"]]
+ self._open_uploads()
os.makedirs(os.path.dirname(local), exist_ok=True)
with open(local, "wb") as f:
- for n in sorted(up["parts"]):
+ for n, _ in listed:
f.write(up["parts"][n])
body = b" "
self.send_response(200)
@@ -409,7 +493,10 @@ def do_DELETE(self):
q = self._qdict()
_, local = self._resolve()
if "uploadId" in q:
- up = self.server.uploads.pop(q["uploadId"], None)
+ with self.server.uploads_lock:
+ up = self.server.uploads.pop(q["uploadId"], None)
+ if up is not None:
+ self._open_uploads()
if up is None:
self._deny(404, "NoSuchUpload")
return
@@ -454,6 +541,8 @@ def main():
srv.vhost_base = args.virtual_host_base
srv.list_page_size = args.list_page_size
srv.uploads = {}
+ srv.uploads_lock = threading.Lock()
+ srv.upload_seq = 0
open(args.log, "a").close()
# Readiness marker for the suite: print once the socket is bound.
print("READY", flush=True)
diff --git a/test/objstore_listing.sh b/test/objstore_listing.sh
index 4db6af3a5..033089e65 100755
--- a/test/objstore_listing.sh
+++ b/test/objstore_listing.sh
@@ -43,14 +43,48 @@ pg_restart_env() {
done
echo "FATAL: cluster did not come back after env restart"; exit 1
}
-sqlstate_of() {
- env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -qtA 2>&1 < "" when SQL raised with PHRASE in its
+# message, the bare SQLSTATE when the message lacks it, or what happened instead. The
+# two non-progress refusals share 08P01: with the no-keys check removed, the replayed
+# page reached the token check and the bare SQLSTATE still passed.
+refusal_of() {
+ local out state
+ wait_up
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA 2>&1 </dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since
+ local _up _now
+ wait_up
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"
+ elif [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1
+ else echo "no ([$_up], $(( _now - $1 )) backend crashes)"; fi
}
-listn() { grep -cE 'list-type=2' "$S3_LOG" | tr -d ' '; }
# ---- fixtures on disk: a prefix of parts, a nested part, hidden + non-parquet,
# and a Hive-partitioned layout ------------------------------------------
@@ -89,6 +123,7 @@ check "premise: fixture up" "$(grep -c READY "$PGC_WORKDIR/s3_server.out" 2>/dev
pg_restart_env "AWS_ENDPOINT_URL='http://127.0.0.1:$S3_PORT'" \
"AWS_ACCESS_KEY_ID='$AKID'" "AWS_SECRET_ACCESS_KEY='$SECRET'" "AWS_REGION='$REGION'"
+CRASHES_BEFORE="$(crashes)"
psql_run "CREATE SERVER s3srv FOREIGN DATA WRAPPER pgcolumnar_parquet;"
@@ -118,6 +153,9 @@ check "glob s3://.../lst/*.parquet matches the 3 top-level parts, not sub/" \
psql_run "CREATE FOREIGN TABLE fexact (id int8) SERVER s3srv OPTIONS (path 's3://$BUCKET/lst/part-0000.parquet');"
check "exact-key read returns its rows" "$(q "SELECT count(*), sum(id) FROM fexact" | tr '|' ' ')" "3 3"
check "an exact-key read issues no ListObjectsV2" "$(listn)" "0"
+# the 0 above reads the same log: it must hold the exact key's own request
+check "and the request log recorded the exact-key read" \
+ "$(grep -qE '^(GET|HEAD) /'"$BUCKET"'/lst/part-0000\.parquet ' "$S3_LOG" && echo logged || echo "not logged")" "logged"
# ---- Hive partition columns over a remote prefix ----------------------------
psql_run "CREATE FOREIGN TABLE fhive (id int8, dt date) SERVER s3srv
@@ -133,28 +171,43 @@ check "a partition predicate prunes a remote file (Files Pruned = 1)" \
# ---- a non-advancing continuation token is refused, not looped forever ------
# A broken or hostile endpoint that replays one truncated page with the same
# token would spin the paging loop toward the page cap. The client detects the
-# token that does not advance and errors (08P01) instead. Removal proof: without
-# that check the read loops until statement_timeout (57014), not 08P01.
+# token that does not advance and errors (08P01) instead, by that check's own
+# message. Removal proof: without that check the read loops until
+# statement_timeout (57014), not 08P01.
cat > "$PGC_WORKDIR/__listing_override__" <<'XML'
true STUCK lst/part-0000.parquet
XML
check "a non-advancing continuation token is refused, not an infinite paging loop" \
- "$(sqlstate_of "SET statement_timeout='10s'; SELECT count(*) FROM pgcolumnar.read_parquet('s3://$BUCKET/lst/') AS t(id int8)")" "08P01"
+ "$(refusal_of "SET statement_timeout='10s'; SELECT count(*) FROM pgcolumnar.read_parquet('s3://$BUCKET/lst/') AS t(id int8)" \
+ "returned a non-advancing continuation token" " by the token check")" "08P01 by the token check"
rm -f "$PGC_WORKDIR/__listing_override__"
# A truncated page that carries NO keys is the other non-progress shape: an
# endpoint that varies its token each page (which the token check cannot catch)
# but never returns a key would otherwise page to the OS_LIST_MAX_PAGES backstop.
# ListObjectsV2 always returns a key on a truncated page, so an empty truncated
-# page is refused on the first page (08P01). Removal proof: without the progress
-# check this pages until statement_timeout (57014).
+# page is refused on the first page (08P01), by the progress check's own message:
+# this page replays its token, so without the progress check the token check
+# refuses it on the second page with the same SQLSTATE.
cat > "$PGC_WORKDIR/__listing_override__" <<'XML'
true next-1
XML
check "a truncated page with no keys is refused, not looped" \
- "$(sqlstate_of "SET statement_timeout='10s'; SELECT count(*) FROM pgcolumnar.read_parquet('s3://$BUCKET/lst/') AS t(id int8)")" "08P01"
+ "$(refusal_of "SET statement_timeout='10s'; SELECT count(*) FROM pgcolumnar.read_parquet('s3://$BUCKET/lst/') AS t(id int8)" \
+ "returned a truncated page with no keys" " by the progress check")" "08P01 by the progress check"
rm -f "$PGC_WORKDIR/__listing_override__"
+# ---- a LIST that fails is refused, never read as an empty prefix -------------
+# An empty listing is a legal answer (a prefix with nothing under it), so a client
+# that took a failed LIST for one would read zero rows from a prefix it never saw.
+echo 500 > "$PGC_WORKDIR/__listing_status__"
+check "a LIST that returns HTTP 500 is refused, not read as an empty prefix" \
+ "$(refusal_of "SET statement_timeout='10s'; SELECT count(*) FROM pgcolumnar.read_parquet('s3://$BUCKET/lst/') AS t(id int8)" \
+ "returned HTTP 500" " by the HTTP status check")" "08006 by the HTTP status check"
+rm -f "$PGC_WORKDIR/__listing_status__"
+
+check "backend still up after the listing refusals" "$(survived "$CRASHES_BEFORE")" "1"
+
# ---- optional: a real S3 (Garage), same as the sibling suites ---------------
if [ -n "${PGC_S3_INTEGRATION_ENDPOINT:-}" ]; then
:
diff --git a/test/objstore_module.sh b/test/objstore_module.sh
index a54984b87..8e3a4ecb6 100755
--- a/test/objstore_module.sh
+++ b/test/objstore_module.sh
@@ -16,13 +16,18 @@
# Written fresh for pgColumnar.
set -uo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+# The remote reads below are refused for want of an endpoint and credentials, and
+# each check names the refusal it wants. The postmaster inherits this environment,
+# so an ambient AWS_* would turn those refusals into connection attempts: clear them.
+unset AWS_ENDPOINT_URL AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN \
+ AWS_REGION AWS_DEFAULT_REGION
pgc_setup "${1:-/usr/local/pg17/bin/pg_config}"
# nm reads the symbol tables the separation checks rest on; stat sizes the
# stand-in module. Without this, a missing nm makes "the symbol is absent from
# the main library" true for the wrong reason, which is the whole failure this
# suite exists to catch, turned on itself.
-pgc_require_tools nm stat || { pgc_summary; exit 1; }
+pgc_require_tools nm stat ldd || { pgc_summary; exit 1; }
LIBDIR="$("$PGC_PG_CONFIG" --pkglibdir 2>/dev/null || echo "")"
[ -n "$LIBDIR" ] || LIBDIR="$(pgc_pg "pg_config --pkglibdir" | tail -1 | tr -d '\r')"
@@ -70,8 +75,9 @@ for stash in "$MOD.away" "$MOD.probe"; do
# every check below would run against a broken installation and report the
# confusing half of the truth, so stop here and say which file to look at.
if ! stash_is_debris; then
- pgc_fail "restored $stash to $MOD, but that is not a module either" \
- "this installation needs 'make install' before the suite can run"
+ # A literal name, so the twin can carry it: the paths go in the detail.
+ pgc_fail "a stash restored over a missing module is a module" \
+ "restored $stash to $MOD, but that is not a module either; this installation needs 'make install' before the suite can run"
pgc_summary
exit 1
fi
@@ -100,15 +106,39 @@ check_num "the module's entry point is NOT inside the preloaded library" \
check_num "positive control: it IS defined in the module, so nm really looked" \
"$(pgc_pg "nm -D --defined-only '$MOD' | grep -c ' T pgcolumnar_objstore_init'" | tail -1)" "1"
+# A remote path's refusal, verbose: "ERROR: SQLSTATE: message" on its first line.
+refusal_of() {
+ env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -At -q -c '\set VERBOSITY verbose' -c "$1" 2>&1
+}
+
+# What each URL must be refused with, SQLSTATE and message. The checks used to take
+# any of seven phrases, and "is not supported" is one of them: with the module
+# dropping s3:// or gs:// from the schemes it handles, each read said so and main
+# passed 30 of 30 (#1341). An https:// read reaches the allow-list only when the
+# module was built with OpenSSL; without it the scheme is unhandled, by design.
+if [ "$(pgc_pg "ldd '$MOD'" 2>/dev/null | grep -c libssl)" -ge 1 ]; then
+ HTTPS_REFUSAL='42501: columnar: endpoint "host:443" is not in pgcolumnar.objstore_allowed_endpoints'
+else
+ HTTPS_REFUSAL='0A000: columnar: reading "https://host/key.parquet" is not supported'
+fi
+refusal_for() {
+ case "$1" in
+ s3://*) echo "28000: columnar: reading \"$1\" requires AWS_ENDPOINT_URL in the server environment" ;;
+ gs://*) echo "28000: columnar: reading \"$1\" requires AWS_ACCESS_KEY_ID in the server environment" ;;
+ https://*) echo "$HTTPS_REFUSAL" ;;
+ esac
+}
+
# A remote path must report a remote error, from the reader, without a connection.
for url in "s3://bucket/key.parquet" "gs://bucket/key.parquet" "https://host/key.parquet"; do
# The scheme, by parameter expansion rather than a `cut` subshell, because BOTH
# names need it now: the headline already carried it and the continuation did not,
# which is why three iterations produced one ledger key (#982).
scheme="${url%%:*}"
- out=$(psql_run "SELECT * FROM pgcolumnar.read_parquet('$url') AS (a int)" 2>&1)
+ out=$(refusal_of "SELECT * FROM pgcolumnar.read_parquet('$url') AS (a int)")
check "a $scheme URL reports an object-storage error, not a missing file" \
- "$([ "$(grep -c 'object storage is not implemented\|is not supported\|requires the object-store module\|requires AWS_\|could not resolve\|could not connect\|objstore_allowed_endpoints' <<<"$out")" -ge 1 ] && echo yes || echo no)" "yes"
+ "$(head -1 <<<"$out")" "ERROR: $(refusal_for "$url")"
check_num "and the $scheme URL is NOT reported as a missing file" \
"$(grep -c 'No such file or directory' <<<"$out")" "0"
done
@@ -128,16 +158,24 @@ for pat in "s3://bucket/a*.parquet" "s3://bucket/a?.parquet" "s3://bucket/a[0-9]
# either, so three iterations produced one key for each of two checks (#982).
meta="${pat#s3://bucket/a}"
meta="${meta%.parquet}"
- out=$(psql_run "SELECT * FROM pgcolumnar.read_parquet('$pat') AS (a int)" 2>&1)
+ out=$(refusal_of "SELECT * FROM pgcolumnar.read_parquet('$pat') AS (a int)")
+ # The listing is refused for want of an endpoint, as a read is; the URL it names
+ # is the listing's, not the pattern, so only the SQLSTATE and the cause are pinned.
check "a remote glob ($meta) is handled remotely (an object-storage error, not a local one)" \
- "$([ "$(grep -c 'requires AWS_\|object storage\|objstore_allowed_endpoints\|requires the object-store module\|could not resolve\|could not connect\|is not in pgcolumnar' <<<"$out")" -ge 1 ] && echo yes || echo no)" "yes"
+ "$(head -1 <<<"$out" | sed -n 's/^ERROR: \(28000\): columnar: reading "s3:[^"]*" \(requires AWS_ENDPOINT_URL\) .*/\1 \2/p')" \
+ "28000 requires AWS_ENDPOINT_URL"
check_num "and the $meta glob is NOT reported as a local filesystem miss" \
"$(grep -c 'no files match pattern\|matched no regular files\|No such file or directory' <<<"$out")" "0"
done
# A local path must be entirely unaffected, which is the regression this could cause.
-psql_run "CREATE TABLE lp (a int) USING pgcolumnar; INSERT INTO lp VALUES (1),(2),(3);" >/dev/null 2>&1
-check_num "a local path still works" "$(q 'SELECT count(*) FROM lp')" "3"
+# Read THROUGH read_parquet, the path the remote dispatch sits in front of. This used to
+# count a columnar table, which never reaches the dispatch: with every local
+# read_parquet refused as a missing file, main passed 30 of 30 (#1341).
+psql_run "CREATE TABLE lp (a int) USING pgcolumnar; INSERT INTO lp VALUES (1),(2),(3);
+ SELECT pgcolumnar.export_parquet('lp', '$PGC_WORKDIR/lp.parquet');" >/dev/null 2>&1
+check_num "a local path still works" \
+ "$(q "SELECT count(*) FROM pgcolumnar.read_parquet('$PGC_WORKDIR/lp.parquet') AS (a int) WHERE a IN (1, 2, 3)")" "3"
# A RELATIVE path, which is what the label says. The previous version of this
# passed an absolute one, so the case it named was never exercised.
check_num "a relative path is not mistaken for a URL" \
diff --git a/test/objstore_s3_read.sh b/test/objstore_s3_read.sh
index 0228bce08..f4c20ed41 100755
--- a/test/objstore_s3_read.sh
+++ b/test/objstore_s3_read.sh
@@ -11,7 +11,9 @@
#
# config an s3:// read with no AWS_* in the postmaster environment errors
# 28000 naming AWS_ENDPOINT_URL, before any connection exists to
-# fail differently.
+# fail differently. A missing region and a missing secret are each
+# refused naming what is missing.
+# region AWS_REGION outranks AWS_DEFAULT_REGION, which is the fallback.
# A differential oracle vs the byte-identical local file, including a
# key that needs percent-encoding (space in both directory and
# file name): an encoding disagreement cannot pass the verifier.
@@ -22,11 +24,19 @@
# arms prove token signing rather than merely tolerating it.
# 403 a tamper bucket the server verifies against a different secret:
# correct client signature, always refused; must surface 28000 and
-# never rows. 404 on the real bucket stays 58P01, and its
+# never rows, naming the HTTP 403. 404 on the real bucket stays 58P01, and its
# reachability doubles as proof requests pass verification first.
# https an https:// endpoint is refused 0A000 (TLS is M3), never
# silently downgraded.
#
+# A REFUSAL IS NOT ANY ERROR (#1341): five guards here share 28000 -- no
+# endpoint, no region, no secret, a 403, and a missing token -- so each refusal
+# must name its own cause. With s3:// falling back to a default host when
+# AWS_ENDPOINT_URL is unset, the no-environment read was refused by the REGION
+# guard instead, and a bare 28000 passed it. A refusal's ERROR reaches the
+# client before abort cleanup runs, so a backend killed in that cleanup passed
+# every SQLSTATE check: survival reads the server log's crash count too.
+#
# The postmaster environment is the ambient credential source (the decided
# default), so the suite restarts its own cluster between phases instead of
# pretending env can change per-session.
@@ -62,6 +72,7 @@ trap objstore_s3_teardown EXIT INT TERM
# Restart OUR cluster with the given environment strings in the postmaster.
pg_restart_env() {
+ wait_up # a backend that crashed in the last reading: let recovery finish first
pgc_pg "pg_ctl -D '$PGC_PGDATA' stop -m fast -w" >/dev/null 2>&1
pgc_pg "$* pg_ctl -D '$PGC_PGDATA' -l '$PGC_LOGFILE' start -w" >/dev/null 2>&1
for _ in $(seq 1 30); do
@@ -82,6 +93,45 @@ SQLEOF
reqs() { wc -l < "$S3_LOG" | tr -d ' '; }
+# wait_up: wait up to 15 s for the server, so a backend that crashed in one reading
+# fails that reading and survival, not every reading after it during recovery.
+wait_up() { for _ in $(seq 1 30); do [ -n "$(q 'SELECT 1')" ] && return 0; sleep 0.5; done; }
+
+# refusal_of SQL PHRASE LABEL -> "" when SQL raised with PHRASE in its
+# message, the bare SQLSTATE when the message lacks it, or what happened instead.
+refusal_of() {
+ local out state
+ wait_up
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA 2>&1 </dev/null)"
+ case "$_n" in '' | *[!0-9]*) ;; *) echo "$_n" ;; esac
+}
+survived() { # survived BEFORE -> "1" when the server answers and no backend died since
+ local _up _now
+ wait_up
+ _up="$(q 'SELECT 1')"; _now="$(crashes)"
+ if ! [[ "$1" =~ ^[0-9]+$ && "$_now" =~ ^[0-9]+$ ]]; then echo "no (crash count unreadable)"
+ elif [ "$_up" = 1 ] && [ "$_now" = "$1" ]; then echo 1
+ else echo "no ([$_up], $(( _now - $1 )) backend crashes)"; fi
+}
+
# ---- fixture ----------------------------------------------------------------
G=3
NCOLS=8
@@ -127,23 +177,35 @@ psql_run "CREATE FOREIGN TABLE fs3tamper ($COLDEFS) SERVER s3srv OPTIONS (path '
psql_run "CREATE FOREIGN TABLE fs3miss ($COLDEFS) SERVER s3srv OPTIONS (path 's3://$BUCKET/no_such.parquet');"
# ---- phase 1: no AWS environment at all ------------------------------------
+CRASHES_BEFORE="$(crashes)"
check "config: s3 read with no environment is 28000" \
- "$(sqlstate_of "SELECT count(*) FROM fs3")" "28000"
+ "$(refusal_of "SELECT count(*) FROM fs3" \
+ "requires AWS_ENDPOINT_URL in the server environment" " naming AWS_ENDPOINT_URL")" \
+ "28000 naming AWS_ENDPOINT_URL"
# ---- phase 2: full ambient environment, fixture endpoint --------------------
+# AWS_DEFAULT_REGION names a region the fixture refuses: AWS_REGION must win.
pg_restart_env "AWS_ENDPOINT_URL='http://127.0.0.1:$S3_PORT'" \
"AWS_ACCESS_KEY_ID='$AKID'" "AWS_SECRET_ACCESS_KEY='$SECRET'" \
- "AWS_REGION='$REGION'" "AWS_SESSION_TOKEN='$TOKEN'"
+ "AWS_REGION='$REGION'" "AWS_DEFAULT_REGION='pgc-wrong-1'" \
+ "AWS_SESSION_TOKEN='$TOKEN'"
+check_num "region: AWS_REGION outranks AWS_DEFAULT_REGION" \
+ "$(q "SELECT count(*) FROM fs3")" "$((G * ROWS_PER_GROUP))"
+wait_up
check "A: full scan s3 == local" \
"$(pgc_set_hash "SELECT * FROM fs3")" "$(pgc_set_hash "SELECT * FROM flocal")"
+wait_up
check "A: projection s3 == local" \
"$(pgc_set_hash "SELECT c1, c6 FROM fs3")" "$(pgc_set_hash "SELECT c1, c6 FROM flocal")"
+wait_up
check "A: percent-encoded key s3 == local" \
"$(pgc_set_hash "SELECT * FROM fs3enc")" "$(pgc_set_hash "SELECT * FROM flocal")"
+wait_up
check_num "A: count(*) over s3" "$(q "SELECT count(*) FROM fs3")" "$((G * ROWS_PER_GROUP))"
K_FULL=$((4 + G * NCOLS))
+wait_up
: > "$S3_LOG"
q "SELECT $SUM_ALL FROM fs3" >/dev/null
FULL_SIGNED="$(reqs)"
@@ -151,10 +213,35 @@ check "premise: signed run logged requests" \
"$([ "${FULL_SIGNED:-0}" -gt 0 ] 2>/dev/null && echo yes)" "yes"
check_num "B: signing left the request arithmetic unchanged" "$FULL_SIGNED" "$K_FULL"
+# The refusal must name the HTTP 403, not be another guard's 28000. (The open's HEAD
+# carries no body, so the server's never reaches the message.)
check "403: tamper bucket surfaces 28000" \
- "$(sqlstate_of "SELECT count(*) FROM fs3tamper")" "28000"
+ "$(refusal_of "SELECT count(*) FROM fs3tamper" \
+ "was denied (HTTP 403" " as a 403")" "28000 as a 403"
check "403 ordering premise: verified requests still reach 404 = 58P01" \
- "$(sqlstate_of "SELECT count(*) FROM fs3miss")" "58P01"
+ "$(refusal_of "SELECT count(*) FROM fs3miss" "does not exist (HTTP 404)" " as a 404")" \
+ "58P01 as a 404"
+
+# ---- phase 2b: AWS_DEFAULT_REGION alone is the fallback ---------------------
+pg_restart_env "AWS_ENDPOINT_URL='http://127.0.0.1:$S3_PORT'" \
+ "AWS_ACCESS_KEY_ID='$AKID'" "AWS_SECRET_ACCESS_KEY='$SECRET'" \
+ "AWS_DEFAULT_REGION='$REGION'" "AWS_SESSION_TOKEN='$TOKEN'"
+check_num "region: AWS_DEFAULT_REGION is the fallback" \
+ "$(q "SELECT count(*) FROM fs3")" "$((G * ROWS_PER_GROUP))"
+
+# ---- phase 2c: no region at all, and no secret ------------------------------
+pg_restart_env "AWS_ENDPOINT_URL='http://127.0.0.1:$S3_PORT'" \
+ "AWS_ACCESS_KEY_ID='$AKID'" "AWS_SECRET_ACCESS_KEY='$SECRET'" \
+ "AWS_SESSION_TOKEN='$TOKEN'"
+check "config: s3 read with no region is refused naming it" \
+ "$(refusal_of "SELECT count(*) FROM fs3" "requires a region option" " naming the region")" \
+ "28000 naming the region"
+pg_restart_env "AWS_ENDPOINT_URL='http://127.0.0.1:$S3_PORT'" \
+ "AWS_ACCESS_KEY_ID='$AKID'" "AWS_REGION='$REGION'" "AWS_SESSION_TOKEN='$TOKEN'"
+check "config: s3 read with no AWS_SECRET_ACCESS_KEY is refused naming it" \
+ "$(refusal_of "SELECT count(*) FROM fs3" \
+ "requires AWS_SECRET_ACCESS_KEY in the server environment" " naming AWS_SECRET_ACCESS_KEY")" \
+ "28000 naming AWS_SECRET_ACCESS_KEY"
# ---- phase 3: an https endpoint is never silently downgraded ---------------
# The property is no-downgrade; its SQLSTATE depends on the build. A module
@@ -173,8 +260,11 @@ if [ "$(grep -c libssl <<<"$_ldd_out" || true)" -ne 0 ]; then
else
HTTPS_WANT="0A000"
fi
-check "https endpoint is not downgraded (want $HTTPS_WANT for this build)" \
- "$(sqlstate_of "SELECT count(*) FROM fs3")" "$HTTPS_WANT"
+# The name is literal so the grader can read it; the build's want is in the reading.
+wait_up
+check "https endpoint is not downgraded" \
+ "$(sqlstate_of "SELECT count(*) FROM fs3") for this build" "$HTTPS_WANT for this build"
+check "backend still up after the s3 refusals" "$(survived "$CRASHES_BEFORE")" "1"
# ---- phase 4: optional integration against a real S3 implementation --------
if [ -n "${PGC_S3_INTEGRATION_ENDPOINT:-}" ]; then
diff --git a/test/objstore_sink_write.sh b/test/objstore_sink_write.sh
index 99f69f68f..654222756 100755
--- a/test/objstore_sink_write.sh
+++ b/test/objstore_sink_write.sh
@@ -44,14 +44,35 @@ pg_restart_env() {
for _ in $(seq 1 30); do [ -n "$(q 'SELECT 1')" ] && return 0; sleep 0.5; done
echo "FATAL: cluster did not come back"; exit 1
}
-sqlstate_of() {
- env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
- -d "$PGC_DB" -qtA 2>&1 < "" when SQL raised with PHRASE in its
+# message, the bare SQLSTATE when the message lacks it, or what happened instead. The
+# part and complete failures share 58030, so each refusal names its own guard.
+refusal_of() {
+ local out state
+ wait_up
+ out="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \
+ -d "$PGC_DB" -qtA 2>&1 < how many uploads the server still holds open (created,
+# neither completed nor aborted) for keys matching it: the billable orphans. The
+# DELETE count alone passed an abort that named the wrong upload (404).
+open_uploads() {
+ if [ -f "$PGC_WORKDIR/__open_uploads__" ]; then grep -cE "$1" "$PGC_WORKDIR/__open_uploads__" | tr -d ' '
+ else echo 0; fi
+}
mkdir -p "$PGC_WORKDIR/$BUCKET"
python3 "$(dirname "${BASH_SOURCE[0]}")/objstore_http_server.py" \
@@ -93,11 +114,16 @@ check_num "small export to s3:// succeeds" \
check_num "small object went as ONE plain PUT (no multipart)" \
"$(logn '^PUT /pgc-bucket/small.parquet -')" "1"
psql_run "CREATE FOREIGN TABLE f_small (id int, t text) SERVER wsrv OPTIONS (path 's3://$BUCKET/small.parquet');"
+# The oracles are built from the generators, not read back through the columnar scan
+# the export itself reads: a scan that lost or bent rows would agree with itself.
+GEN_SMALL="SELECT g, 'row-'||g FROM generate_series(1,1000) g"
+GEN_BIG="SELECT g::int8, md5(g::text)||md5((g+1)::text) FROM generate_series(1,400000) g"
+GEN_PAR="SELECT g::int8, (g % 100)::int FROM generate_series(1,80000) g"
check "small round trip: s3 read-back == source" \
- "$(pgc_set_hash "SELECT * FROM f_small")" "$(pgc_set_hash "SELECT * FROM es_small")"
+ "$(pgc_set_hash "SELECT * FROM f_small")" "$(pgc_set_hash "$GEN_SMALL")"
: > "$S3_LOG"
-BIGROWS="$(q "SELECT count(*) FROM es_big")"
+BIGROWS=400000
check_num "large export to s3:// succeeds" \
"$(qset "SELECT pgcolumnar.export_parquet('es_big', 's3://$BUCKET/big.parquet')")" "$BIGROWS"
check "premise: the large export took the multipart protocol" \
@@ -106,14 +132,15 @@ check_num "and completed it exactly once (POST with uploadId, no partNumber)" \
"$(logn '^POST /pgc-bucket/big.parquet uploadId=')" "1"
psql_run "CREATE FOREIGN TABLE f_big (id int8, pad text) SERVER wsrv OPTIONS (path 's3://$BUCKET/big.parquet');"
check "large round trip: s3 read-back == source" \
- "$(pgc_set_hash "SELECT * FROM f_big")" "$(pgc_set_hash "SELECT * FROM es_big")"
+ "$(pgc_set_hash "SELECT * FROM f_big")" "$(pgc_set_hash "$GEN_BIG")"
# ---- injection: abort mid-part leaves NOTHING -------------------------------
: > "$S3_LOG"
# fail_after well above one part so the fault lands mid-multipart, after at
# least one part and the CreateMultipartUpload.
check "inject: mid-upload failure surfaces as disk-full (53100)" \
- "$(sqlstate_of "$PART SET pgcolumnar.sink_fail_after = 900000; SELECT pgcolumnar.export_parquet('es_big', 's3://$BUCKET/fail.parquet')")" "53100"
+ "$(refusal_of "$PART SET pgcolumnar.sink_fail_after = 900000; SELECT pgcolumnar.export_parquet('es_big', 's3://$BUCKET/fail.parquet')" \
+ "export write failed (injected fault)" " by the injected fault")" "53100 by the injected fault"
check "inject: the key does not exist" \
"$([ ! -e "$PGC_WORKDIR/$BUCKET/fail.parquet" ] && echo clean)" "clean"
# the money check: every multipart upload STARTED for this key was ABORTED, so
@@ -125,12 +152,37 @@ check "premise: the fault landed mid-multipart (an upload was started)" \
"$([ "${STARTED:-0}" -ge 1 ] && echo yes)" "yes"
check_num "inject: every upload started was aborted (no billable orphan)" \
"$ABORTED" "$STARTED"
+check_num "inject: the server holds no open upload for the key" \
+ "$(open_uploads '^pgc-bucket/fail\.parquet$')" "0"
+
+# ---- a part the server refuses fails the export; nothing completes ----------
+# Parts 2 and up are answered 500 and not stored. Read as stored, the export would
+# complete a short object (or reach the complete with parts missing, 58030 too),
+# so the refusal must be the part check's own.
+: > "$S3_LOG"
+echo "500 2" > "$PGC_WORKDIR/__part_status__"
+check "part failure: a refused part fails the export (58030)" \
+ "$(refusal_of "$PART SELECT pgcolumnar.export_parquet('es_big', 's3://$BUCKET/partfail.parquet')" \
+ "uploading a part of" " by the part check")" "58030 by the part check"
+rm -f "$PGC_WORKDIR/__part_status__"
+check "part failure: the key does not exist" \
+ "$([ ! -e "$PGC_WORKDIR/$BUCKET/partfail.parquet" ] && echo clean)" "clean"
+check "premise: the part failure landed mid-multipart (an upload was started)" \
+ "$([ "$(logn '^POST /pgc-bucket/partfail.parquet uploads=')" -ge 1 ] && echo yes)" "yes"
+check_num "part failure: the server holds no open upload for the key" \
+ "$(open_uploads '^pgc-bucket/partfail\.parquet$')" "0"
# ---- taxonomy: the write side wears the same gates as the read side ---------
check "allow-list: an unlisted endpoint refuses 42501 before any write" \
- "$(sqlstate_of "SELECT pgcolumnar.export_parquet('es_small', 'http://127.0.0.2:1/x.parquet')")" "42501"
+ "$(refusal_of "SELECT pgcolumnar.export_parquet('es_small', 'http://127.0.0.2:1/x.parquet')" \
+ "is not in pgcolumnar.objstore_allowed_endpoints" " by the allow-list")" "42501 by the allow-list"
+# the object is read back: imported from the bucket into a fresh table and compared
+# with the generator (a count > 0 passed with the object's last byte missing)
+psql_run "CREATE TABLE es_arrow_back (id int, t text) USING pgcolumnar;"
+q "SELECT pgcolumnar.export_arrow('es_small', 's3://$BUCKET/small.arrow')" >/dev/null
+q "SELECT pgcolumnar.import_arrow('es_arrow_back', '$PGC_WORKDIR/$BUCKET/small.arrow')" >/dev/null
check "arrow export to s3:// also round-trips" \
- "$(q "SELECT pgcolumnar.export_arrow('es_small', 's3://$BUCKET/small.arrow') > 0")" "t"
+ "$(pgc_set_hash "SELECT * FROM es_arrow_back")" "$(pgc_set_hash "$GEN_SMALL")"
# ---- step 4 (#623): parallel_export_parquet to an s3:// prefix --------------
# The workers already write per-worker objects remotely through the sink; this
@@ -139,7 +191,7 @@ check "arrow export to s3:// also round-trips" \
psql_run "CREATE TABLE es_par (id int8, v int) USING pgcolumnar;"
psql_run "SELECT pgcolumnar.set_options('es_par', stripe_row_limit => 5000);"
psql_run "INSERT INTO es_par SELECT g, g % 100 FROM generate_series(1,80000) g;"
-PARROWS="$(q "SELECT count(*) FROM es_par")"
+PARROWS=80000
: > "$S3_LOG"
check_num "parallel export to an s3:// prefix succeeds" \
@@ -162,7 +214,7 @@ for i in 0 1 2 3; do
done
check "parallel: the union of the per-worker objects == source" \
"$(pgc_set_hash "SELECT * FROM fpar_0 UNION ALL SELECT * FROM fpar_1 UNION ALL SELECT * FROM fpar_2 UNION ALL SELECT * FROM fpar_3")" \
- "$(pgc_set_hash "SELECT * FROM es_par")"
+ "$(pgc_set_hash "$GEN_PAR")"
# a non-empty prefix is a documented caveat, not enforced remotely: a re-run
# into the same prefix overwrites the same keys and still round-trips.
@@ -205,10 +257,28 @@ for _ in $(seq 1 300); do
sleep 0.1
done
check "premise: a worker was uploading before the cancel" "$wrote" "yes"
+# Cancel only once all four workers' uploads are registered on the server. A cancel
+# landing while a worker's POST ?uploads is still in flight leaves an upload whose id
+# the worker never received, so nothing can abort it: that window is #1743's, not what
+# the open-upload check below pins (it flaked on it in CI, #1723's head). Not a
+# premise: a writer that never opens a multipart upload reddens it.
+cx_open=0
+for _ in $(seq 1 300); do
+ cx_open="$(open_uploads '^pgc-bucket/cx/')"
+ [ "$cx_open" -ge 4 ] && break
+ sleep 0.1
+done
+check "all four workers' uploads were open before the cancel" \
+ "$([ "$cx_open" -ge 4 ] && echo yes || echo "no ($cx_open open)")" "yes"
q "SELECT pg_cancel_backend(pid) FROM pg_stat_activity
WHERE query LIKE '%parallel_export_parquet%' AND state = 'active'
AND pid <> pg_backend_pid()" >/dev/null
wait "$bgpid" 2>/dev/null || true
+# let requests still in flight from the cancelled workers land (the log stops growing
+# for a second, at most 10 s), so a late write to a final key is read, not raced
+for _ in $(seq 1 10); do
+ _sz="$(wc -c < "$S3_LOG")"; sleep 1; [ "$(wc -c < "$S3_LOG")" = "$_sz" ] && break
+done
check "premise: the export was cancelled, not completed" \
"$(grep -qiE 'canceling statement|canceled on user request' "$PGC_WORKDIR/cx_bg.out" && echo cancelled || echo completed)" "cancelled"
check "cancel: the dispatcher issued DELETE over the known keys" \
@@ -219,6 +289,50 @@ check_num "cancel: no completed object remains at the prefix" \
# only on full success), and the dispatcher's cleanup also drops any stale one.
check "cancel: no _SUCCESS marker at the prefix" \
"$([ -e "$PGC_WORKDIR/$BUCKET/cx/_SUCCESS" ] && echo present || echo absent)" "absent"
+# #1722: a worker the cancel terminates aborts its multipart upload on the way out,
+# so no billable orphan is left under the prefix
+check "cancel: the server holds no open upload under the prefix" \
+ "$(open_uploads '^pgc-bucket/cx/')" "0"
+
+# #1727 review: a worker that SIGTERM ends aborts its upload in an exit callback, and a
+# store that stops answering must not hold it there. Each wait slice times out, so the
+# 60 s quiet limit ends the abort; without WL_TIMEOUT the wait never woke and the review
+# saw all four workers alive at +92 s. Stop the fixture mid-upload, terminate the
+# workers, and require them gone within 75 s. One check: no upload seen is a red too.
+WORKERS="backend_type = 'pgcolumnar parallel_export worker'"
+: > "$S3_LOG"
+rm -rf "$PGC_WORKDIR/$BUCKET/hs"
+env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres -d "$PGC_DB" -Atq \
+ -c "SET pgcolumnar.objstore_part_size = 262144" \
+ -c "SELECT pgcolumnar.parallel_export_parquet('es_parbig'::regclass, 's3://$BUCKET/hs', 4)" \
+ >"$PGC_WORKDIR/hs_bg.out" 2>&1 &
+hspid=$!
+hwrote=no
+for _ in $(seq 1 300); do
+ [ "$(logn '^PUT /pgc-bucket/hs/part-')" -ge 1 ] && { hwrote=yes; break; }
+ sleep 0.1
+done
+kill -STOP "$SRV_PID"
+hs_n="$(q "SELECT count(*) FROM pg_stat_activity WHERE $WORKERS;")"
+q "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE $WORKERS;" >/dev/null
+hs_t0=$(date +%s)
+hs_left="$hs_n"
+for _ in $(seq 1 90); do
+ hs_left="$(q "SELECT count(*) FROM pg_stat_activity WHERE $WORKERS;")"
+ [ "$hs_left" = 0 ] && break
+ sleep 1
+done
+hs_dt=$(( $(date +%s) - hs_t0 ))
+kill -CONT "$SRV_PID"
+q "SELECT pg_cancel_backend(pid) FROM pg_stat_activity
+ WHERE query LIKE '%parallel_export_parquet%' AND state = 'active'
+ AND pid <> pg_backend_pid()" >/dev/null
+wait "$hspid" 2>/dev/null || true
+echo "store silent: ${hs_n:-?} workers terminated, ${hs_left:-?} left after ${hs_dt}s"
+check "terminate: export workers exit within 75 s while the store is silent" \
+ "$(if [ "$hwrote" != yes ]; then echo "no upload seen"
+ elif [ "${hs_n:-0}" -ge 1 ] && [ "$hs_left" = 0 ] && [ "$hs_dt" -le 75 ]; then echo bounded
+ else echo "slow (${hs_n:-?} workers, ${hs_left:-?} left after ${hs_dt}s)"; fi)" "bounded"
# ---- optional: a real S3 implementation (Garage) ----------------------------
if [ -n "${PGC_S3_INTEGRATION_ENDPOINT:-}" ]; then
@@ -233,7 +347,7 @@ if [ -n "${PGC_S3_INTEGRATION_ENDPOINT:-}" ]; then
"$(q "SELECT pgcolumnar.export_parquet('es_big', 's3://$IB/m2sink-$$.parquet')")" "$BIGROWS"
psql_run "CREATE FOREIGN TABLE f_real (id int8, pad text) SERVER wsrv OPTIONS (path 's3://$IB/m2sink-$$.parquet');"
check "integration: real S3 read-back == source" \
- "$(pgc_set_hash "SELECT * FROM f_real")" "$(pgc_set_hash "SELECT * FROM es_big")"
+ "$(pgc_set_hash "SELECT * FROM f_real")" "$(pgc_set_hash "$GEN_BIG")"
else
echo "note: PGC_S3_INTEGRATION_ENDPOINT unset; the stdlib-verifier arms carry the proof alone"
fi
diff --git a/test/objstore_stash_recovery.sh b/test/objstore_stash_recovery.sh
index 1e5e4042a..54218a13a 100755
--- a/test/objstore_stash_recovery.sh
+++ b/test/objstore_stash_recovery.sh
@@ -107,23 +107,51 @@ fi
SAFE="$(mktemp /tmp/pgc-objstore-safe.XXXXXX)"
cp -p "$MOD" "$SAFE"
+# EVERY SLICE STARTS FROM THE PRISTINE COPY (#1341), not from whatever the slice
+# before it left. Chained, a presence-only guard that deleted the only copy in the
+# second slice left nothing to move in the third, so the third slice reported its
+# own premise rather than the defect it exists for, and which arm caught what
+# depended on the order the slices were written in.
+arrange_pristine() {
+ rm -f "$MOD.probe" "$MOD.away"
+ cp -p "$SAFE" "$MOD"
+}
+
# ---- debris beside a good installation must not stop the suite ----------------
#
# The PG17 case, exactly: a stash left by an interrupted run, and a valid module
# reinstalled beside it by the next `make install`.
+#
+# THE DEBRIS IS A DIFFERENT MODULE, NOT A COPY (#1341). On PG17 the stash was a
+# STALE build and the installed module a fresh one, which is why restoring the
+# stash was the wrong advice. A byte-identical copy cannot show that: a guard with
+# no debris branch at all, restoring every stash over the installed module, passed
+# every check here, because restoring the copy changed nothing. Trailing bytes keep
+# it a valid module to nm and to dlopen while making it a different file.
+#
+# The check this replaces, "and it did not stop at the start guard", counted the
+# words 'interrupted mid-move' in the suite's output. That was the refusal's text
+# before #393's fix and nothing has printed it since, so it read 0 whatever the
+# guard did: with the guard turned back into a refusal, every other check in this
+# slice went red and it stayed green. Whether the suite stopped is the exit status
+# above it, so it is not replaced by another count of words.
+arrange_pristine
check "premise: the installed module is valid before this slice arranges anything" \
"$(mod_is_valid "$MOD" && echo yes || echo no)" "yes"
cp -p "$MOD" "$MOD.probe"
+printf 'stale' >> "$MOD.probe"
check "premise: a debris stash really is in place for the run below" \
"$([ -e "$MOD.probe" ] && echo yes || echo no)" "yes"
+check "premise: and the debris is a valid module that differs from the installed one" \
+ "$(mod_is_valid "$MOD.probe" && ! cmp -s "$MOD.probe" "$MOD" && echo yes || echo no)" "yes"
out="$(PGC_SKIP_BUILD=1 PGC_PORT="$(pgc_pick_port)" bash "$SUITE" "$PG_CONFIG" 2>&1)"
rc=$?
check_num "the suite runs to completion with debris beside a valid module" "$rc" "0"
-check_num "and it did not stop at the start guard" \
- "$(grep -c 'interrupted mid-move' <<<"$out")" "0"
+check "and the installed module is still the one that was there, not the debris" \
+ "$(cmp -s "$MOD" "$SAFE" && echo same || echo replaced)" "same"
check "the debris is gone afterwards" \
"$([ -e "$MOD.probe" ] && echo present || echo absent)" "absent"
check "and the module left installed is still valid" \
@@ -134,11 +162,18 @@ check "and the module left installed is still valid" \
# The interrupted state itself: the module moved aside and nothing put back. The
# suite must not proceed past this by treating the stash as debris -- there is no
# module to fall back on -- and it must not leave the box needing a human either.
-mv "$MOD" "$MOD.away"
+#
+# AT .probe, NOT .away (#1341). The guard walks both names, and the third slice
+# below already parks the real module at .away. With both restore slices at .away,
+# a guard that walked .away alone passed every check here, while the interrupt
+# objstore_module.sh's own restore_module comment describes -- the real module at
+# .probe and nothing at its path -- went unrecovered.
+arrange_pristine
+mv "$MOD" "$MOD.probe"
check "premise: nothing is installed at the module's path for this slice" \
"$([ -e "$MOD" ] && echo present || echo absent)" "absent"
check "premise: and the stash beside it is the real module" \
- "$(mod_is_valid "$MOD.away" && echo yes || echo no)" "yes"
+ "$(mod_is_valid "$MOD.probe" && echo yes || echo no)" "yes"
out="$(PGC_SKIP_BUILD=1 PGC_PORT="$(pgc_pick_port)" bash "$SUITE" "$PG_CONFIG" 2>&1)"
rc=$?
@@ -147,7 +182,7 @@ check_num "the suite recovers the only surviving copy and runs to completion" "$
check "the module is installed again afterwards, and valid" \
"$(mod_is_valid "$MOD" && echo yes || echo no)" "yes"
check "and the stash is not left behind for the next run to trip on" \
- "$([ -e "$MOD.away" ] && echo present || echo absent)" "absent"
+ "$([ -e "$MOD.probe" ] && echo present || echo absent)" "absent"
# ---- a stand-in installed over the real module is the destructive case --------
#
@@ -156,6 +191,7 @@ check "and the stash is not left behind for the next run to trip on" \
# rule written on presence alone deletes .away here and destroys the installation,
# and it looks correct in every other arrangement. This is the arm that says the
# discriminator has to be validity.
+arrange_pristine
mv "$MOD" "$MOD.away"
printf 'not a shared object' > "$MOD"
check_num "premise: a 19-byte stand-in really is installed as the module" \
diff --git a/test/objstore_tls_read.sh b/test/objstore_tls_read.sh
index 2664f6123..d42b45f43 100755
--- a/test/objstore_tls_read.sh
+++ b/test/objstore_tls_read.sh
@@ -14,14 +14,21 @@
# SNI suppressed for the IP literal).
# A2 s3://bucket/key over an https endpoint with SigV4 verification on:
# the M2 signer composed over TLS.
-# C wronghost / expired / untrusted each fail SQLSTATE 08006 with
-# OpenSSL's deterministic reason string, and each carries the premise
+# C wronghost / expired / untrusted / self-signed each fail SQLSTATE
+# 08006 with the module's own message and OpenSSL's verification
+# reason, word for word, in the DETAIL -- not any error that happens to
+# mention a certificate -- and each carries the premise
# that the TLS handshake was genuinely attempted (the fixture logs
# HANDSHAKE at accept time, BEFORE the handshake can fail) -- a reason
# grep without that premise is satisfied by a server that was never
# contacted.
# D statement_timeout cancels a transfer stalled mid-body over TLS: the
# SSL_ERROR_WANT_* wait loop services interrupts like the cleartext one.
+# E the CA in SSL_CERT_DIR alone (no SSL_CERT_FILE) carries the good read:
+# the default verify paths honour the directory as well as the file.
+# No arm may crash a backend: the server log's "terminated by signal" count
+# is read at the end, and every refusal waits for the server to answer
+# SELECT 1 again, so a crash fails its own checks and not the next arm's.
#
# The fixture CA is trusted through SSL_CERT_FILE in the postmaster
# environment, which OpenSSL's default verify paths honour; the module carries
@@ -87,7 +94,14 @@ gen_cert expired ca1 "IP:127.0.0.1" \
# proves the certificate is genuinely expired, whichever path produced it.
[ -s "$PKI/expired.pem" ] || gen_cert expired ca1 "IP:127.0.0.1" -days 0
gen_cert untrusted ca2 "IP:127.0.0.1"
-for f in ca1 ca2 good goodsig wronghost expired untrusted; do
+# A self-signed leaf: its own issuer, trusted by nobody.
+openssl req -x509 -newkey rsa:2048 -nodes -keyout "$PKI/selfsigned.key" \
+ -subj "/CN=selfsigned" -addext "subjectAltName=IP:127.0.0.1" \
+ -days 2 -out "$PKI/selfsigned.pem" 2>/dev/null
+# The SSL_CERT_DIR form of CA-1: a directory holding it under its subject hash.
+mkdir -p "$PKI/cadir"
+cp "$PKI/ca1.pem" "$PKI/cadir/$(openssl x509 -hash -noout -in "$PKI/ca1.pem").0"
+for f in ca1 ca2 good goodsig wronghost expired untrusted selfsigned; do
[ -s "$PKI/$f.pem" ] || { echo "FATAL: PKI generation failed for $f"; exit 1; }
done
check "premise: expired certificate is genuinely expired" \
@@ -129,7 +143,9 @@ start_srv goodsig goodsig --sigv4-key "$AKID" --sigv4-secret "$SECRET" \
start_srv wronghost wronghost
start_srv expired expired
start_srv untrusted untrusted
-check_num "premise: five TLS servers are up" "${#SRV_PIDS[@]}" "5"
+start_srv selfsigned selfsigned
+check_num "premise: six TLS servers are up" "${#SRV_PIDS[@]}" "6"
+CRASHES_BEFORE="$(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null)"
# Restart the cluster with the trust anchor and the s3 environment.
pgc_pg "pg_ctl -D '$PGC_PGDATA' stop -m fast -w" >/dev/null 2>&1
@@ -146,6 +162,7 @@ psql_run "CREATE FOREIGN TABLE fs3tls ($COLDEFS) SERVER tlssrv OPTIONS (path 's3
psql_run "CREATE FOREIGN TABLE fwrong ($COLDEFS) SERVER tlssrv OPTIONS (path 'https://127.0.0.1:${PORT[wronghost]}/$BUCKET/fixture.parquet');"
psql_run "CREATE FOREIGN TABLE fexp ($COLDEFS) SERVER tlssrv OPTIONS (path 'https://127.0.0.1:${PORT[expired]}/$BUCKET/fixture.parquet');"
psql_run "CREATE FOREIGN TABLE funtr ($COLDEFS) SERVER tlssrv OPTIONS (path 'https://127.0.0.1:${PORT[untrusted]}/$BUCKET/fixture.parquet');"
+psql_run "CREATE FOREIGN TABLE fself ($COLDEFS) SERVER tlssrv OPTIONS (path 'https://127.0.0.1:${PORT[selfsigned]}/$BUCKET/fixture.parquet');"
psql_run "CREATE FOREIGN TABLE fstall ($COLDEFS) SERVER tlssrv OPTIONS (path 'https://127.0.0.1:${PORT[good]}/stall/$BUCKET/fixture.parquet');"
# SQLSTATE and full verbose error of a failing statement.
@@ -159,6 +176,9 @@ SQLEOF
handshakes_for() { # handshakes since last truncation
grep -c "^HANDSHAKE" "$LOG" | tr -d ' '
}
+wait_up() { # a crashed backend fails its own check, not the next arm's
+ for _ in $(seq 1 30); do [ -n "$(q 'SELECT 1')" ] && return; sleep 0.5; done
+}
# ---- arm A: the good certificate carries data -------------------------------
check "A: https differential == local" \
@@ -171,24 +191,32 @@ check "A2: s3 over https differential == local" \
"$(pgc_set_hash "SELECT * FROM fs3tls")" "$(pgc_set_hash "SELECT * FROM flocal")"
# ---- arm C: the refusal matrix ---------------------------------------------
-c_arm() { # c_arm
- local label="$1" tbl="$2" want="$3" out state reason hs0 hs1
+# The refusal is the module's own: its message names the URL it could not
+# reach, and its DETAIL is OpenSSL's verification reason for THIS certificate,
+# compared whole -- a different verification failure, a cleartext reply or a
+# crash each read differently.
+c_arm() { # c_arm
+ local label="$1" tbl="$2" srv="$3" want="$4" out state msg detail hs0 hs1
hs0="$(handshakes_for)"
out="$(verr "SELECT count(*) FROM $tbl")"
hs1="$(handshakes_for)"
+ wait_up
state="$(sed -n 's/^ERROR: \([0-9A-Z]\{5\}\).*/\1/p' <<<"$out" | head -1)"
- reason="$(grep -icE "$want" <<<"$out")"
+ msg="$(sed -n 's/^ERROR: [0-9A-Z]\{5\}: //p' <<<"$out" | head -1)"
+ detail="$(sed -n 's/^DETAIL: //p' <<<"$out" | head -1)"
check "premise: $label handshake was attempted" \
"$([ "$hs1" -gt "$hs0" ] 2>/dev/null && echo yes)" "yes"
check "C: $label refused with 08006" "$state" "08006"
- check "C: $label names its reason" \
- "$([ "${reason:-0}" -ge 1 ] && echo yes || echo no)" "yes"
+ check "C: $label refusal is the TLS one" "$msg" \
+ "columnar: could not establish TLS to \"https://127.0.0.1:${PORT[$srv]}/$BUCKET/fixture.parquet\""
+ check "C: $label names its reason" "$detail" "Certificate verification failed: $want."
check_num "C: $label returned no rows" \
"$(grep -cE '^[0-9]+$' <<<"$out")" "0"
}
-c_arm "wrong-host" fwrong "hostname mismatch|ip address mismatch"
-c_arm "expired" fexp "certificate has expired"
-c_arm "untrusted-ca" funtr "unable to get local issuer|self-signed certificate|unable to verify"
+c_arm "wrong-host" fwrong wronghost "IP address mismatch"
+c_arm "expired" fexp expired "certificate has expired"
+c_arm "untrusted-ca" funtr untrusted "unable to get local issuer certificate"
+c_arm "self-signed" fself selfsigned "self-signed certificate"
# ---- arm D: cancel mid-body over TLS ---------------------------------------
D_START=$(date +%s)
@@ -203,5 +231,16 @@ D_ELAPSED=$(( $(date +%s) - D_START ))
check "D: stalled TLS transfer cancelled with query_canceled" "$D_STATE" "57014"
check_num "D: cancel latency bounded (<=10s)" \
"$([ "$D_ELAPSED" -le 10 ] 2>/dev/null && echo 1 || echo 0)" "1"
+wait_up
+
+# ---- arm E: the CA from SSL_CERT_DIR alone ---------------------------------
+pgc_pg "pg_ctl -D '$PGC_PGDATA' stop -m fast -w" >/dev/null 2>&1
+pgc_pg "SSL_CERT_DIR='$PKI/cadir' pg_ctl -D '$PGC_PGDATA' -l '$PGC_LOGFILE' start -w" >/dev/null 2>&1
+wait_up
+check_num "E: SSL_CERT_DIR alone trusts the CA (count(*) over https)" \
+ "$(q "SELECT count(*) FROM fgood")" "$((G * ROWS_PER_GROUP))"
+
+check_num "survival: no backend was terminated by a signal" \
+ "$(( $(pgc_pg "grep -c 'terminated by signal' '$PGC_LOGFILE'" 2>/dev/null) - CRASHES_BEFORE ))" "0"
pgc_summary
diff --git a/test/phase2.sh b/test/phase2.sh
index d8622363d..abcc5ac6b 100755
--- a/test/phase2.sh
+++ b/test/phase2.sh
@@ -94,28 +94,20 @@ fail=0
# return nothing on this log, it REFUSED it: "no RESULT records, so there is
# nothing to reconcile".
#
-# `pgc_record` takes the DISPLAY whole, so the human output below is byte-for-byte
-# what it was. NOT lib.sh's own `check`: that composes its own display and would
-# drop the `: $got` suffix, which is the measured value rather than a label.
-#
-# `fail` is still set, so this suite's exit logic is untouched. Its verdict line
-# stays for the same reason: under `set -euo pipefail` a failing command aborts the
-# suite, and the verdict is what distinguishes finished from stopped.
+# lib.sh's own `check` (#1341), as smoke.sh did. This suite used to define its own
+# `check` around pgc_record, to keep a `: $got` suffix on PASS lines. A PASS
+# line's value is the wanted value, which the call already states, and a FAIL line
+# carries both either way. A suite-local recorder is also what the pytest twin's
+# grading cannot read (test_compare_to_bash's seed arm), so the shared one is used
+# and `fail` is taken from lib.sh's own PGC_FAIL before the verdict. The verdict
+# line stays: under `set -euo pipefail` a failing command aborts the suite, and
+# the verdict is what distinguishes finished from stopped.
#
# The `checks run:` line is READ BY NOTHING YET. The matrix gates reconciliation on
# the ACCOUNTING line via `pgc_log_shows_accounting`, and this suite emits none
# because it emits no accounting line. The line is still correct and wanted --
-# it is the total the records reconcile against -- so the remaining step is a gate
+# it is the total the records reconcile against, so the remaining step is a gate
# flip rather than new work (@jdatcmd, #969 review).
-check() {
- local name="$1" got="$2" want="$3"
- if [ "$got" = "$want" ]; then
- pgc_record PASS "$name" "PASS $name: $got"
- else
- pgc_record FAIL "$name" "FAIL $name: got [$got] want [$want]"
- fail=1
- fi
-}
q "CREATE EXTENSION pgcolumnar;" >/dev/null
@@ -152,9 +144,14 @@ for codec in none pglz lz4 zstd; do
check "$codec sum(id)" "$(q 'SELECT sum(id) FROM c;')" "200010000"
check "$codec payload ok" "$(q "SELECT payload = repeat('x',100) FROM c WHERE id = 12345;")" "t"
check "$codec distinct pl" "$(q 'SELECT count(DISTINCT payload) FROM c;')" "1"
- # The native writer records the per-vector encoding cascade and the optional
- # block codec in the column-chunk descriptor; that choice is exercised by the
- # native_encoding suite. Here the round-trip above proves each codec decodes.
+ # AND THE CODEC ASKED FOR IS THE ONE STORED (#1341). The round-trip alone
+ # proves only that what was stored decodes: with the session's setting ignored
+ # (every write zstd), or with no block codec ever applied, all four codecs
+ # passed every check above, and $code was computed for nothing since the
+ # native conversion dropped the 2.2 catalog's check. The payload column, whose
+ # 20000 identical values compress under every codec, is read; codec none is the
+ # control that 0 is what an unrequested codec stores.
+ check "$codec block codec" "$(q "SELECT string_agg(DISTINCT block_codec::text, ',') FROM pgcolumnar.column_chunk WHERE storage_id = pgcolumnar.get_storage_id('c') AND column_index = 1;")" "$code"
q "DROP TABLE c;" >/dev/null
done
@@ -222,6 +219,13 @@ check "between count" "$(q 'SELECT count(*) FROM s WHERE id BETWEEN 15000 AND 1
check "range min/max" "$(qq 'SELECT min(id) || CHR(124) || max(id) FROM s WHERE id > 24990;')" "24991|25000,"
check "point lookup" "$(q 'SELECT count(*) FROM s WHERE id = 20005;')" "1"
check "empty range" "$(q 'SELECT count(*) FROM s WHERE id > 30000;')" "0"
+# AND THE SKIP LIST SKIPS (#1341). The four results above hold whether or not a
+# vector was skipped, so with the zone maps ruling out nothing every check here
+# passed. id > 24990 lies wholly in vector 2 (20001-25000), so the qual column
+# decodes one of the three. Read as Vectors Decoded, not Vectors Skipped: exact
+# selection rules the other two out by value after decoding them, and still
+# reports them skipped (measured: 2 skipped, 3 decoded with the zone maps off).
+check "range decodes 1 of 3 vectors" "$(q 'SET max_parallel_workers_per_gather = 0; EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT id FROM s WHERE id > 24990;' | grep -oP 'Columnar Vectors Decoded: \K[0-9]+')" "1"
q "DROP TABLE s;" >/dev/null
# ---------------------------------------------------------------------------
@@ -230,6 +234,8 @@ q "DROP TABLE s;" >/dev/null
check "orphan row groups" "$(q 'SELECT count(*) FROM pgcolumnar.row_group;')" "0"
check "orphan chunks" "$(q 'SELECT count(*) FROM pgcolumnar.column_chunk;')" "0"
+fail="$PGC_FAIL"
+
echo
if [ "$fail" = "0" ]; then
echo "checks run: $PGC_CHECKS"
diff --git a/test/phase3.sh b/test/phase3.sh
index b9ee51c68..aadabb217 100755
--- a/test/phase3.sh
+++ b/test/phase3.sh
@@ -103,28 +103,20 @@ fail=0
# return nothing on this log, it REFUSED it: "no RESULT records, so there is
# nothing to reconcile".
#
-# `pgc_record` takes the DISPLAY whole, so the human output below is byte-for-byte
-# what it was. NOT lib.sh's own `check`: that composes its own display and would
-# drop the `: $got` suffix, which is the measured value rather than a label.
-#
-# `fail` is still set, so this suite's exit logic is untouched. Its verdict line
-# stays for the same reason: under `set -euo pipefail` a failing command aborts the
-# suite, and the verdict is what distinguishes finished from stopped.
+# lib.sh's own `check` (#1341), as smoke.sh did. This suite used to define its own
+# `check` around pgc_record, to keep a `: $got` suffix on PASS lines. A PASS
+# line's value is the wanted value, which the call already states, and a FAIL line
+# carries both either way. A suite-local recorder is also what the pytest twin's
+# grading cannot read (test_compare_to_bash's seed arm), so the shared one is used
+# and `fail` is taken from lib.sh's own PGC_FAIL before the verdict. The verdict
+# line stays: under `set -euo pipefail` a failing command aborts the suite, and
+# the verdict is what distinguishes finished from stopped.
#
# The `checks run:` line is READ BY NOTHING YET. The matrix gates reconciliation on
# the ACCOUNTING line via `pgc_log_shows_accounting`, and this suite emits none
# because it emits no accounting line. The line is still correct and wanted --
# it is the total the records reconcile against -- so the remaining step is a gate
# flip rather than new work (@jdatcmd, #969 review).
-check() {
- local name="$1" got="$2" want="$3"
- if [ "$got" = "$want" ]; then
- pgc_record PASS "$name" "PASS $name: $got"
- else
- pgc_record FAIL "$name" "FAIL $name: got [$got] want [$want]"
- fail=1
- fi
-}
q "CREATE EXTENSION pgcolumnar;" >/dev/null
@@ -160,6 +152,10 @@ q "DROP TABLE ryw;" >/dev/null
echo "-- delete"
q "CREATE TABLE d (id int, v text) USING pgcolumnar;" >/dev/null
q "INSERT INTO d SELECT g, 'r'||g FROM generate_series(1,25000) g;" >/dev/null
+# AND IT DOES STRADDLE ONE (#1341). Nothing read the boundaries, so with a
+# 16384-row vector every check below passed on a delete that straddled nothing.
+# Each vector's maximum id, decoded from the zone map's little-endian int4.
+check "the deleted range straddles vectors 0 and 1" "$(q "SELECT string_agg(((get_byte(maximum,3)::bigint<<24)|(get_byte(maximum,2)<<16)|(get_byte(maximum,1)<<8)|get_byte(maximum,0))::text, chr(44) ORDER BY vector_index) FROM pgcolumnar.zone_map WHERE storage_id = pgcolumnar.get_storage_id('d') AND column_index = 0 AND vector_index >= 0;")" "10000,20000,25000"
q "DELETE FROM d WHERE id BETWEEN 9995 AND 10005;" >/dev/null
check "delete count" "$(q 'SELECT count(*) FROM d;')" "24989"
check "delete gone" "$(q 'SELECT count(*) FROM d WHERE id BETWEEN 9995 AND 10005;')" "0"
@@ -227,7 +223,71 @@ SPRES="$(q "BEGIN;
# before savepoint: added 11, removed 1 -> {2..11}. after-savepoint work undone.
check "savepoint in-txn" "$SPRES" "2,3,4,5,6,7,8,9,10,11"
check "savepoint final" "$(qq 'SELECT a FROM sp ORDER BY a;')" "2,3,4,5,6,7,8,9,10,11,"
+# AND A STATEMENT THAT FAILS PARTWAY INSIDE A SAVEPOINT (#1341). The two checks
+# above cannot reach the savepoint's own discard: executor end flushes each
+# COMPLETED statement's buffers under its subtransaction, so the abort's ordinary
+# xmin verdict hides them (#1441). Measured: with the subxact abort discarding
+# neither pending writes nor pending deletes, both stayed green and so did every
+# other check here. An INSERT whose 2001st row divides by zero leaves 2000 rows
+# pending, and a DELETE that fails at a = 9 leaves 2..8 pending; ROLLBACK TO must
+# discard both, in the transaction and after it. ON_ERROR_STOP is lifted for the
+# one statement meant to fail, and each must fail with division by zero and
+# nothing else: one that failed before writing anything would leave both discard
+# checks nothing to discard. If either failed otherwise, every reading is blanked,
+# as the twin's 22012-only allowance ends its session there. A later ERROR (the
+# COMMIT, say) already stops psql, and the readings before it stand.
+SPERR="$WORKDIR/spfail.err"
+SPFAIL="$(q 'BEGIN;
+ SAVEPOINT s2;
+ \set ON_ERROR_STOP 0
+ INSERT INTO sp SELECT CASE WHEN g < 2100 THEN g ELSE 1 / (g - g) END FROM generate_series(100, 3000) g;
+ \set ON_ERROR_STOP 1
+ ROLLBACK TO s2;
+ SELECT count(*) FROM sp WHERE a >= 100;
+ SAVEPOINT s3;
+ \set ON_ERROR_STOP 0
+ DELETE FROM sp WHERE a < 11 AND 1 / (a - 9) IS NOT NULL;
+ \set ON_ERROR_STOP 1
+ ROLLBACK TO s3;
+ SELECT string_agg(a::text, chr(44) ORDER BY a) FROM sp;
+ COMMIT;' 2>"$SPERR" || true)"
+sed 's/^/spfail stderr: /' "$SPERR"
+[ "$(grep ERROR "$SPERR" | head -2 | grep -c 'division by zero')" = 2 ] || SPFAIL=""
+check "savepoint failed insert discarded" "$(sed -n 1p <<<"$SPFAIL")" "0"
+check "savepoint failed delete discarded" "$(sed -n 2p <<<"$SPFAIL")" "2,3,4,5,6,7,8,9,10,11"
+check "savepoint failed final" "$(qq 'SELECT a FROM sp ORDER BY a;')" "2,3,4,5,6,7,8,9,10,11,"
q "DROP TABLE sp;" >/dev/null
+# AND A READ IN A BLOCK THAT COMMITS INTO ONE THAT ABORTS (#1341). A DELETE's
+# RETURNING calls spn_rd once per deleted row. spn_rd reads the table in an inner
+# EXCEPTION block that commits into an outer one, which raises. The read writes the
+# outer DELETE's marks so far under the inner block. Its commit hands them to the
+# outer block, whose rollback takes them away. The next read must write them again.
+# With the inner block's commit leaving them attributed to itself, no later read
+# writes them, so each later read counts the deleted rows as live. The statement's
+# end still writes every mark, so only the reads show it; their sum is pinned here,
+# with the table's shape after. Heap runs the same statement on the same rows.
+q "CREATE FUNCTION spn_rd(t text) RETURNS int LANGUAGE plpgsql AS \$\$
+DECLARE n int;
+BEGIN
+ BEGIN
+ BEGIN
+ EXECUTE format('SELECT count(*) FROM %s', t) INTO n;
+ EXCEPTION WHEN no_data_found THEN NULL;
+ END;
+ PERFORM 1 / 0;
+ EXCEPTION WHEN division_by_zero THEN NULL;
+ END;
+ RETURN n;
+END \$\$;" >/dev/null
+spn() {
+ q "CREATE TABLE spn_$1 (id int) USING $1;
+ INSERT INTO spn_$1 SELECT g FROM generate_series(1, 200) g;
+ WITH d AS (DELETE FROM spn_$1 WHERE id % 2 = 0 RETURNING spn_rd('spn_$1') AS n)
+ SELECT sum(n) FROM d;
+ SELECT count(*) || chr(124) || sum(id) FROM spn_$1;" | paste -sd'|'
+}
+check "savepoint nested read in a delete sees its deleted rows, as on heap" "$(spn pgcolumnar)" "$(spn heap | grep . || echo "heap gave no reading")"
+q "DROP TABLE spn_pgcolumnar, spn_heap; DROP FUNCTION spn_rd(text);" >/dev/null
# ---------------------------------------------------------------------------
# Temporary columnar table: create, insert, scan, delete, update all work.
@@ -254,6 +314,8 @@ q "DROP TABLE dur;" >/dev/null
check "delete_vector cleaned" "$(q 'SELECT count(*) FROM pgcolumnar.delete_vector;')" "0"
check "row group cleaned" "$(q 'SELECT count(*) FROM pgcolumnar.row_group;')" "0"
+fail="$PGC_FAIL"
+
echo
if [ "$fail" = "0" ]; then
echo "checks run: $PGC_CHECKS"
diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md
index 5c1d4d285..3ad416b32 100644
--- a/test/pytest/TESTS.md
+++ b/test/pytest/TESTS.md
@@ -346,6 +346,41 @@ behaviour, the source of that number is named.
- [298. test_parallel.py: a parallel columnar scan returns what the heap returns](#298-test_parallelpy-a-parallel-columnar-scan-returns-what-the-heap-returns)
- [299. test_parallel_vector_agg.py: the parallel vectorized aggregates fold every row group once](#299-test_parallel_vector_aggpy-the-parallel-vectorized-aggregates-fold-every-row-group-once)
- [300. test_parallel_export_parquet.py: parallel Parquet export equals serial export equals the source](#300-test_parallel_export_parquetpy-parallel-parquet-export-equals-serial-export-equals-the-source)
+- [301. test_native_fold_deferral.py: the batch fold defers payload until a row passes its keys, and the work is measured](#301-test_native_fold_deferralpy-the-batch-fold-defers-payload-until-a-row-passes-its-keys-and-the-work-is-measured)
+- [302. test_native_lazy_slot.py: the index fetch decodes only the prefix the executor asks for, and never a stale one](#302-test_native_lazy_slotpy-the-index-fetch-decodes-only-the-prefix-the-executor-asks-for-and-never-a-stale-one)
+- [303. test_native_metadata_flush.py: a stripe flush opens each metadata table once, and the flush's writes read back correctly](#303-test_native_metadata_flushpy-a-stripe-flush-opens-each-metadata-table-once-and-the-flushs-writes-read-back-correctly)
+- [304. test_native_late_materialization.py: a row the qual rejects is not materialized, and the answers do not change](#304-test_native_late_materializationpy-a-row-the-qual-rejects-is-not-materialized-and-the-answers-do-not-change)
+- [305. test_native_reclaim_reconcile.py: a reuse op drops the free_space row that overlaps a live group, and only that row](#305-test_native_reclaim_reconcilepy-a-reuse-op-drops-the-free_space-row-that-overlaps-a-live-group-and-only-that-row)
+- [306. test_native_page_offset_bound.py: a corrupt page_offset is refused by the containment guard on both reads, and an intact table still reads](#306-test_native_page_offset_boundpy-a-corrupt-page_offset-is-refused-by-the-containment-guard-on-both-reads-and-an-intact-table-still-reads)
+- [307. test_native_param_pushdown.py: a parameterized qual prunes chunk groups, and a correlated one is never frozen](#307-test_native_param_pushdownpy-a-parameterized-qual-prunes-chunk-groups-and-a-correlated-one-is-never-frozen)
+- [308. test_native_sort_by.py: the declared sort_by key orders the rows it says, and each refusal is its own guard's](#308-test_native_sort_bypy-the-declared-sort_by-key-orders-the-rows-it-says-and-each-refusal-is-its-own-guards)
+- [309. test_native_rewrite.py: compact_rewrite reclaims partially-deleted groups online, and vacuum's rewrite keeps every row reachable](#309-test_native_rewritepy-compact_rewrite-reclaims-partially-deleted-groups-online-and-vacuums-rewrite-keeps-every-row-reachable)
+- [310. test_native_zonemap.py: zone-map rows carry exact counts, sums and the right bounds, by value](#310-test_native_zonemappy-zone-map-rows-carry-exact-counts-sums-and-the-right-bounds-by-value)
+- [311. test_native_zonemap_session.py: a scan resolves zone_map once, and each scan holds its own session](#311-test_native_zonemap_sessionpy-a-scan-resolves-zone_map-once-and-each-scan-holds-its-own-session)
+- [312. test_native_zonemap_narrow.py: group skipping reads only the predicate columns' zone maps, and still prunes](#312-test_native_zonemap_narrowpy-group-skipping-reads-only-the-predicate-columns-zone-maps-and-still-prunes)
+- [313. test_native_vecdecode.py: decode honours the skip vector, and the fold reads neither holes nor nulls as values](#313-test_native_vecdecodepy-decode-honours-the-skip-vector-and-the-fold-reads-neither-holes-nor-nulls-as-values)
+- [314. test_native_parquet_fieldid.py: read_parquet binds output columns by Parquet field id, and refuses each misuse by its own guard](#314-test_native_parquet_fieldidpy-read_parquet-binds-output-columns-by-parquet-field-id-and-refuses-each-misuse-by-its-own-guard)
+- [315. test_native_parquet_codecs.py: GZIP, ZSTD and LZ4_RAW pages decode to the generator's rows, and a crafted page size is refused](#315-test_native_parquet_codecspy-gzip-zstd-and-lz4_raw-pages-decode-to-the-generators-rows-and-a-crafted-page-size-is-refused)
+- [316. test_native_parquet_units.py: TIME and TIMESTAMP units decode to the right instant, and their stats skip correctly](#316-test_native_parquet_unitspy-time-and-timestamp-units-decode-to-the-right-instant-and-their-stats-skip-correctly)
+- [317. test_native_parquet_projection.py: a projected Parquet scan decodes only the columns it references, and reads the generator's rows](#317-test_native_parquet_projectionpy-a-projected-parquet-scan-decodes-only-the-columns-it-references-and-reads-the-generators-rows)
+- [318. test_native_parquet_hardening.py: the Parquet reader refuses values that do not fit their column, and a row group with too few chunks](#318-test_native_parquet_hardeningpy-the-parquet-reader-refuses-values-that-do-not-fit-their-column-and-a-row-group-with-too-few-chunks)
+- [319. test_native_parquet_stack.py: a crafted Parquet footer trips the recursion guard instead of crashing the backend](#319-test_native_parquet_stackpy-a-crafted-parquet-footer-trips-the-recursion-guard-instead-of-crashing-the-backend)
+- [320. test_native_parquet_streaming.py: external Parquet reads stream: an oversized file describes itself, crafted pages are refused, the FDW decodes one row group at a time](#320-test_native_parquet_streamingpy-external-parquet-reads-stream-an-oversized-file-describes-itself-crafted-pages-are-refused-the-fdw-decodes-one-row-group-at-a-time)
+- [321. test_native_parquet_partition.py: Hive-style partition columns materialize and prune whole files, and a tree that does not match fails loudly](#321-test_native_parquet_partitionpy-hive-style-partition-columns-materialize-and-prune-whole-files-and-a-tree-that-does-not-match-fails-loudly)
+- [322. test_native_parquet_fdw.py: the Parquet FDW reads an exported file as a foreign table, prunes what it can, and refuses each misuse by its own guard](#322-test_native_parquet_fdwpy-the-parquet-fdw-reads-an-exported-file-as-a-foreign-table-prunes-what-it-can-and-refuses-each-misuse-by-its-own-guard)
+- [323. test_native_read_parquet.py: read_parquet returns the rows that were written, refuses what it must, and keeps its file gate](#323-test_native_read_parquetpy-read_parquet-returns-the-rows-that-were-written-refuses-what-it-must-and-keeps-its-file-gate)
+- [324. test_objstore_listing.py: a remote prefix or glob is listed page by page, an exact key is not listed, and a listing that cannot advance or fails is refused](#324-test_objstore_listingpy-a-remote-prefix-or-glob-is-listed-page-by-page-an-exact-key-is-not-listed-and-a-listing-that-cannot-advance-or-fails-is-refused)
+- [325. test_native_rewrite_conc.py: The online rewrite runs alongside writers without losing a row, and without blocking them](#325-test_native_rewrite_concpy-the-online-rewrite-runs-alongside-writers-without-losing-a-row-and-without-blocking-them)
+- [326. test_objstore_addressing.py: gs:// and S3 virtual-host addressing, checked by the request path the server received](#326-test_objstore_addressingpy-gs-and-s3-virtual-host-addressing-checked-by-the-request-path-the-server-received)
+- [327. test_objstore_stash_recovery.py: recovery from a stash an interrupted objstore_module run left](#327-test_objstore_stash_recoverypy-recovery-from-a-stash-an-interrupted-objstore_module-run-left)
+- [328. test_objstore_s3_read.py: SigV4-signed s3:// reads, every refusal naming its cause](#328-test_objstore_s3_readpy-sigv4-signed-s3-reads-every-refusal-naming-its-cause)
+- [329. test_objstore_tls_read.py: TLS for object-store reads, every verification proven by a certificate that must be refused](#329-test_objstore_tls_readpy-tls-for-object-store-reads-every-verification-proven-by-a-certificate-that-must-be-refused)
+- [330. test_objstore_http_read.py: the HTTP ranged byte source, with request counts and each refusal's own guard](#330-test_objstore_http_readpy-the-http-ranged-byte-source-with-request-counts-and-each-refusals-own-guard)
+- [331. test_objstore_sink_write.py: exports to s3:// go as one PUT or a completed multipart upload, read back as the generator, and a failed export leaves no key and no open upload](#331-test_objstore_sink_writepy-exports-to-s3-go-as-one-put-or-a-completed-multipart-upload-read-back-as-the-generator-and-a-failed-export-leaves-no-key-and-no-open-upload)
+- [332. test_native_fetch_interrupt.py: the by-row-number fetch is cancellable](#332-test_native_fetch_interruptpy-the-by-row-number-fetch-is-cancellable)
+- [333. test_objstore_module.py: the object-store module's packaging and loader, every refusal pinned](#333-test_objstore_modulepy-the-object-store-modules-packaging-and-loader-every-refusal-pinned)
+- [334. test_phase2.py: compression, projection, zone maps and chunk-group filtering](#334-test_phase2py-compression-projection-zone-maps-and-chunk-group-filtering)
+- [335. test_phase3.py: read-your-writes, DELETE, UPDATE, rollback, savepoints and temporary tables](#335-test_phase3py-read-your-writes-delete-update-rollback-savepoints-and-temporary-tables)
## 1. How to read a test in here
@@ -11550,3 +11585,1017 @@ The source hashes and counts come from the generator that fills each table, not
| `test_pexport_rejects_a_non_empty_directory` | reject a non-empty output directory |
| `test_pexport_rejects_a_non_columnar_target` | reject a non-columnar target |
| `test_pexport_the_server_survives_the_exports` | server still up after the exports |
+
+## 301. test_native_fold_deferral.py: the batch fold defers payload until a row passes its keys, and the work is measured
+
+The twin of `test/native_fold_deferral.sh`. On a five-column table over six row groups, a 1%-survival sum folds with every group deferred and loads exactly survivors times payload width; a 99%-survival sum defers only the optimistic first group and loads more than survivors alone; and both answers equal the row path and a heap mirror written from the generator.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. A failed reading is a `query_error()`, so two failed reads never agree, and a count or plan line that cannot be read is a failed reading, never 0.
+
+| test | what it holds |
+| --- | --- |
+| `test_fold_deferral_the_selective_query_takes_the_fold` | the selective query takes the fold (ANALYZE, post-#602) |
+| `test_fold_deferral_the_selective_predicate_is_selective` | premise: the selective predicate is genuinely selective (<= 2%) |
+| `test_fold_deferral_deferred_fold_equals_row_path` | deferred fold == row path (selective) |
+| `test_fold_deferral_deferred_fold_equals_heap_mirror` | deferred fold == heap mirror (selective) |
+| `test_fold_deferral_high_survival_fold_equals_heap_mirror` | fold == heap mirror (high survival) |
+| `test_fold_deferral_the_loads_counter_exists` | the loads counter exists under ANALYZE |
+| `test_fold_deferral_the_predicate_has_survivors` | premise: the predicate has survivors (the identity is not 0 == 0) |
+| `test_fold_deferral_loads_equal_survivors_times_payload` | deferred: loads == survivors x payload width (the identity) |
+| `test_fold_deferral_explain_group_total_matches_stats` | EXPLAIN group total == stats() group count |
+| `test_fold_deferral_the_fixture_spans_several_groups` | the fixture spans several groups |
+| `test_fold_deferral_every_group_deferred_at_one_percent` | deferred: every group deferred at 1% survival |
+| `test_fold_deferral_only_the_first_group_defers_at_high_survival` | adaptive: only the optimistic first group deferred at 99% survival |
+| `test_fold_deferral_eager_groups_load_more_than_survivors` | adaptive: eager groups load more than survivors alone (work measured, not assumed) |
+
+## 302. test_native_lazy_slot.py: the index fetch decodes only the prefix the executor asks for, and never a stale one
+
+The twin of `test/native_lazy_slot.sh` (#157). Through an index scan with the custom scan off, values match a heap table built from the generator for every column position and for count(*); a row still in the write buffer reads correctly; sorting, CREATE TABLE AS and a unique check still get the whole row; and a 41-column table is not in a different class from a 3-column one for the same 2000 one-column fetches.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. The heap mirror is written from the generator, not copied through the reader under test. Readings are values: a failed query is a query_error() that every comparison refuses by name, so two failed reads never agree, and a timing whose statement failed is no reading rather than a fast one.
+
+| test | what it holds |
+| --- | --- |
+| `test_lazy_slot_the_fetch_is_an_index_scan` | the fetch really is an index scan |
+| `test_lazy_slot_every_column_position_matches_heap` | single-row index fetches match heap for every column position |
+| `test_lazy_slot_select_star_matches_heap` | SELECT * through the index matches heap |
+| `test_lazy_slot_a_range_sums_the_same_as_heap` | a range of rows sums the same as heap |
+| `test_lazy_slot_count_star_matches_heap` | count(*) through the index matches heap |
+| `test_lazy_slot_a_buffered_row_reads_correctly` | a row fetched before it is flushed reads correctly |
+| `test_lazy_slot_an_order_by_matches_heap` | an ORDER BY over index-fetched rows matches heap |
+| `test_lazy_slot_a_copy_keeps_every_column` | copying index-fetched rows into a heap table keeps every column |
+| `test_lazy_slot_a_unique_violation_is_detected` | a unique violation is still detected through the fetch path |
+| `test_lazy_slot_a_wide_table_is_not_in_another_class` | a wide table is not in a different class from a narrow one |
+
+## 303. test_native_metadata_flush.py: a stripe flush opens each metadata table once, and the flush's writes read back correctly
+
+The twin of `test/native_metadata_flush.sh`. A 3-column and a 20-column table each flush one stripe under small limits; the flush's DEBUG1 witness (`metadata flush: opens=N`) must report the same small count for both, no more than six and at least the three tables every flush writes. The wide table must read back exactly what the generator wrote, and a range read must rule out a vector by its zone row alone.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. The witness is captured from the inserting session's notices, with the stripe limits set in that session as the shell does. The heap mirror is written from the generator. A failed reading is a `query_error()`, so two failures never agree.
+
+| test | what it holds |
+| --- | --- |
+| `test_native_metadata_flush_emits_the_witness` | the flush emits the metadata-open witness |
+| `test_native_metadata_flush_narrow_and_wide_open_the_same` | narrow (3 col) and wide (20 col) flush open the same number of metadata relations |
+| `test_native_metadata_flush_wide_opens_a_small_constant` | a 20-column flush opens a small constant number of metadata relations, not one per row |
+| `test_native_metadata_flush_wide_opens_at_least_three` | and the 20-column flush opens at least the three tables every flush writes |
+| `test_native_metadata_flush_row_count_survives` | the wide table's row count survives the batched flush |
+| `test_native_metadata_flush_every_column_reads_back` | every column reads back correct (sum over all 20 columns matches the heap mirror) |
+| `test_native_metadata_flush_range_read_matches_heap` | a zone-map-pruned range read matches heap (the batched zone rows are correct) |
+| `test_native_metadata_flush_zone_row_rules_out_a_vector` | and that range read rules out a vector by its zone row alone |
+
+## 304. test_native_late_materialization.py: a row the qual rejects is not materialized, and the answers do not change
+
+The twin of `test/native_late_materialization.sh`. A leading-wildcard LIKE that cannot be pruned selects one row in 1024 from a one-group table with wide payload columns; the scan counts the rejected rows as filtered before materialization, the executor still reports them as removed by filter, and every answer, with the feature on, off and refused for a volatile qual, equals a heap mirror written from the generator.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order, with serial plans as the shell's cluster runs them. A failed reading is a `query_error()`, and a counter the plan did not print is a failed reading, never 0.
+
+| test | what it holds |
+| --- | --- |
+| `test_late_materialization_fixture_loaded_every_row` | premise: the fixture loaded every row |
+| `test_late_materialization_needle_is_selective` | premise: the needle is selective, not most of the table |
+| `test_late_materialization_heap_agrees_on_matches` | heap agrees on how many rows match |
+| `test_late_materialization_query_runs_on_the_custom_scan` | the query runs on the columnar custom scan |
+| `test_late_materialization_nothing_pruned_at_group_level` | nothing was pruned at the group level |
+| `test_late_materialization_nothing_pruned_at_vector_level` | nothing was pruned at the vector level |
+| `test_late_materialization_rejected_row_not_materialized` | a rejected row does not materialize its payload columns |
+| `test_late_materialization_executor_still_reports_filtered_rows` | the executor still reports the rows the scan filtered |
+| `test_late_materialization_rows_identical_to_heap` | the rows are identical to the heap mirror |
+| `test_late_materialization_off_filters_nothing_early` | with the feature off nothing is filtered early |
+| `test_late_materialization_off_answers_unchanged` | and the answers are the same with it off |
+| `test_late_materialization_volatile_qual_refused` | a volatile qual is refused the two-pass path |
+| `test_late_materialization_volatile_matches_heap` | and the volatile query still matches the heap mirror |
+| `test_late_materialization_same_shape_uses_the_path` | the same shape without the volatile term does use it |
+
+## 305. test_native_reclaim_reconcile.py: a reuse op drops the free_space row that overlaps a live group, and only that row
+
+The twin of `test/native_reclaim_reconcile.sh` (Phase F reconcile). A free_space row synthesized over a live group, the post-crash state end-truncation can leave, is dropped by compact_rewrite, compact and truncate, the data still equals a heap mirror after each, the legitimate free ranges come through a plain compact unchanged, and reuse keeps the data right for two more cycles.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order, each statement in its own autocommit session as the shell's psql -c runs it. Readings are values: a failed query is a query_error(), so two failed reads never agree, and the row sets are compared as an md5 over the sorted rows.
+
+| test | what it holds |
+| --- | --- |
+| `test_rr_parity_before` | parity before |
+| `test_rr_stale_row_injected` | stale overlapping free row injected |
+| `test_rr_compact_rewrite_reconciled` | compact_rewrite reconciled the stale row |
+| `test_rr_data_intact_after_rewrite` | data intact after reconcile + rewrite |
+| `test_rr_row_count_intact` | row count intact |
+| `test_rr_a_legitimate_free_range_exists` | compact_rewrite recorded the free ranges it left |
+| `test_rr_plain_compact_reconciled` | plain compact reconciled the stale row |
+| `test_rr_plain_compact_kept_the_legitimate_ranges` | plain compact kept the legitimate free ranges, which overlap no live group |
+| `test_rr_data_intact_after_compact` | data intact after compact reconcile |
+| `test_rr_truncate_reconciled` | truncate reconciled the stale row |
+| `test_rr_data_intact_after_truncate` | data intact after truncate reconcile |
+| `test_rr_parity_after_reuse_cycle_1` | parity after reuse cycle 1 |
+| `test_rr_parity_after_reuse_cycle_2` | parity after reuse cycle 2 |
+
+## 306. test_native_page_offset_bound.py: a corrupt page_offset is refused by the containment guard on both reads, and an intact table still reads
+
+The twin of `test/native_page_offset_bound.sh`. A 20000-row table is corrupted one column_chunk.page_offset at a time (past the group, to 0 so the subtraction wraps, and to -1 so it arrives near UINT64_MAX), and read through the whole-group read (column projection off) and the projected read; each read must be refused by the chunk containment guard, the backend must survive, and an intact table must still read every row on both paths.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. A refusal reads as "XX001 (containment guard)" only when the primary message is the guard's; survival also reads the server log's count of backends killed by a signal since the arm began; a failed reading is a `query_error()`.
+
+| test | what it holds |
+| --- | --- |
+| `test_an_intact_table_reads_every_row_with_projection_off` | an intact table reads every row and value with projection off |
+| `test_an_intact_tables_projected_read_returns_every_id` | an intact table's projected read returns every id |
+| `test_a_projected_page_offset_overrun_is_refused_by_the_guard` | projected page_offset overrun is refused by the containment guard |
+| `test_the_backend_survives_the_projected_overrun` | alive after projected overrun |
+| `test_a_whole_group_page_offset_overrun_is_refused_by_the_guard` | whole-group page_offset overrun is refused by the containment guard |
+| `test_the_backend_survives_the_whole_group_overrun` | alive after whole-group overrun |
+| `test_a_whole_group_page_offset_of_zero_is_refused_by_the_guard` | whole-group page_offset=0 is refused by the containment guard |
+| `test_the_backend_survives_the_whole_group_wrap` | alive after whole-group wrap |
+| `test_a_whole_group_page_offset_of_minus_one_hits_the_guard` | whole-group page_offset=-1 hits the containment guard |
+| `test_the_backend_survives_the_whole_group_minus_one` | alive after whole-group page_offset=-1 |
+| `test_a_projected_page_offset_of_minus_one_hits_the_guard` | projected page_offset=-1 hits the containment guard |
+| `test_the_backend_survives_the_projected_minus_one` | alive after projected page_offset=-1 |
+
+## 307. test_native_param_pushdown.py: a parameterized qual prunes chunk groups, and a correlated one is never frozen
+
+The twin of `test/native_param_pushdown.sh`. A generic-plan prepared statement's `ts >= $1` prunes 19 of 20 monotonic chunk groups and counts what the literal counts; a second execution in the same session counts and prunes with its own value; a correlated nestloop param builds no skip predicate, and both its lower- and upper-bound answers stay right.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. A failed reading is a `query_error()`, a plan line that is missing is a failed reading, and survival also requires the server log's crash count to be unchanged.
+
+| test | what it holds |
+| --- | --- |
+| `test_param_pushdown_a_generic_plan_parameterized_qual_prunes` | a generic-plan parameterized qual prunes chunk groups (19 of 20 removed) |
+| `test_param_pushdown_the_parameterized_count_matches_the_literal` | parameterized count matches the literal count |
+| `test_param_pushdown_the_literal_count_is_the_expected_value` | literal count is the expected value |
+| `test_param_pushdown_the_correlated_arm_rescans_the_columnar_scan` | the correlated arm rescans the columnar scan once per outer row |
+| `test_param_pushdown_a_correlated_param_exec_builds_no_skip_predicate` | a correlated PARAM_EXEC builds no skip predicate |
+| `test_param_pushdown_a_correlated_param_exec_is_not_mis_pruned` | a correlated PARAM_EXEC is not mis-pruned (results stay correct) |
+| `test_param_pushdown_a_correlated_upper_bound_is_not_mis_pruned` | a correlated PARAM_EXEC upper bound is not mis-pruned either |
+| `test_param_pushdown_a_second_execution_counts_with_its_own_value` | a second execution in the same session counts with its own value |
+| `test_param_pushdown_a_second_execution_prunes_with_its_own_value` | and prunes with its own value too (18 of 20 removed) |
+| `test_param_pushdown_the_backend_is_alive` | backend alive |
+
+## 308. test_native_sort_by.py: the declared sort_by key orders the rows it says, and each refusal is its own guard's
+
+The twin of `test/native_sort_by.sh`. A 40,960-row table with 50 hosts cycled in insert order is sorted by its declared key (host, ts) with the zero-argument vacuum_sorted: heap parity holds before and after, group skipping on a host appears, the rows lie in (host, ts) order in scan order, and explicit columns lay them out the same way. The key round-trips and resets, and no key, a bad column, a dropped declared column and (PG18+) a virtual generated column are each refused by their own guard, with the backend still up.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. A refusal reads as ' ', a failed reading is a `query_error()`, and every reading after a refusal first waits for the server, so a crash reds its own arm and the survival check. Before PostgreSQL 18 the virtual arm declines, as the shell records its skip.
+
+| test | what it holds |
+| --- | --- |
+| `test_nsb_row_count` | row count |
+| `test_nsb_the_plan_under_test_is_a_columnar_custom_scan` | the plan under test is a columnar custom scan |
+| `test_nsb_equality_parity_pre_sort` | equality parity (pre-sort) |
+| `test_nsb_the_scan_order_starts_unsorted` | premise: before sorting, the scan order does not follow (host, ts) |
+| `test_nsb_declared_sort_by_round_trips` | declared sort_by round-trips |
+| `test_nsb_row_count_after_sort` | row count after sort |
+| `test_nsb_full_table_parity_after_sort` | full-table parity after sort |
+| `test_nsb_equality_parity_post_sort` | equality parity (post-sort) |
+| `test_nsb_sorting_a_text_key_enables_group_skipping` | sorting a text key enables group skipping |
+| `test_nsb_the_scan_order_follows_the_declared_key` | the scan order follows the declared key (host, ts) |
+| `test_nsb_explicit_column_sort_parity` | explicit-column sort parity |
+| `test_nsb_explicit_columns_lay_the_rows_out_as_the_declared_key_does` | explicit columns lay the rows out as the declared key does |
+| `test_nsb_sort_by_cleared` | sort_by cleared |
+| `test_nsb_zero_arg_errors_when_no_key_declared` | zero-arg errors when no key declared |
+| `test_nsb_set_options_rejects_a_nonexistent_sort_by_column` | set_options rejects a nonexistent sort_by column |
+| `test_nsb_zero_arg_errors_on_a_dropped_declared_column` | zero-arg errors on a dropped declared column |
+| `test_nsb_empty_table_sorts_without_error` | empty table sorts without error |
+| `test_nsb_single_row_table_sorts_without_error` | single-row table sorts without error |
+| `test_nsb_single_row_parity` | single-row parity |
+| `test_nsb_set_options_rejects_a_virtual_generated_sort_by_column` | set_options rejects a virtual generated sort_by column |
+| `test_nsb_vacuum_sorted_rejects_an_explicit_virtual_generated_column` | vacuum_sorted rejects an explicit virtual generated column |
+| `test_nsb_backend_still_up_after_the_refusals` | backend still up after the refusals |
+
+## 309. test_native_rewrite.py: compact_rewrite reclaims partially-deleted groups online, and vacuum's rewrite keeps every row reachable
+
+The twin of `test/native_rewrite.sh` (Phase F3b). compact_rewrite rewrites groups past the deleted-fraction threshold, and only those, into fresh groups with fresh row numbers under ShareUpdateExclusiveLock, with results equal to a heap mirror written from the generator; index scans and point lookups stay right; group numbers are never reused; and pgcolumnar.vacuum rewrites into fresh storage with every row reachable, through the index as well.
+
+### Every test
+
+Every test makes one check under the shell's name, from one module fixture that takes every reading in the shell's order on a private schema. Readings are values: a failed query is a query_error(), so two failed reads never agree, counts are compared as numbers, and the duplicate must be refused with 23505 by n_id_uq itself. The index after vacuum is read over a narrow range, because over the whole table stale entries still land on every row once.
+
+| test | what it holds |
+| --- | --- |
+| `test_row_count_after_delete` | row count after delete |
+| `test_there_are_deleted_rows_to_reclaim` | there are deleted rows to reclaim |
+| `test_parity_after_delete` | parity after delete |
+| `test_rewrote_some_groups` | rewrote some groups |
+| `test_dead_row_space_reclaimed` | dead-row space reclaimed |
+| `test_parity_after_rewrite` | parity after rewrite |
+| `test_row_count_after_rewrite` | row count after rewrite |
+| `test_a_second_rewrite_with_nothing_deleted_rewrites_no_group` | a second rewrite with nothing deleted rewrites no group |
+| `test_a_group_deleted_below_the_threshold_is_not_rewritten` | a group deleted below the threshold is not rewritten |
+| `test_index_scan_returns_each_live_row_once` | index scan returns each live row once |
+| `test_live_id_present_via_index` | live id present via index |
+| `test_deleted_id_absent_via_index` | deleted id absent via index |
+| `test_unique_constraint_still_enforced` | unique constraint still enforced |
+| `test_compact_rewrite_holds_share_update_exclusive_lock` | compact_rewrite holds ShareUpdateExclusiveLock |
+| `test_compact_rewrite_holds_no_access_exclusive_lock` | compact_rewrite holds no AccessExclusiveLock |
+| `test_a_rewrite_plus_later_writes_reuse_no_group_number` | a rewrite plus later writes reuse no group number |
+| `test_group_numbers_issued_after_a_rewrite_are_past_every_earlier_one` | group numbers issued after a rewrite are past every earlier one |
+| `test_vacuum_rewrites_into_a_fresh_storage_id` | vacuum rewrites into a fresh storage id |
+| `test_every_live_row_still_reads_back_after_rewrite_and_vacuum` | every live row still reads back after rewrite and vacuum |
+| `test_an_index_range_read_after_vacuum_returns_the_rows_asked_for` | an index range read after vacuum returns the rows asked for |
+
+## 310. test_native_zonemap.py: zone-map rows carry exact counts, sums and the right bounds, by value
+
+The twin of `test/native_zonemap.sh`. On a four-column table of three row groups of two vectors each, the pgcolumnar.zone_map rows are checked against a heap mirror written from the same generator: their number, value and null counts, sums, the presence and width of their bounds, and each bound of id, k and v decoded and compared with the heap's own for its group and vector; the read path still matches the heap, and the rows go with the table.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. Each reading opens its own connection and returns a failure as a `query_error()`, so two failed reads never agree and a count that cannot be read is never 0.
+
+| test | what it holds |
+| --- | --- |
+| `test_zonemap_row_count` | row count |
+| `test_zonemap_read_parity` | read parity (no regression) |
+| `test_zonemap_whole_chunk_zone_rows` | whole-chunk zone rows |
+| `test_zonemap_per_vector_zone_rows` | per-vector zone rows |
+| `test_zonemap_whole_chunk_rows_cover_their_group` | whole-chunk value+null = group rows |
+| `test_zonemap_each_vector_covers_1024` | per-vector value+null = 1024 |
+| `test_zonemap_value_count_sums_for_nullable_v` | value_count sums (v, nullable) |
+| `test_zonemap_null_count_sums_for_nullable_v` | null_count sums (v, nullable) |
+| `test_zonemap_value_count_sums_for_id` | value_count sums (id, no nulls) |
+| `test_zonemap_null_count_zero_for_id` | null_count zero (id, no nulls) |
+| `test_zonemap_vector_value_counts_fold_into_the_chunk` | per-vector value_count folds into chunk (k) |
+| `test_zonemap_min_max_present_for_all_columns` | min/max present for all columns |
+| `test_zonemap_int4_min_width` | id (int4) min width = 4 bytes |
+| `test_zonemap_int8_max_width` | k (int8) max width = 8 bytes |
+| `test_zonemap_v_is_not_ascending_within_a_vector` | premise: v is not ascending within a vector, so a stale minimum would show |
+| `test_zonemap_every_vector_minimum_is_the_heaps` | every vector's minimum of id, k and v is the heap's |
+| `test_zonemap_every_vector_maximum_is_the_heaps` | every vector's maximum of id, k and v is the heap's |
+| `test_zonemap_every_chunk_minimum_is_the_heaps` | every whole-chunk minimum of id, k and v is the heap's |
+| `test_zonemap_every_chunk_maximum_is_the_heaps` | every whole-chunk maximum of id, k and v is the heap's |
+| `test_zonemap_sum_present_for_int4_columns` | sum present for int4 columns |
+| `test_zonemap_sum_null_for_other_columns` | sum null for non-int4 columns |
+| `test_zonemap_whole_chunk_sum_of_id_matches_heap` | whole-chunk sum(id) matches heap |
+| `test_zonemap_zone_maps_go_with_the_table` | zone maps cleaned on drop |
+
+## 311. test_native_zonemap_session.py: a scan resolves zone_map once, and each scan holds its own session
+
+The twin of `test/native_zonemap_session.sh`. The witness is the DEBUG1 line each scan's zone-map session emits when it closes, `probes=N opens=M`: over 40 chunk groups a scan probes once per group and opens zone_map once, a second predicate column adds one probe and no open, the two scans of a join each report their own groups, and the scans around one that errors inside a subtransaction each open for themselves and probe only their own groups.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. Each reading returns a failure as a `query_error()`, and the statements around the aborted scan run one by one, continuing past the error as psql does with ON_ERROR_STOP=0.
+
+| test | what it holds |
+| --- | --- |
+| `test_native_zonemap_session_fixture_has_several_groups` | the fixture has more than one chunk group |
+| `test_native_zonemap_session_scan_probes_once_per_group` | the scan really probed the zone maps, once per group |
+| `test_native_zonemap_session_scan_resolves_zone_map_once` | the scan resolves zone_map ONCE, not once per group (#744) |
+| `test_native_zonemap_session_second_column_still_opens_once` | a second predicate column still resolves zone_map once |
+| `test_native_zonemap_session_short_circuit_adds_one_probe` | and the short circuit means it adds one probe, not one per group |
+| `test_native_zonemap_session_zs2_has_several_groups` | zs2 has more than one chunk group |
+| `test_native_zonemap_session_nested_scans_answer` | nested columnar scans return the right answer |
+| `test_native_zonemap_session_nested_scans_hold_their_own_handle` | nested columnar scans each hold their own handle |
+| `test_native_zonemap_session_middle_scan_errored` | the middle scan really did error inside the subtransaction |
+| `test_native_zonemap_session_every_scan_reports_its_own_session` | every scan around an aborted one reports its own session (#744) |
+| `test_native_zonemap_session_each_scan_opened_once` | and each of them opened exactly once |
+| `test_native_zonemap_session_each_scan_probed_its_own_groups` | and each of them probed exactly its own groups |
+| `test_native_zonemap_session_scan_after_abort_answers` | the scan after the subtransaction abort returns the right answer |
+
+## 312. test_native_zonemap_narrow.py: group skipping reads only the predicate columns' zone maps, and still prunes
+
+The twin of `test/native_zonemap_narrow.sh`. A one-predicate scan of a 30-column table reads about as many pgcolumnar.zone_map tuples as the same scan of a 2-column table (pg_stat_all_tables.idx_tup_fetch, flushed with pg_stat_force_next_flush); both answer correctly; and c1 > 19000 removes nine of the ten groups on each table, read from the scan's own counter.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. Each reading opens its own connection, as the shell's psql calls do, and returns a failure as a `query_error()`, so two failed reads never agree and an unread count is never 0.
+
+| test | what it holds |
+| --- | --- |
+| `test_zone_map_reads_scale_with_predicates_not_table_width` | zone_map reads scale with predicates, not table width (wide ~= narrow) |
+| `test_the_wide_scan_result_is_correct` | wide scan result is correct |
+| `test_the_narrow_scan_result_is_correct` | narrow scan result is correct |
+| `test_a_pruning_predicate_still_prunes_on_the_wide_table` | a pruning predicate still prunes on the wide table |
+| `test_the_zone_map_removes_nine_groups_on_the_wide_table` | the zone map removes the nine groups c1 > 19000 cannot match, on the wide table |
+| `test_the_zone_map_removes_nine_groups_on_the_narrow_table` | and on the narrow table |
+
+## 313. test_native_vecdecode.py: decode honours the skip vector, and the fold reads neither holes nor nulls as values
+
+The twin of `test/native_vecdecode.sh`. One row group of 32 1024-row vectors with a monotonic id, so a narrow range rules most vectors out: "Columnar Vectors Decoded" must move by exactly the skipped count, every range must match a heap mirror written from the generator, and the batch fold must answer correctly over skipped vectors and over a nullable column.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database built once in the shell's order. Each counter is read from the plan text and a missing one is a `query_error()`, never 0; every comparison of two readings refuses a failed side. The fold arm runs with the shell's SET statements.
+
+| test | what it holds |
+| --- | --- |
+| `test_vecdecode_narrow_plan_is_the_custom_scan` | the narrow plan is a columnar custom scan |
+| `test_vecdecode_wide_plan_is_the_custom_scan` | the wide plan is a columnar custom scan |
+| `test_vecdecode_one_row_group` | one row group, so every difference is per-vector |
+| `test_vecdecode_no_whole_group_pruned` | no whole group is pruned on the narrow query |
+| `test_vecdecode_narrow_range_skips_vectors` | the narrow range skips vectors |
+| `test_vecdecode_wide_range_skips_none` | the wide range skips none, so the arms really differ |
+| `test_vecdecode_explain_reports_vectors_decoded` | EXPLAIN reports Columnar Vectors Decoded |
+| `test_vecdecode_full_scan_decodes_something` | and it is not stuck at zero when a full scan decodes everything |
+| `test_vecdecode_ruled_out_vector_not_decoded` | a ruled-out vector is not decoded |
+| `test_vecdecode_decoded_plus_skipped_is_the_groups_vectors` | and EVERY ruled-out vector is: decoded plus skipped is the group's vectors |
+| `test_vecdecode_narrow_range_parity` | narrow range parity with heap |
+| `test_vecdecode_cross_vector_parity` | cross-vector range parity |
+| `test_vecdecode_boundary_range_parity` | boundary range parity, a range starting exactly on a vector edge |
+| `test_vecdecode_nullable_column_has_nulls` | premise: the nullable column has nulls inside the ranges read below |
+| `test_vecdecode_nullable_cross_vector_parity` | cross-vector parity of a nullable column, nulls included |
+| `test_vecdecode_aggregate_over_skipping_range` | aggregate over a skipping range matches heap |
+| `test_vecdecode_aggregate_over_many_skipped_vectors` | aggregate over a range spanning many skipped vectors matches heap |
+| `test_vecdecode_range_matching_no_row` | a range matching no row at all is still correct |
+| `test_vecdecode_aggregate_reaches_the_fold` | the aggregate really reaches the batch fold |
+| `test_vecdecode_fold_answers_over_skipping_range` | the fold answers correctly over a range that skips vectors |
+| `test_vecdecode_nullable_aggregate_reaches_the_fold` | a nullable aggregate reaches the fold |
+| `test_vecdecode_fold_nullable_count_and_sum` | the fold answers count and sum of a nullable column over a skipping range |
+| `test_vecdecode_deep_range_reaches_the_fold` | the deep range reaches the fold too |
+| `test_vecdecode_deep_range_agrees_with_heap` | and it agrees with heap after many leading skipped vectors |
+| `test_vecdecode_poison_range_reaches_the_fold` | the poison-accepting range reaches the fold |
+| `test_vecdecode_fold_arm_decodes_only_what_it_needs` | the FOLD ARM really decodes only the vectors it needs |
+| `test_vecdecode_fold_counter_reads_every_vector_when_none_skipped` | control: and the same counter reads every vector when nothing is skipped |
+| `test_vecdecode_fold_does_not_count_undecoded_rows` | the fold does not count rows from a vector it never decoded |
+
+## 314. test_native_parquet_fieldid.py: read_parquet binds output columns by Parquet field id, and refuses each misuse by its own guard
+
+The twin of `test/native_parquet_fieldid.sh`. pyarrow writes a file whose field ids are out of order and disjoint from position (alpha=7, beta=3, gamma=12), one with no field ids, and one where two columns share an id. read_parquet(path, field_ids) must bind and reorder by id, read a subset, leave the positional read alone, and refuse an absent id, a duplicate id in the file, a non-scalar output column, a count mismatch, an id-less file and a duplicate requested id, each with its own guard's message; the backend must survive.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. The writer runs as a subprocess, so the module imports no pyarrow, and every test declines under the shell's skip name before it asks for the fixture. A refusal reads as ' ()' only when the primary message is the guard's own; every reading waits for the server first; survival also requires that the server log's count of killed backends has not moved.
+
+| test | what it holds |
+| --- | --- |
+| `test_fieldid_projection_binds_and_reorders_by_id` | field-id projection binds and reorders by id (g=gamma, a=alpha) |
+| `test_fieldid_projection_reads_a_subset` | field-id projection reads a subset (only id 3 = beta) |
+| `test_positional_read_parquet_is_unaffected_by_fieldid_binding` | positional read_parquet is unaffected |
+| `test_an_absent_field_id_is_refused` | an absent field id is refused (22023) |
+| `test_a_duplicate_field_id_in_the_parquet_file_is_refused` | a duplicate field id in the file is refused (42702) |
+| `test_a_nested_fieldid_output_column_is_refused` | a nested/array output column is refused (0A000) |
+| `test_a_field_ids_column_count_mismatch_is_refused` | a field_ids/column-count mismatch is refused (22023) |
+| `test_a_parquet_file_with_no_field_ids_is_refused` | a file with no field ids is refused (22023) |
+| `test_a_duplicate_requested_field_id_is_refused` | a duplicate requested field id is refused (22023) |
+| `test_the_duplicate_requested_id_error_names_the_id` | the duplicate-requested-id error names the id, not the file |
+| `test_the_backend_survives_the_fieldid_refusals` | backend still up after the refusals |
+
+## 315. test_native_parquet_codecs.py: GZIP, ZSTD and LZ4_RAW pages decode to the generator's rows, and a crafted page size is refused
+
+The twin of `test/native_parquet_codecs.sh`. `test/craft_parquet_codecs.py`, the shell's own crafter, writes the same 20000 rows with each codec plus an uncompressed control, and a gzip page whose header declares an uncompressed size of -1. Each file's codec is read from its own metadata; every file decodes through `read_parquet` to the generator's rows, built in SQL; each FDW count and a sum read every value; and the crafted header is refused with the reader's own decode error, killing no backend.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. Each test declines under the shell's pyarrow skip name before it asks for the fixture, which runs the crafter as a subprocess, so the fixture never imports pyarrow. A failed reading is a `query_error()`, and survival also requires the server log's count of backends killed by a signal not to have moved.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_uncompressed_file_is_uncompressed` | premise: the uncompressed file is UNCOMPRESSED |
+| `test_the_gzip_file_is_gzip_compressed` | premise: the gzip file is GZIP-compressed |
+| `test_the_zstd_file_is_zstd_compressed` | premise: the zstd file is ZSTD-compressed |
+| `test_the_lz4_file_is_lz4_compressed` | premise: the lz4 file is LZ4-compressed |
+| `test_uncompressed_decodes_to_the_generators_rows` | uncompressed decodes to the generator's rows |
+| `test_gzip_decodes_to_the_generators_rows` | gzip decodes to the generator's rows |
+| `test_zstd_decodes_to_the_generators_rows` | zstd decodes to the generator's rows |
+| `test_lz4_decodes_to_the_generators_rows` | lz4 decodes to the generator's rows |
+| `test_the_gzip_fdw_counts_every_row` | gzip FDW count |
+| `test_the_zstd_fdw_counts_every_row` | zstd FDW count |
+| `test_the_lz4_fdw_counts_every_row` | lz4 FDW count |
+| `test_the_gzip_sum_matches_the_generator` | gzip sum matches the generator |
+| `test_a_crafted_uncompressed_size_is_a_clean_decode_error` | crafted uncompressed size gives a clean decode error |
+| `test_the_backend_survives_the_crafted_header` | backend survived the crafted header |
+
+## 316. test_native_parquet_units.py: TIME and TIMESTAMP units decode to the right instant, and their stats skip correctly
+
+The twin of `test/native_parquet_units.sh`. pyarrow writes one instant as TIMESTAMP millis, micros and nanos, a time as TIME micros and millis, a millis value whose conversion overflows, two nanos values with sub-microsecond digits (one pre-epoch), and a two-group millis file; each reads back to the right value through the FDW, read_parquet and import_parquet, parquet_schema advises the lossless type, the overflow raises 22008, and the millis stats skip the group a predicate cannot match.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database, and declines first under the shell's pyarrow skip name. Each reading returns a failure as a `query_error()`, so two failed reads never agree. The truncation checks want literals and the two-group file is compared with the generator's series, so no oracle is read through the reader under test.
+
+| test | what it holds |
+| --- | --- |
+| `test_ts_ms_decodes_to_the_right_instant` | ts_ms decodes to the right instant |
+| `test_ts_us_decodes_to_the_right_instant` | ts_us decodes to the right instant |
+| `test_ts_ns_decodes_to_the_right_instant` | ts_ns decodes to the right instant |
+| `test_time_micros_decodes_to_the_right_time` | time micros decodes to the right time |
+| `test_time_millis_decodes_to_the_right_time` | time millis decodes to the right time |
+| `test_parquet_schema_advises_time_for_time_millis` | parquet_schema advises time for TIME_MILLIS |
+| `test_parquet_schema_advises_timestamp_for_millis` | parquet_schema advises timestamp for millis |
+| `test_parquet_schema_advises_timestamp_for_micros` | parquet_schema advises timestamp for micros |
+| `test_parquet_schema_advises_bigint_for_nanos` | parquet_schema advises bigint for nanos (lossless) |
+| `test_nanos_read_as_bigint_is_exact` | nanos read as bigint is exact |
+| `test_millis_to_micros_overflow_raises_22008` | millis-to-micros overflow raises 22008 |
+| `test_read_parquet_honours_the_unit` | read_parquet honours the unit |
+| `test_import_parquet_honours_the_unit` | import_parquet honours the unit |
+| `test_millis_equals_micros` | millis == micros |
+| `test_nanos_equals_micros` | nanos == micros |
+| `test_nanos_sub_us_truncates_toward_zero_frac` | nanos sub-us truncates toward zero [frac] |
+| `test_nanos_sub_us_truncates_toward_zero_neg` | nanos sub-us truncates toward zero [neg] |
+| `test_millis_scaled_stats_skip_the_non_matching_group` | millis-scaled stats skip the non-matching group |
+| `test_the_millis_file_reads_back_exactly_as_written` | the millis file reads back exactly as written |
+| `test_millis_pushdown_result_equals_the_oracle` | millis pushdown result == oracle |
+
+## 317. test_native_parquet_projection.py: a projected Parquet scan decodes only the columns it references, and reads the generator's rows
+
+The twin of `test/native_parquet_projection.sh`. `test/craft_parquet_projection.py`, the shell's own crafter, writes a four-column file and a two-column file whose second column's data page is smashed. Each projection, a qual on an unselected column, and a projection behind a dropped column return the generator's rows, built in SQL; the EXPLAIN "Columns Read" counter reflects each projection; and the smashed column is never decoded when projected out.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. Each test declines under the shell's pyarrow skip name before it asks for the fixture, which runs the crafter as a subprocess, so the fixture never imports pyarrow. A failed reading is a `query_error()`, and a setup step that fails is not raised: every reading then fails by its own name.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_a_projection_matches_the_oracle` | projection [a] == oracle |
+| `test_the_c_projection_matches_the_oracle` | projection [c] == oracle |
+| `test_the_a_d_projection_matches_the_oracle` | projection [a, d] == oracle |
+| `test_the_b_c_d_projection_matches_the_oracle` | projection [b, c, d] == oracle |
+| `test_the_star_projection_matches_the_oracle` | projection [*] == oracle |
+| `test_a_qual_on_an_unselected_column_matches_the_oracle` | qual on unselected column == oracle |
+| `test_one_column_reads_one` | one column reads 1 |
+| `test_two_columns_read_two` | two columns read 2 |
+| `test_select_star_reads_four` | select star reads 4 |
+| `test_count_star_reads_zero` | count(*) reads 0 |
+| `test_after_a_dropped_column_the_d_projection_matches_the_oracle` | after a dropped column, projection [d] == oracle |
+| `test_after_a_dropped_column_one_column_reads_one` | after a dropped column, one column reads 1 |
+| `test_a_selected_column_and_a_qual_column_read_two` | selected 1 + qual 1 reads 2 |
+| `test_selecting_the_good_column_skips_the_corrupt_one` | selecting the good column skips the corrupt one |
+| `test_count_star_decodes_nothing_and_survives_the_corruption` | count(*) decodes nothing, survives corruption |
+| `test_selecting_the_corrupt_column_does_error` | selecting the corrupt column does error |
+| `test_the_good_column_values_are_still_correct` | good-column values are still correct |
+
+## 318. test_native_parquet_hardening.py: the Parquet reader refuses values that do not fit their column, and a row group with too few chunks
+
+The twin of `test/native_parquet_hardening.sh`. `test/craft_parquet_hardening.py`, the shell's own crafter, writes the narrowing files (int2 and date over INT32, time and timestamp over INT64, each out of range and in range) and a two-column file whose row group carries one column chunk. Each out-of-range value must raise with its type's own message rather than wrap, each in-range value must decode to exactly what was written, read_parquet and import_parquet share the decoder, the short chunk list must be refused by the chunk-count guard itself, and the backend must survive.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. The crafter runs as a subprocess, so the module imports no pyarrow, and every test declines under the shell's skip name before it asks for the fixture. A refusal reads as 'SQLSTATE: message' through the shell's own pgc_try; every reading waits for the server first; survival also requires that the server log's count of killed backends has not moved.
+
+| test | what it holds |
+| --- | --- |
+| `test_parquet_hardening_int2_out_of_range_raises_22003` | int2 out of range -> 22003 |
+| `test_parquet_hardening_time_out_of_day_raises_22008` | time out of day -> 22008 |
+| `test_parquet_hardening_invalid_date_raises_22008` | date invalid -> 22008 |
+| `test_parquet_hardening_date_offset_overflow_raises_22008` | date offset overflow -> 22008 |
+| `test_parquet_hardening_timestamp_overflow_raises_22008` | timestamp overflow -> 22008 |
+| `test_parquet_hardening_int2_in_range_still_reads` | int2 in range still reads |
+| `test_parquet_hardening_time_in_range_still_reads` | time in range still reads |
+| `test_parquet_hardening_date_in_range_still_reads` | date in range still reads |
+| `test_parquet_hardening_timestamp_in_range_still_reads` | timestamp in range still reads |
+| `test_parquet_hardening_no_wrapped_value_appears` | no wrapped -25536 appears |
+| `test_parquet_hardening_read_parquet_also_raises_22003` | read_parquet also raises 22003 |
+| `test_parquet_hardening_read_parquet_in_range_still_reads` | read_parquet in range still reads |
+| `test_parquet_hardening_import_parquet_also_raises_22003` | import_parquet also raises 22003 |
+| `test_parquet_hardening_import_parquet_left_no_wrapped_rows` | import_parquet left no wrapped rows |
+| `test_parquet_hardening_short_chunk_list_is_a_clean_reject` | short chunk list rejected with a specific error |
+| `test_parquet_hardening_backend_survives_the_short_chunk_list` | backend survived short chunk list |
+| `test_parquet_hardening_parquet_schema_still_describes_the_bad_file` | parquet_schema still describes a bad chunk list |
+| `test_parquet_hardening_a_valid_two_column_file_still_reads` | valid two-column file still reads |
+
+## 319. test_native_parquet_stack.py: a crafted Parquet footer trips the recursion guard instead of crashing the backend
+
+The twin of `test/native_parquet_stack.sh`. Two hand-crafted footers (no writer makes them, so no pyarrow) each drive one of the footer parser's two recursions 20000 deep: a run of Thrift struct openers through `PgColumnarThriftSkip`, and a linear schema chain through `walk_schema`. Under a pinned `max_stack_depth` each must raise 54001 rather than SIGSEGV, a shallow valid chain must still describe its one column, and `read_parquet` must refuse the crash input the same way `parquet_schema` does.
+
+### Every test
+
+Every test makes one check under the shell's name. Each parquet_schema or read_parquet call runs on its own connection with max_stack_depth pinned, and a failed or unreachable reading is a `query_error()`, so a crash shows as the survival check reading something other than 1 rather than as an unnamed error. Each survival test runs its own trigger with a crash baseline taken just before it, waits for the server, and wants SELECT 1 and an unchanged count of `terminated by signal` in the server log, since a bare SELECT 1 answers again once crash recovery is over (#1708 review).
+
+| test | what it holds |
+| --- | --- |
+| `test_nested_struct_footer_raises_stack_depth` | nested-struct footer raises stack-depth, not SIGSEGV |
+| `test_backend_survived_the_nested_struct_footer` | backend survived the nested-struct footer |
+| `test_schema_chain_footer_raises_stack_depth` | schema-chain footer raises stack-depth, not SIGSEGV |
+| `test_backend_survived_the_schema_chain_footer` | backend survived the schema-chain footer |
+| `test_a_shallow_valid_schema_chain_still_reads` | a shallow valid schema chain still reads |
+| `test_backend_survived_the_valid_file` | backend survived the valid file |
+| `test_read_parquet_rejects_the_nested_struct_footer` | read_parquet rejects the nested-struct footer the same way |
+| `test_backend_survived_read_parquet_on_the_crash_input` | backend survived read_parquet on the crash input |
+
+## 320. test_native_parquet_streaming.py: external Parquet reads stream: an oversized file describes itself, crafted pages are refused, the FDW decodes one row group at a time
+
+The twin of `test/native_parquet_streaming.sh`. `test/craft_parquet_streaming.py`, now shared by both harnesses, writes a 1600MB sparse file carrying a small file's footer (over the old 1GB palloc ceiling), six crafted files that each break one thing the page loop must not trust, an all-null column, and a four-row-group file for the FDW's Row Groups Decoded counter. The footer path reads the oversized file; each crafted page area is refused by the page loop's own decode refusal; the backend survives; and a LIMIT decodes only the row groups it needs.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. The crafter runs as a subprocess, so the module imports no pyarrow; every test declines under the shell's skip name before it asks for the fixture, and on a box that cannot build the sparse file under the shell's other skip name. Every reading waits for the server first; survival also requires that the server log's count of killed backends has not moved since its section began. One extra test mirrors 'crafted files were built', which the shell records only when the crafter fails.
+
+| test | what it holds |
+| --- | --- |
+| `test_parquet_streaming_crafted_files_were_built` | crafted files were built |
+| `test_parquet_streaming_schema_reads_a_file_over_the_palloc_ceiling` | parquet_schema reads a file over the 1GB palloc ceiling |
+| `test_parquet_streaming_schema_reports_the_oversized_files_columns` | parquet_schema reports the columns of the oversized file |
+| `test_parquet_streaming_value_read_of_the_sparse_file_fails_cleanly` | value read of the sparse file fails cleanly |
+| `test_parquet_streaming_backend_survived_the_failed_read` | backend survived the failed read |
+| `test_parquet_streaming_schema_still_reads_a_normal_file` | parquet_schema still reads a normal file |
+| `test_parquet_streaming_read_parquet_still_reads_a_normal_file` | read_parquet still reads a normal file |
+| `test_parquet_streaming_zero_filled_page_area_is_rejected` | zero-filled page area is rejected, not walked byte by byte |
+| `test_parquet_streaming_page_size_past_eof_is_rejected` | page size past end of file is rejected by the size bound |
+| `test_parquet_streaming_negative_page_offset_is_rejected` | negative page offset is rejected by the offset check |
+| `test_parquet_streaming_unparseable_page_header_is_rejected` | unparseable page header is rejected |
+| `test_parquet_streaming_second_dictionary_page_is_rejected` | a second dictionary page in a chunk is rejected |
+| `test_parquet_streaming_v2_level_lengths_past_the_page_are_rejected` | v2 level lengths past the page are rejected |
+| `test_parquet_streaming_all_null_column_still_reads` | all-null column (empty dictionary page) still reads |
+| `test_parquet_streaming_backend_survived_the_crafted_page_areas` | backend survived the crafted page areas |
+| `test_parquet_streaming_uncompressed_source_file_still_reads` | the uncompressed source file still reads |
+| `test_parquet_streaming_full_scan_content_is_correct` | streaming: full scan content is correct |
+| `test_parquet_streaming_full_scan_decodes_all_four_row_groups` | streaming: a full scan decodes all four row groups |
+| `test_parquet_streaming_limit_10_decodes_one_row_group` | streaming: LIMIT 10 (inside group 0) decodes one row group |
+| `test_parquet_streaming_limit_1010_decodes_two_row_groups` | streaming: LIMIT 1010 (into group 1) decodes two row groups |
+| `test_parquet_streaming_rescan_under_a_nested_loop_is_correct` | streaming: rescan under a nested loop is correct |
+
+## 321. test_native_parquet_partition.py: Hive-style partition columns materialize and prune whole files, and a tree that does not match fails loudly
+
+The twin of `test/native_parquet_partition.sh`. pyarrow writes a four-file dt=/region= tree and a percent-encoded tree. The partition values must materialize as typed columns; a predicate on a partition column must drop whole files before they are opened (the EXPLAIN Files and Files Pruned counters); partition and file predicates must combine; a volatile clause must never decide a whole file; a tree that does not match the declaration must fail with its own guard's message; Hive encoding (percent escapes, the null marker) must decode, and an encoded NUL or an invalid byte must be refused.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. The writer runs as a subprocess, so the module imports no pyarrow, and every test declines under the shell's skip name before it asks for the fixture. A pruning count is 'no reading' unless the plan's Files line shows the scan ran; a refusal reads as ' (its own message)' only when the primary message is the guard's own; every reading waits for the server; survival also requires that the server log's count of killed backends has not moved.
+
+| test | what it holds |
+| --- | --- |
+| `test_all_four_partitions_read` | all four partitions read |
+| `test_partition_values_materialize` | partition values materialize |
+| `test_a_partition_column_is_typed_not_text` | partition column is typed, not text |
+| `test_group_by_a_partition_column` | group by a partition column |
+| `test_one_partition_value_prunes_half_the_files` | one partition value prunes half the files |
+| `test_and_reads_the_other_half` | and reads the other half |
+| `test_both_partition_columns_prune_down_to_one_file` | both columns prune down to one file |
+| `test_a_pruned_scan_still_returns_the_right_rows` | pruned scan still returns the right rows |
+| `test_an_inequality_on_a_partition_column_prunes` | an inequality on a partition column prunes |
+| `test_in_on_a_partition_column_prunes` | IN on a partition column prunes |
+| `test_a_predicate_matching_everything_prunes_nothing` | a predicate matching everything prunes nothing |
+| `test_a_predicate_on_a_file_column_prunes_no_files` | a predicate on a file column prunes no files |
+| `test_a_pruned_files_row_groups_are_never_counted` | a pruned file's row groups are never counted |
+| `test_partition_prune_plus_row_group_skip` | partition prune plus row-group skip |
+| `test_row_groups_are_still_skipped_inside_the_surviving_files` | row groups are still skipped inside the surviving files |
+| `test_a_volatile_clause_prunes_nothing` | a volatile clause prunes nothing |
+| `test_a_volatile_clause_keeps_every_matching_partition_row` | a volatile clause keeps every matching partition row |
+| `test_a_stable_clause_still_prunes` | a stable clause still prunes |
+| `test_a_file_missing_a_declared_partition_component_errors` | a file missing a declared partition component errors |
+| `test_a_basename_that_looks_like_a_partition_component_is_unaffected` | a file whose basename looks like a partition component is unaffected |
+| `test_an_unknown_partition_column_errors` | an unknown partition column errors rather than being ignored |
+| `test_an_empty_partition_columns_option_is_rejected` | an empty partition_columns option is rejected |
+| `test_declaring_a_file_column_as_a_partition_column_errors` | declaring a file column as a partition column errors |
+| `test_the_backend_survives_the_bad_declarations` | backend survived the bad declarations |
+| `test_a_percent_encoded_partition_value_decodes` | a percent-encoded partition value decodes |
+| `test_the_encoded_value_is_queryable_as_itself` | the encoded value is queryable as itself |
+| `test_hive_default_partition_reads_as_null` | __HIVE_DEFAULT_PARTITION__ reads as null |
+| `test_a_null_partition_value_is_not_the_literal_marker` | a null partition value is not the literal marker |
+| `test_an_unencoded_value_is_unaffected` | an unencoded value is unaffected |
+| `test_all_three_encoded_partitions_still_read` | all three partitions still read |
+| `test_a_null_partition_value_prunes` | a null partition value prunes |
+| `test_an_encoded_null_byte_in_a_partition_value_is_rejected` | an encoded null byte in a partition value is rejected |
+| `test_a_byte_invalid_in_the_server_encoding_is_rejected` | a byte invalid in the server encoding is rejected |
+| `test_the_encoded_tree_reads_again_once_both_are_gone` | the tree reads again once both are gone |
+| `test_the_backend_survives_the_malformed_partition_values` | backend survived the malformed partition values |
+
+## 322. test_native_parquet_fdw.py: the Parquet FDW reads an exported file as a foreign table, prunes what it can, and refuses each misuse by its own guard
+
+The twin of `test/native_parquet_fdw.sh`. A columnar table is exported and read back through a pgcolumnar_parquet foreign table, compared with a heap table written from the generator across full scan, projection, predicate, a nested-loop self-join (rescan) and a nested-type table. EXPLAIN ANALYZE must show a one-column query reading one of eleven columns, and a selective predicate skipping three of four row groups. The option validator and the scan refuse an invalid option, an empty path, a missing path, a non-Parquet file and an incompatible declared type, each with its own guard's message, and the backend survives.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database with its own foreign server; the files live in server_dir. A failed reading is a query_error(), unique per call, so two failures never agree. A refusal reads as ' ()' only when the primary message is the guard's own; every reading waits for the server first; survival also requires that the server log's count of killed backends has not moved.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_scalar_table_exports_every_row` | scalar export rows |
+| `test_a_full_foreign_scan_equals_the_source` | full scan: fdw == source |
+| `test_the_foreign_table_counts_every_row` | fdw row count |
+| `test_a_projection_equals_the_source` | projection: fdw == source |
+| `test_a_predicate_equals_the_source` | predicate: fdw == source |
+| `test_a_nested_loop_self_join_rescans_correctly` | nested-loop self-join (rescan): fdw == source |
+| `test_explain_shows_a_foreign_scan` | EXPLAIN shows Foreign Scan |
+| `test_a_one_column_query_reads_one_column` | a one-column query reads one of the eleven columns |
+| `test_the_skip_table_exports_every_row` | skip export rows |
+| `test_the_skip_file_has_four_row_groups` | the skip file has four row groups |
+| `test_a_selective_predicate_skips_three_row_groups` | a selective predicate skips the three row groups it cannot match |
+| `test_the_skipped_scan_returns_every_matching_row` | and the skipped scan still returns every matching row |
+| `test_the_fdw_nested_table_exports_every_row` | nested export rows |
+| `test_a_nested_full_scan_equals_the_source` | nested full scan: fdw == source |
+| `test_an_invalid_table_option_is_rejected` | reject invalid table option (HV00D) |
+| `test_an_empty_path_option_is_rejected` | reject empty path option (HV00D) |
+| `test_a_scan_without_a_path_option_errors` | scan without path option errors (HV00J) |
+| `test_a_scan_of_a_non_parquet_file_errors` | scan of non-parquet file errors (XX001) |
+| `test_a_scan_with_an_incompatible_declared_type_errors` | scan with incompatible declared type errors (42804) |
+| `test_the_backend_is_still_up_after_the_fdw_refusals` | backend still up after the refusals |
+
+## 323. test_native_read_parquet.py: read_parquet returns the rows that were written, refuses what it must, and keeps its file gate
+
+The twin of `test/native_read_parquet.sh`. A columnar table of eleven scalar types (with an all-NULL row) and one of int[], text[] and a composite are exported; read_parquet reads each file back as the generator's rows, a projection keeps its column, and import_parquet of the same file agrees. A missing column list, too few columns, an incompatible type and a non-Parquet file are each refused with their own SQLSTATE and message; a role without pg_read_server_files is refused by read_parquet's own gate, and a member reads every row.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. Every reading waits for the server first and returns a failure as a `query_error()`, so a crash fails only its own reading and the survival check, and two failed reads never agree.
+
+| test | what it holds |
+| --- | --- |
+| `test_read_parquet_fixture_exports_every_scalar_row` | scalar export rows |
+| `test_read_parquet_returns_the_generated_scalar_rows` | scalar round-trip: read_parquet == the generated rows |
+| `test_read_parquet_counts_every_scalar_row` | scalar read_parquet row count |
+| `test_a_projected_column_round_trips` | projected column round-trips |
+| `test_read_parquet_fixture_exports_every_nested_row` | nested export rows |
+| `test_read_parquet_returns_the_generated_nested_rows` | nested round-trip: read_parquet == the generated rows |
+| `test_a_missing_column_definition_list_is_refused` | premise: reject missing column definition list |
+| `test_fewer_declared_columns_than_the_file_is_refused` | reject fewer columns than file |
+| `test_an_incompatible_declared_type_is_refused` | reject incompatible declared type |
+| `test_a_file_that_is_not_parquet_is_refused` | reject non-parquet file |
+| `test_read_parquet_keeps_its_own_file_gate` | a role without pg_read_server_files is refused by read_parquet's own gate |
+| `test_a_pg_read_server_files_member_reads_every_row` | and a member of pg_read_server_files reads every row |
+| `test_the_backend_is_still_up_after_the_read_parquet_refusals` | backend still up after the refusals |
+| `test_import_parquet_loads_every_row` | import parity rows |
+| `test_read_parquet_agrees_with_import_parquet` | read_parquet == import_parquet |
+
+## 324. test_objstore_listing.py: a remote prefix or glob is listed page by page, an exact key is not listed, and a listing that cannot advance or fails is refused
+
+The twin of `test/objstore_listing.sh`. Over the local SigV4 fixture server, answering ListObjectsV2 two keys a page, read_parquet and the FDW read every object under a remote prefix (recursively, hidden and non-Parquet objects excluded), a remote glob matches only the top level, an exact key is read with no LIST, and Hive partition columns are stamped and pruned over a remote prefix. A replayed continuation token, a truncated page with no keys and a LIST answered with HTTP 500 are each refused by their own guard, and the backend survives.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private cluster restarted with AWS_* in its environment; the Parquet fixtures are written by a subprocess, and without pyarrow every test declines under the shell's skip message. A failed reading is a query_error(), unique per call, so two failures never agree. A refusal reads as ' by the ' only when the message is the guard's own; every reading waits for the server first; survival also requires that the server log's count of killed backends has not moved.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_listing_fixture_server_is_up` | premise: fixture up |
+| `test_read_parquet_over_a_prefix_reads_every_object_under_it` | read_parquet over a prefix reads every object under it (4 parts, recursive) |
+| `test_the_prefix_listing_paged` | the prefix listing paged (more than one list-type=2 request) |
+| `test_the_fdw_over_a_prefix_reads_what_read_parquet_reads` | FDW over a prefix == read_parquet over the prefix |
+| `test_hidden_and_non_parquet_objects_are_excluded_from_the_listing` | hidden (_SUCCESS) and non-parquet (notes.txt) objects are excluded |
+| `test_a_remote_glob_matches_only_the_top_level_parts` | glob s3://.../lst/*.parquet matches the 3 top-level parts, not sub/ |
+| `test_an_exact_key_read_returns_its_rows` | exact-key read returns its rows |
+| `test_an_exact_key_read_issues_no_list` | an exact-key read issues no ListObjectsV2 |
+| `test_the_exact_key_read_reached_the_request_log` | and the request log recorded the exact-key read |
+| `test_hive_over_a_remote_prefix_stamps_the_partition_column` | Hive over a remote prefix stamps the partition column |
+| `test_a_partition_predicate_prunes_a_remote_file` | a partition predicate prunes a remote file (Files Pruned = 1) |
+| `test_a_non_advancing_continuation_token_is_refused` | a non-advancing continuation token is refused, not an infinite paging loop |
+| `test_a_truncated_page_with_no_keys_is_refused` | a truncated page with no keys is refused, not looped |
+| `test_a_list_that_fails_is_refused_not_read_as_empty` | a LIST that returns HTTP 500 is refused, not read as an empty prefix |
+| `test_the_backend_is_still_up_after_the_listing_refusals` | backend still up after the listing refusals |
+
+## 325. test_native_rewrite_conc.py: The online rewrite runs alongside writers without losing a row, and without blocking them
+
+The twin of `test/native_rewrite_conc.sh`. Six workers insert, delete and update disjoint ranges of a columnar table and its heap oracle while a compactor loops compact_rewrite and compact. A delete that races a rewrite of its group is refused with a serialization failure and retried, so the two tables must end equal. A compact_rewrite held mid-verb must then let a reader and a writer through.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private database. The workers and the compactor run as threads, each statement retried as the shell's runsql retries it. The oracle comparisons hash both tables, and a failed read is a value no other read shares. The held rewrite runs in its own thread; the reader and the writer each set a 3 s lock_timeout, and the lock is checked to be still held after both finish.
+
+| test | what it holds |
+| --- | --- |
+| `test_all_rewrite_workers_succeeded` | all workers succeeded (retried past serialization failures) |
+| `test_the_compactor_rewrote_groups_while_the_workers_were_writing` | the compactor rewrote groups while the workers were writing |
+| `test_conc_rewrite_whole_row` | conc-rewrite whole-row |
+| `test_conc_rewrite_count_sum` | conc-rewrite count/sum |
+| `test_conc_rewrite_range` | conc-rewrite range |
+| `test_row_count_sane` | row count sane |
+| `test_a_compact_rewrite_holds_its_lock_mid_verb` | a compact_rewrite holds its lock on t_col mid-verb |
+| `test_a_reader_finishes_while_the_rewrite_holds_its_lock` | a reader with a 3 s lock_timeout finishes while the rewrite holds its lock |
+| `test_a_writer_inserts_while_the_rewrite_holds_its_lock` | a writer with a 3 s lock_timeout inserts while the rewrite holds its lock |
+| `test_the_rewrite_still_held_its_lock_when_both_had_finished` | and the rewrite still held its lock when both had finished |
+| `test_the_held_rewrite_itself_completed` | the held rewrite itself completed |
+
+## 326. test_objstore_addressing.py: gs:// and S3 virtual-host addressing, checked by the request path the server received
+
+The twin of `test/objstore_addressing.sh`. A private cluster reads parquet objects through the local SigV4 fixture server, started with `--virtual-host-base s3.local`: s3:// path-style and virtual-host, gs:// read, write and read-back, and a key with a space, '+' and '=' under both addressings. With no endpoint, s3:// must be refused naming AWS_ENDPOINT_URL and gs:// must go to storage.googleapis.com, which this cluster's allow-list refuses by name.
+
+### Every test
+
+Every test makes one check under the shell's name. Each addressing check reads the request paths the fixture logged for that read, not only the answer, since the fixture serves the same object either way. Refusals are a SQLSTATE plus the phrase that names the cause; a failed read is a query_error, never text. The first test mirrors the shell's two pgc_skip names: it declines without pyarrow, or UNAVAILABLE_ENDPOINT where pgc-bucket.s3.local does not resolve, and the rest decline with it.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_fixture_server_is_up` | premise: fixture up |
+| `test_an_s3_path_style_read` | s3:// path-style read |
+| `test_path_style_puts_the_bucket_in_the_request_path` | path-style put the bucket in the request path |
+| `test_a_key_needing_encoding_reads_back_path_style` | a key with a space, '+' and '=' reads back (path-style) |
+| `test_the_path_style_request_path_is_the_key_encoded_once` | and its path-style request path is the key URI-encoded once |
+| `test_a_gs_read_round_trips` | gs:// read round-trips (scheme handled, HMAC creds reused) |
+| `test_a_gs_write_round_trips` | gs:// write round-trips |
+| `test_the_gs_written_object_reads_back` | gs:// read-back of the written object |
+| `test_a_virtual_host_read_equals_the_path_style_read` | virtual-host read == path-style read (same object, different addressing) |
+| `test_virtual_host_puts_the_key_alone_in_the_path` | virtual-host put the KEY ALONE in the path (no bucket segment) |
+| `test_a_key_needing_encoding_reads_back_virtual_host` | a key with a space, '+' and '=' reads back (virtual-host) |
+| `test_the_virtual_host_request_path_is_the_key_alone_encoded_once` | and its virtual-host request path is the key alone, URI-encoded once |
+| `test_addressing_path_puts_the_bucket_back_in_the_path` | removal proof: with addressing=path the bucket is back in the path |
+| `test_the_request_path_carries_the_bucket_again` | and the request path carried the bucket again |
+| `test_s3_with_no_endpoint_demands_one` | s3:// with no endpoint demands one (names AWS_ENDPOINT_URL) |
+| `test_gs_with_no_endpoint_goes_to_storage_googleapis_com` | gs:// with no endpoint goes to storage.googleapis.com (the allow-list refuses that host by name) |
+
+## 327. test_objstore_stash_recovery.py: recovery from a stash an interrupted objstore_module run left
+
+The twin of `test/objstore_stash_recovery.sh`. It runs `test/objstore_module.sh` as a subprocess against three arrangements of pkglibdir, each from a pristine copy of the module: a different valid module left at `.probe` beside a good one, the only copy at `.probe` with nothing installed, and a 19-byte stand-in installed with the real module at `.away`. It asserts on the suite's exit status and on what it left behind. The installed module is shared, so this file must not run beside others that load it.
+
+### Every test
+
+Every test reads one observation the module fixture took, and makes one check under the shell's name. If no valid module is installed, each test is unrunnable as `stash recovery`, the shell's skip.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_session_build_installed_a_valid_module` | the build or install failed, so nothing below measures the guard |
+| `test_debris_premise_installed_module_valid` | premise: the installed module is valid before this slice arranges anything |
+| `test_debris_premise_stash_in_place` | premise: a debris stash really is in place for the run below |
+| `test_debris_premise_stash_is_a_different_valid_module` | premise: and the debris is a valid module that differs from the installed one |
+| `test_debris_suite_completes` | the suite runs to completion with debris beside a valid module |
+| `test_debris_not_restored_over_the_module` | and the installed module is still the one that was there, not the debris |
+| `test_debris_gone` | the debris is gone afterwards |
+| `test_debris_module_still_valid` | and the module left installed is still valid |
+| `test_only_copy_premise_nothing_installed` | premise: nothing is installed at the module's path for this slice |
+| `test_only_copy_premise_stash_is_real` | premise: and the stash beside it is the real module |
+| `test_only_copy_suite_completes` | the suite recovers the only surviving copy and runs to completion |
+| `test_only_copy_reinstalled` | the module is installed again afterwards, and valid |
+| `test_only_copy_stash_not_left` | and the stash is not left behind for the next run to trip on |
+| `test_stand_in_premise_installed` | premise: a 19-byte stand-in really is installed as the module |
+| `test_stand_in_premise_not_a_module` | premise: which is not a module |
+| `test_stand_in_premise_stash_is_real` | premise: and the real module is the thing parked at the stash |
+| `test_stand_in_suite_completes` | the suite replaces the stand-in with the real module and completes |
+| `test_stand_in_real_module_survived` | the real module survived, rather than being deleted with the stash |
+| `test_stand_in_stash_cleared` | and the stash is cleared |
+
+## 328. test_objstore_s3_read.py: SigV4-signed s3:// reads, every refusal naming its cause
+
+The twin of `test/objstore_s3_read.sh`. A private cluster reads a parquet object through the local fixture server in SigV4 mode, which recomputes every signature with python's stdlib, requires the session token in the signed set, and refuses a tamper bucket against a different secret. Reads are compared with the byte-identical local file, the request count with the unsigned arithmetic, and the region with both AWS_REGION and AWS_DEFAULT_REGION set, then the fallback alone. A missing endpoint, region and secret are each refused naming what is missing; a 403 names the HTTP 403 and a 404 stays 58P01; an https endpoint is never downgraded.
+
+### Every test
+
+Every test makes one check under the shell's name. Refusals are a SQLSTATE plus the phrase that names the cause; a failed read is a query_error, never text. Every reading waits for the server first, and survival also counts killed backends in the private cluster's log. The first tests decline without pyarrow, under the shell's skip message. The real-S3 arm declines UNMET_PRECONDITION unless PGC_S3_INTEGRATION_ENDPOINT is set, which no matrix run does, so expected_unrunnable.txt lists it on every major.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_fixture_is_written` | premise: fixture written |
+| `test_the_sigv4_fixture_server_is_up` | premise: sigv4 fixture server is up |
+| `test_an_s3_read_with_no_environment_is_refused_naming_the_endpoint` | config: s3 read with no environment is 28000 |
+| `test_aws_region_outranks_aws_default_region` | region: AWS_REGION outranks AWS_DEFAULT_REGION |
+| `test_a_full_scan_over_s3_reads_what_the_local_file_reads` | A: full scan s3 == local |
+| `test_a_projection_over_s3_reads_what_the_local_file_reads` | A: projection s3 == local |
+| `test_a_percent_encoded_key_over_s3_reads_what_the_local_file_reads` | A: percent-encoded key s3 == local |
+| `test_count_over_s3` | A: count(*) over s3 |
+| `test_the_signed_run_logged_requests` | premise: signed run logged requests |
+| `test_signing_left_the_request_arithmetic_unchanged` | B: signing left the request arithmetic unchanged |
+| `test_the_tamper_bucket_surfaces_its_403_as_28000` | 403: tamper bucket surfaces 28000 |
+| `test_a_verified_request_for_a_missing_key_still_reaches_404` | 403 ordering premise: verified requests still reach 404 = 58P01 |
+| `test_aws_default_region_is_the_fallback` | region: AWS_DEFAULT_REGION is the fallback |
+| `test_an_s3_read_with_no_region_is_refused_naming_it` | config: s3 read with no region is refused naming it |
+| `test_an_s3_read_with_no_secret_is_refused_naming_it` | config: s3 read with no AWS_SECRET_ACCESS_KEY is refused naming it |
+| `test_an_https_endpoint_is_not_downgraded` | https endpoint is not downgraded |
+| `test_the_backend_is_still_up_after_the_s3_refusals` | backend still up after the s3 refusals |
+| `test_a_real_s3_implementation_reads_what_the_local_file_reads` | integration: real S3 implementation == local |
+
+## 329. test_objstore_tls_read.py: TLS for object-store reads, every verification proven by a certificate that must be refused
+
+The twin of `test/objstore_tls_read.sh`. A private cluster, trusting the fixture CA through SSL_CERT_FILE in the postmaster environment, reads a parquet object over https from the shell's fixture server and over s3:// with SigV4 on an https endpoint, then must refuse four servers: a wrong host, an expired certificate, an untrusted CA and a self-signed leaf. A transfer stalled mid-body must be cancelled by statement_timeout, and a restart with SSL_CERT_DIR alone must still trust the CA.
+
+### Every test
+
+Every test makes one check under the shell's name. A refusal is 08006, the module's own message naming the URL, and OpenSSL's verification reason for that certificate as the whole DETAIL; each carries the premise that the fixture logged the handshake, and waits for SELECT 1 afterwards so a crash fails only its own checks, which the closing survival check counts in the server log. The PKI, the fixture and the logs live in `server_dir`, since the backend reads the CA. The first test mirrors the shell's two pgc_skip names: it declines without pyarrow or without the openssl CLI, and the rest decline with it.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_expired_certificate_is_genuinely_expired` | premise: expired certificate is genuinely expired |
+| `test_six_tls_servers_are_up` | premise: six TLS servers are up |
+| `test_an_https_read_equals_the_local_read` | A: https differential == local |
+| `test_count_over_https` | A: count(*) over https |
+| `test_s3_over_https_equals_the_local_read` | A2: s3 over https differential == local |
+| `test_the_handshake_was_attempted[...]` | premise: {label} handshake was attempted, one test per label (wrong-host, expired, untrusted-ca, self-signed) |
+| `test_the_certificate_is_refused_08006[...]` | C: {label} refused with 08006, one test per label |
+| `test_the_refusal_is_the_tls_one[...]` | C: {label} refusal is the TLS one: the module's message naming the URL, one test per label |
+| `test_the_refusal_names_its_reason[...]` | C: {label} names its reason: the whole DETAIL, OpenSSL's verification reason, one test per label |
+| `test_the_refused_read_returned_no_rows[...]` | C: {label} returned no rows, one test per label |
+| `test_a_stalled_tls_transfer_is_cancelled` | D: stalled TLS transfer cancelled with query_canceled |
+| `test_the_cancel_latency_is_bounded` | D: cancel latency bounded (<=10s) |
+| `test_ssl_cert_dir_alone_trusts_the_ca` | E: SSL_CERT_DIR alone trusts the CA (count(*) over https) |
+| `test_no_backend_was_terminated_by_a_signal` | survival: no backend was terminated by a signal |
+
+## 330. test_objstore_http_read.py: the HTTP ranged byte source, with request counts and each refusal's own guard
+
+The twin of `test/objstore_http_read.sh`. Foreign tables over the local fixture server (`test/objstore_http_server.py`, the shell's own) read a G-row-group parquet fixture: the answer must equal the byte-identical local file, the buffered request count must equal the arithmetic from the fixture's shape with every GET carrying a Range header, and statement_timeout must cancel a transfer stalled mid-body. An unreachable port, a 404, a server that ignores Range, a short 206 body, EOF mid-body and a server that hangs up are each refused by their own guard; a keep-alive connection the server dropped is retried once, and a fresh connection is not.
+
+### Every test
+
+Every test makes one check under the shell's name, from readings a module fixture takes in the shell's order, each statement on its own connection as the shell's psql calls are. Request counts come from the fixture's request log. A refusal is the SQLSTATE plus the phrase from its own guard's message, since many guards share 08006 and 08P01; each refusal reading first waits for the server, so a crash in one arm can't leak into the next. A failed read is a query_error, never text. The first test mirrors the shell's pgc_skip name: it declines without pyarrow, and the rest decline with it.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_http_read_fixture_is_written` | premise: fixture written |
+| `test_the_http_read_fixture_server_is_up` | premise: fixture server is up |
+| `test_a_full_scan_over_http_equals_the_local_file` | A: full scan http == local |
+| `test_a_projection_over_http_equals_the_local_file` | A: projection http == local |
+| `test_count_over_http` | A: count(*) over http |
+| `test_the_buffered_run_logged_requests` | premise: buffered run logged requests |
+| `test_the_unbuffered_run_logged_requests` | premise: unbuffered run logged requests |
+| `test_buffered_full_width_requests` | B: buffered full-width requests (K = 4 + G*NCOLS) |
+| `test_buffered_two_column_requests` | B: buffered 2-col requests (K = 4 + G*2) |
+| `test_unbuffered_is_at_least_ten_times_k` | B: unbuffered >= 10x K |
+| `test_the_buffered_to_unbuffered_request_ratio` | B: buffered/unbuffered request ratio |
+| `test_the_range_audit_phase_logged_gets` | premise: the range-audit phase logged GETs |
+| `test_every_get_carried_a_range_header` | B: every GET carried a Range header |
+| `test_an_unreachable_endpoint_is_refused` | C: unreachable endpoint SQLSTATE |
+| `test_a_404_is_refused` | C: http 404 SQLSTATE |
+| `test_a_range_ignoring_server_is_refused` | C: range-ignoring server SQLSTATE |
+| `test_a_short_206_body_is_refused` | C: a short 206 body is refused |
+| `test_eof_mid_body_is_refused` | C: EOF mid-body is refused |
+| `test_a_dropped_keep_alive_connection_is_retried` | C: a dropped keep-alive connection is retried (http == local) |
+| `test_a_server_that_hangs_up_is_refused` | C: a server that hangs up on a fresh connection is refused |
+| `test_a_fresh_connection_is_not_retried` | C: and that fresh connection is not retried (one request logged) |
+| `test_a_stalled_transfer_is_cancelled` | D: stalled transfer cancelled with query_canceled |
+| `test_the_http_read_cancel_latency_is_bounded` | D: cancel latency bounded (<=10s) |
+| `test_the_server_survives` | survival: the server answers and no backend crashed |
+
+## 331. test_objstore_sink_write.py: exports to s3:// go as one PUT or a completed multipart upload, read back as the generator, and a failed export leaves no key and no open upload
+
+The twin of `test/objstore_sink_write.sh`. Over the local SigV4 fixture server, which emulates multipart as S3 does (the object is the parts the complete lists, each checked against its ETag), a small export goes as one plain PUT and a large one as a multipart upload completed once, and each object reads back as the generator's rows, the Arrow one imported back from the bucket. An injected fault and a part the server refuses each fail the export by their own message, leave no key and leave no upload open. An unlisted endpoint is refused by the allow-list. parallel_export_parquet writes one object per worker and a _SUCCESS marker under an s3:// prefix, a smaller re-run drops the stale tail, and a cancelled run leaves no object, no marker and no upload open.
+
+### Every test
+
+Every test makes one check under the shell's name, on a private cluster restarted with AWS_* in its environment; the fixtures are generated by SQL, so the module needs no pyarrow. A failed reading is a query_error(), unique per call, so two failures never agree. A refusal reads as ' by the ' only when the message is the guard's own, and every reading waits for the server first. Open uploads are read from the fixture server's __open_uploads__, not inferred from the DELETE count.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_sink_write_sigv4_fixture_server_is_up` | premise: sigv4 fixture server is up |
+| `test_a_small_export_to_s3_succeeds` | small export to s3:// succeeds |
+| `test_a_small_object_goes_as_one_plain_put` | small object went as ONE plain PUT (no multipart) |
+| `test_the_small_object_reads_back_as_the_generator` | small round trip: s3 read-back == source |
+| `test_a_large_export_to_s3_succeeds` | large export to s3:// succeeds |
+| `test_the_large_export_took_the_multipart_protocol` | premise: the large export took the multipart protocol |
+| `test_the_large_export_completed_its_upload_once` | and completed it exactly once (POST with uploadId, no partNumber) |
+| `test_the_large_object_reads_back_as_the_generator` | large round trip: s3 read-back == source |
+| `test_an_injected_mid_upload_failure_surfaces_as_disk_full` | inject: mid-upload failure surfaces as disk-full (53100) |
+| `test_the_injected_failure_leaves_no_key` | inject: the key does not exist |
+| `test_the_injected_fault_landed_mid_multipart` | premise: the fault landed mid-multipart (an upload was started) |
+| `test_every_upload_the_injected_export_started_was_aborted` | inject: every upload started was aborted (no billable orphan) |
+| `test_the_injected_failure_leaves_no_open_upload` | inject: the server holds no open upload for the key |
+| `test_a_refused_part_fails_the_export` | part failure: a refused part fails the export (58030) |
+| `test_a_refused_part_leaves_no_key` | part failure: the key does not exist |
+| `test_the_part_failure_landed_mid_multipart` | premise: the part failure landed mid-multipart (an upload was started) |
+| `test_a_refused_part_leaves_no_open_upload` | part failure: the server holds no open upload for the key |
+| `test_an_unlisted_endpoint_is_refused_by_the_allow_list` | allow-list: an unlisted endpoint refuses 42501 before any write |
+| `test_an_arrow_export_to_s3_imports_back_as_the_generator` | arrow export to s3:// also round-trips |
+| `test_a_parallel_export_to_an_s3_prefix_succeeds` | parallel export to an s3:// prefix succeeds |
+| `test_the_parallel_export_wrote_one_object_per_worker` | parallel: one object per worker was written (4) |
+| `test_every_worker_put_went_to_the_prefix` | parallel: every worker PUT went to the prefix |
+| `test_a_success_marker_exists_at_the_prefix` | parallel remote: a _SUCCESS marker object exists at the prefix |
+| `test_the_marker_went_to_the_prefix_as_a_put` | parallel remote: the marker went to the prefix as a PUT |
+| `test_the_per_worker_objects_read_back_as_the_generator` | parallel: the union of the per-worker objects == source |
+| `test_a_rerun_into_the_same_prefix_succeeds` | parallel: a re-run into the same prefix succeeds (overwrite) |
+| `test_the_four_worker_run_wrote_four_parts_and_a_marker` | shrink: the 4-worker run wrote 4 parts + marker |
+| `test_a_smaller_rerun_leaves_exactly_its_own_parts` | shrink: a smaller re-run leaves exactly its own 2 parts (stale tail dropped) |
+| `test_the_larger_runs_stale_parts_are_gone` | shrink: parts 0002/0003 from the larger run are gone |
+| `test_the_marker_certifies_the_reconciled_prefix` | shrink: _SUCCESS certifies the reconciled prefix |
+| `test_a_worker_was_uploading_before_the_cancel` | premise: a worker was uploading before the cancel |
+| `test_all_four_uploads_were_open_before_the_cancel` | all four workers' uploads were open before the cancel -- an upload whose POST ?uploads is in flight is #1743's window |
+| `test_the_export_was_cancelled_not_completed` | premise: the export was cancelled, not completed |
+| `test_the_dispatcher_deleted_the_known_keys_on_cancel` | cancel: the dispatcher issued DELETE over the known keys |
+| `test_no_completed_object_remains_after_the_cancel` | cancel: no completed object remains at the prefix |
+| `test_no_success_marker_remains_after_the_cancel` | cancel: no _SUCCESS marker at the prefix |
+| `test_no_upload_stays_open_after_the_cancel` | cancel: the server holds no open upload under the prefix -- #1722: the workers the cancel terminates abort their uploads on the way out |
+| `test_terminated_workers_exit_while_the_store_is_silent` | terminate: export workers exit within 75 s while the store is silent -- #1727: each wait slice times out, so the 60 s quiet limit ends a worker's abort |
+
+## 332. test_native_fetch_interrupt.py: the by-row-number fetch is cancellable
+
+The twin of `test/native_fetch_interrupt.sh`. It reads `src/columnar_reader.c` as the shell does: `pgcolumnar_fetch_row` carries a `CHECK_FOR_INTERRUPTS`, the check comes before the `pgcolumnar_lookup_row_group(` call it guards, and that helper is where `PgColumnarReadRowGroupList` is called. It needs no cluster and is listed in NO_CLUSTER.
+
+### Every test
+
+Every test makes one check under the shell's name. Each function body is taken from its signature to the first closing brace at column 0, with its comments blanked (multi-line ones too, newlines kept), so a call commented out is not a call. A missing file or function reads as an empty body, which fails every check.
+
+| test | what it holds |
+| --- | --- |
+| `test_fetch_row_carries_an_interrupt_check` | pgcolumnar_fetch_row carries an interrupt check |
+| `test_the_check_precedes_the_row_group_resolution` | the interrupt check precedes the row-group resolution |
+| `test_the_resolution_is_where_the_catalog_read_lives` | the row-group resolution is where the catalog read lives |
+
+## 333. test_objstore_module.py: the object-store module's packaging and loader, every refusal pinned
+
+The twin of `test/objstore_module.sh`. nm finds the module's entry point absent from `pgcolumnar.so` and present in `pgcolumnar_objstore.so`. An s3://, gs:// and https:// read are each refused with their own SQLSTATE and message (https:// by whether the module links libssl), a remote glob is listed remotely, a local file `export_parquet` wrote reads back through `read_parquet`, and a relative path is not taken for a URL. Then the installed module is moved: present, absent and broken are three different reports, each the same twice in one session.
+
+### Every test
+
+Every test makes one check under the shell's name, from one module fixture that takes a pristine copy of the module first and puts it back in its finally; the file runs alone, as the shell does. A refusal reads as `SQLSTATE: primary message`; a read that succeeded reads as a query_error. Counts are lines of the error text, as the shell's grep -c. With an ambient AWS_* in this process's environment (the session postmaster inherits it) the remote refusals decline UNMET_PRECONDITION; with the module unmovable the moving arms decline under the shell's skip name.
+
+| test | what it holds |
+| --- | --- |
+| `test_a_restored_stash_is_a_module` | a stash restored over a missing module is a module -- the shell records it only when it fails, so it has no ledger row |
+| `test_the_main_library_is_installed` | the main library is installed |
+| `test_the_module_is_installed_beside_it` | the object-store module is installed BESIDE it, not inside it |
+| `test_the_entry_point_is_not_in_the_preloaded_library` | the module's entry point is NOT inside the preloaded library |
+| `test_the_entry_point_is_defined_in_the_module` | positive control: it IS defined in the module, so nm really looked |
+| `test_an_s3_url_reports_an_object_storage_error` | a s3 URL reports an object-storage error, not a missing file |
+| `test_an_s3_url_is_not_a_missing_file` | and the s3 URL is NOT reported as a missing file |
+| `test_a_gs_url_reports_an_object_storage_error` | a gs URL reports an object-storage error, not a missing file |
+| `test_a_gs_url_is_not_a_missing_file` | and the gs URL is NOT reported as a missing file |
+| `test_an_https_url_reports_an_object_storage_error` | a https URL reports an object-storage error, not a missing file |
+| `test_an_https_url_is_not_a_missing_file` | and the https URL is NOT reported as a missing file |
+| `test_a_star_glob_is_handled_remotely` | a remote glob (*) is handled remotely (an object-storage error, not a local one) |
+| `test_a_star_glob_is_not_a_local_miss` | and the * glob is NOT reported as a local filesystem miss |
+| `test_a_question_glob_is_handled_remotely` | a remote glob (?) is handled remotely (an object-storage error, not a local one) |
+| `test_a_question_glob_is_not_a_local_miss` | and the ? glob is NOT reported as a local filesystem miss |
+| `test_a_bracket_glob_is_handled_remotely` | a remote glob ([0-9]) is handled remotely (an object-storage error, not a local one) |
+| `test_a_bracket_glob_is_not_a_local_miss` | and the [0-9] glob is NOT reported as a local filesystem miss |
+| `test_a_local_path_still_works` | a local path still works |
+| `test_a_relative_path_is_not_a_url` | a relative path is not mistaken for a URL |
+| `test_present_both_reads_report_an_unhandled_scheme` | with the module present, both reads report an unhandled scheme |
+| `test_present_neither_claims_the_module_is_missing` | and neither claims the module is missing |
+| `test_premise_the_module_was_absent` | premise: the module really was absent while those two reads ran |
+| `test_premise_the_module_is_back` | premise: and the real module is back afterwards |
+| `test_absent_no_read_leaks_the_loader_error` | with the module absent, neither read leaks the loader's own error |
+| `test_absent_both_reads_name_the_missing_module` | both reads name the missing module, identically |
+| `test_absent_neither_downgrades_to_an_unhandled_scheme` | and neither downgrades it to an unhandled scheme |
+| `test_premise_the_module_is_corrupt` | premise: the module really is corrupt for this run, not merely intended to be |
+| `test_premise_the_module_was_restored` | premise: the real module was restored afterwards |
+| `test_broken_is_not_an_unhandled_scheme` | a broken module does NOT masquerade as an unhandled scheme |
+| `test_broken_is_not_a_module_never_installed` | nor as a module that was never installed |
+| `test_broken_keeps_the_loader_reason` | and the loader's own reason survives |
+
+## 334. test_phase2.py: compression, projection, zone maps and chunk-group filtering
+
+The twin of `test/phase2.sh`. A private database, built once by a module fixture in the shell's order: the phase 1 regression table, one table per codec, the too-small-to-compress fallback, nulls under lz4, projection, and 25000 sorted ints in three vectors whose zone maps are decoded from their bytes. Every expected value comes from the generator.
+
+### Every test
+
+Every test reads one value the fixture took and makes one check under the shell's name; the five per-codec tests are parametrized over the four codecs, named by the shell's own `$codec` template. A failed query is a query_error(), never text standing in for one.
+
+| test | what it holds |
+| --- | --- |
+| `test_count_star_reads_every_row` | count(*) |
+| `test_a_filtered_count_reads_the_matching_rows` | count where a<50 |
+| `test_order_by_limit_returns_the_first_three_rows` | order by a limit3 |
+| `test_a_column_with_no_nulls_reads_none` | no nulls t.b |
+| `test_a_codec_round_trips_every_row` (none, pglz, lz4, zstd) | {codec} count |
+| `test_a_codec_round_trips_the_ids` (each codec) | {codec} sum(id) |
+| `test_a_codec_round_trips_a_payload` (each codec) | {codec} payload ok |
+| `test_a_codec_round_trips_one_distinct_payload` (each codec) | {codec} distinct pl |
+| `test_the_codec_asked_for_is_the_block_codec_stored` (each codec) | {codec} block codec -- 0, 1, 2, 3 |
+| `test_a_value_too_small_to_compress_round_trips` | fallback value |
+| `test_a_value_too_small_to_compress_is_stored_with_codec_0` | fallback codec=0 |
+| `test_nulls_round_trip_under_lz4` | null rows |
+| `test_every_third_value_reads_null` | null count |
+| `test_a_null_reads_null` | null value |
+| `test_a_value_beside_nulls_reads_its_value` | nonnull val |
+| `test_projecting_a_reads_a` | project a |
+| `test_projecting_b_reads_b` | project b |
+| `test_projecting_c_reads_c` | project c |
+| `test_a_sum_over_one_projected_column` | project sum |
+| `test_projecting_two_columns_in_order` | project b,c |
+| `test_three_vectors_have_zone_maps` | vectors |
+| `test_every_zone_map_has_a_minimum_and_maximum` | minmax present |
+| `test_vector_0_minimum_is_1` | v0 min=1 |
+| `test_vector_0_maximum_is_10000` | v0 max=10000 |
+| `test_vector_2_maximum_is_25000` | v2 max=25000 |
+| `test_a_between_filter_returns_its_rows` | between count |
+| `test_a_range_filter_returns_its_bounds` | range min/max |
+| `test_a_point_lookup_returns_one_row` | point lookup |
+| `test_a_range_past_the_data_returns_none` | empty range |
+| `test_a_range_inside_the_last_vector_decodes_one_of_three` | range decodes 1 of 3 vectors |
+| `test_no_row_group_outlives_its_table` | orphan row groups |
+| `test_no_column_chunk_outlives_its_table` | orphan chunks |
+
+## 335. test_phase3.py: read-your-writes, DELETE, UPDATE, rollback, savepoints and temporary tables
+
+The twin of `test/phase3.sh`. A private database, built once by a module fixture in the shell's order. Statements the shell runs in one psql session run on one connection here, including the savepoint arms, where a statement meant to fail may fail only with division_by_zero (22012). Every expected value comes from the generator, except the nested-read arm's, which is heap's answer to the same statement on the same rows.
+
+### Every test
+
+Every test reads one value the fixture took and makes one check under the shell's name. A failed query, or a reading the session never reached, is a query_error(), never text standing in for one.
+
+| test | what it holds |
+| --- | --- |
+| `test_the_regression_table_counts_every_row` | reg count |
+| `test_the_regression_table_filters` | reg filter a<100 |
+| `test_a_table_created_in_the_transaction_reads_its_rows` | ryw count |
+| `test_an_insert_is_read_by_its_own_transaction` | ryw sum |
+| `test_the_transaction_s_rows_persist` | ryw persisted |
+| `test_the_deleted_range_straddles_a_vector_boundary` | the deleted range straddles vectors 0 and 1 |
+| `test_a_delete_leaves_the_survivors` | delete count |
+| `test_deleted_rows_are_gone` | delete gone |
+| `test_the_deleted_range_s_neighbours_survive` | delete neighbors |
+| `test_the_table_s_bounds_survive` | delete boundaries |
+| `test_a_tail_delete_leaves_the_survivors` | delete tail count |
+| `test_a_tail_delete_lowers_the_maximum` | delete tail max |
+| `test_the_delete_vector_catalog_has_rows` | delete_vector rows>0 |
+| `test_the_delete_vector_catalog_counts_every_deletion` | delete_vector deleted |
+| `test_an_update_shows_the_new_value` | update value |
+| `test_an_update_changes_one_row` | update one row |
+| `test_an_update_keeps_the_row_count` | update count |
+| `test_a_key_update_removes_the_old_keys` | update key gone |
+| `test_a_key_update_writes_the_new_keys` | update key new |
+| `test_a_key_update_keeps_the_row_count` | update key total |
+| `test_a_bulk_update_keeps_the_row_count` | bulk update rows |
+| `test_a_bulk_update_rewrites_every_row` | bulk update sfx |
+| `test_the_rollback_table_starts_with_its_rows` | rb before |
+| `test_a_rolled_back_transaction_leaves_the_count` | rb after count |
+| `test_a_rolled_back_transaction_leaves_the_range` | rb after range |
+| `test_rollback_to_a_savepoint_keeps_the_work_before_it` | savepoint in-txn |
+| `test_rollback_to_a_savepoint_holds_after_commit` | savepoint final |
+| `test_an_insert_that_failed_inside_a_savepoint_leaves_nothing` | savepoint failed insert discarded |
+| `test_a_delete_that_failed_inside_a_savepoint_deletes_nothing` | savepoint failed delete discarded |
+| `test_the_failed_statements_leave_nothing_after_commit` | savepoint failed final |
+| `test_a_read_in_a_block_that_commits_into_an_aborted_one_sees_the_deletes` | savepoint nested read in a delete sees its deleted rows, as on heap |
+| `test_a_temporary_table_keeps_its_survivors` | temp survivors |
+| `test_a_temporary_table_s_deleted_rows_are_gone` | temp deleted |
+| `test_deletes_survive_their_transaction` | durable survivors |
+| `test_only_the_undeleted_rows_survive` | durable odd only |
+| `test_drop_removes_the_delete_vector_rows` | delete_vector cleaned |
+| `test_drop_removes_the_row_group_rows` | row group cleaned |
diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt
index de921e82f..0d0816383 100644
--- a/test/pytest/expected_tests.txt
+++ b/test/pytest/expected_tests.txt
@@ -317,9 +317,11 @@
# guard_tests 474 -> 476 for #1339's test_mutation_ledger.py :525 and :1002: the new
# test_the_prior_ceiling_is_resolved_rather_than_assumed and
# test_a_ledger_row_must_name_a_real_major, by collection over NO_CLUSTER.
+# guard_tests 806 -> 809 for #1341's test_native_fetch_interrupt.py, the twin of native_fetch_interrupt.sh,
+# which needs no cluster: 3 tests, by collection over NO_CLUSTER.
# guard_tests 804 -> 806 for #1613's test_a_refused_comparison_is_counted_and_named in
# test_check_records.py, serial and xdist, by collection over NO_CLUSTER.
-guard_tests 806
+guard_tests 809
# The complement: tests that need the driver and a throwaway cluster. Until #1016 these ran
# in no CI job at all -- a quarter of the corpus, green when somebody ran them by hand and
@@ -1005,6 +1007,78 @@ guard_tests 806
# 27 tests, one per check, by collection over the cluster files.
# cluster_tests 2030 -> 2037 for #1341's test_isolation.py, the twin of isolation.sh:
# 7 tests, one per check, by collection over the cluster files.
+# cluster_tests 4035 -> 4072 for #1341's test_phase3.py, the twin of phase3.sh:
+# 37 checks in 37 tests, by collection over the cluster files.
+# cluster_tests 3988 -> 4035 for #1341's test_phase2.py, the twin of phase2.sh:
+# 47 checks in 47 tests, by collection over the cluster files.
+# cluster_tests 3957 -> 3988 for #1341's test_objstore_module.py, the twin of objstore_module.sh:
+# 30 checks in 31 tests, by collection over the cluster files.
+# cluster_tests 3919 -> 3954 for #1341's test_objstore_sink_write.py, the twin of objstore_sink_write.sh:
+# 35 checks in 35 tests, by collection over the cluster files.
+# cluster_tests 3954 -> 3955 for #1722: test_objstore_sink_write.py gains the cancel arm's
+# open-upload check, by collection over the cluster files.
+# cluster_tests 3955 -> 3957 for #1727's review: test_objstore_sink_write.py gains the
+# all-uploads-open premise before the cancel and the silent-store terminate check.
+# cluster_tests 3895 -> 3919 for #1341's test_objstore_http_read.py, the twin of objstore_http_read.sh:
+# 24 checks in 24 tests, by collection over the cluster files.
+# cluster_tests 3866 -> 3895 for #1341's test_objstore_tls_read.py, the twin of objstore_tls_read.sh:
+# 29 checks in 29 tests, by collection over the cluster files.
+# cluster_tests 3848 -> 3866 for #1341's test_objstore_s3_read.py, the twin of objstore_s3_read.sh:
+# 17 checks in 18 tests, by collection over the cluster files.
+# cluster_tests 3829 -> 3848 for #1341's test_objstore_stash_recovery.py, the twin of objstore_stash_recovery.sh:
+# 18 checks in 19 tests, by collection over the cluster files.
+# cluster_tests 3813 -> 3829 for #1341's test_objstore_addressing.py, the twin of objstore_addressing.sh:
+# 16 checks in 16 tests, by collection over the cluster files.
+# cluster_tests 3802 -> 3813 for #1341's test_native_rewrite_conc.py, the twin of native_rewrite_conc.sh:
+# 11 checks in 11 tests, by collection over the cluster files.
+# cluster_tests 3787 -> 3802 for #1341's test_objstore_listing.py, the twin of objstore_listing.sh:
+# 15 checks in 15 tests, by collection over the cluster files.
+# cluster_tests 3772 -> 3787 for #1341's test_native_read_parquet.py, the twin of native_read_parquet.sh:
+# 15 checks in 15 tests, by collection over the cluster files.
+# cluster_tests 3752 -> 3772 for #1341's test_native_parquet_fdw.py, the twin of native_parquet_fdw.sh:
+# 20 checks in 20 tests, by collection over the cluster files.
+# cluster_tests 3717 -> 3752 for #1341's test_native_parquet_partition.py, the twin of native_parquet_partition.sh:
+# 35 checks in 35 tests, by collection over the cluster files.
+# cluster_tests 3696 -> 3717 for #1341's test_native_parquet_streaming.py, the twin of native_parquet_streaming.sh:
+# 20 checks in 21 tests, by collection over the cluster files.
+# cluster_tests 3688 -> 3696 for #1341's test_native_parquet_stack.py, the twin of native_parquet_stack.sh:
+# 8 checks in 8 tests, by collection over the cluster files.
+# cluster_tests 3670 -> 3688 for #1341's test_native_parquet_hardening.py, the twin of native_parquet_hardening.sh:
+# 18 checks in 18 tests, by collection over the cluster files.
+# cluster_tests 3653 -> 3670 for #1341's test_native_parquet_projection.py, the twin of native_parquet_projection.sh:
+# 17 checks in 17 tests, by collection over the cluster files.
+# cluster_tests 3633 -> 3653 for #1341's test_native_parquet_units.py, the twin of native_parquet_units.sh:
+# 20 checks in 20 tests, by collection over the cluster files.
+# cluster_tests 3619 -> 3633 for #1341's test_native_parquet_codecs.py, the twin of native_parquet_codecs.sh:
+# 14 checks in 14 tests, by collection over the cluster files.
+# cluster_tests 3608 -> 3619 for #1341's test_native_parquet_fieldid.py, the twin of native_parquet_fieldid.sh:
+# 11 checks in 11 tests, by collection over the cluster files.
+# cluster_tests 3580 -> 3608 for #1341's test_native_vecdecode.py, the twin of native_vecdecode.sh:
+# 28 checks in 28 tests, by collection over the cluster files.
+# cluster_tests 3574 -> 3580 for #1341's test_native_zonemap_narrow.py, the twin of native_zonemap_narrow.sh:
+# 6 checks in 6 tests, by collection over the cluster files.
+# cluster_tests 3561 -> 3574 for #1341's test_native_zonemap_session.py, the twin of native_zonemap_session.sh:
+# 13 checks in 13 tests, by collection over the cluster files.
+# cluster_tests 3538 -> 3561 for #1341's test_native_zonemap.py, the twin of native_zonemap.sh:
+# 23 checks in 23 tests, by collection over the cluster files.
+# cluster_tests 3518 -> 3538 for #1341's test_native_rewrite.py, the twin of native_rewrite.sh:
+# 20 checks in 20 tests, by collection over the cluster files.
+# cluster_tests 3496 -> 3518 for #1341's test_native_sort_by.py, the twin of native_sort_by.sh:
+# 21 checks in 22 tests, by collection over the cluster files.
+# cluster_tests 3486 -> 3496 for #1341's test_native_param_pushdown.py, the twin of native_param_pushdown.sh:
+# 10 checks in 10 tests, by collection over the cluster files.
+# cluster_tests 3474 -> 3486 for #1341's test_native_page_offset_bound.py, the twin of native_page_offset_bound.sh:
+# 12 checks in 12 tests, by collection over the cluster files.
+# cluster_tests 3461 -> 3474 for #1341's test_native_reclaim_reconcile.py, the twin of native_reclaim_reconcile.sh:
+# 13 checks in 13 tests, by collection over the cluster files.
+# cluster_tests 3447 -> 3461 for #1341's test_native_late_materialization.py, the twin of native_late_materialization.sh:
+# 14 checks in 14 tests, by collection over the cluster files.
+# cluster_tests 3439 -> 3447 for #1341's test_native_metadata_flush.py, the twin of native_metadata_flush.sh:
+# 8 checks in 8 tests, by collection over the cluster files.
+# cluster_tests 3429 -> 3439 for #1341's test_native_lazy_slot.py, the twin of native_lazy_slot.sh:
+# 10 checks in 10 tests, by collection over the cluster files.
+# cluster_tests 3416 -> 3429 for #1341's test_native_fold_deferral.py, the twin of native_fold_deferral.sh:
+# 13 checks in 13 tests, by collection over the cluster files.
# cluster_tests 3207 -> 3222 for #1341's test_native_fetch_group_memo.py, the twin of native_fetch_group_memo.sh:
# 15 checks in 15 tests, by collection over the cluster files.
# cluster_tests 3201 -> 3207 for #1341's test_native_fold_skipguard.py, the twin of native_fold_skipguard.sh:
@@ -1106,4 +1180,4 @@ guard_tests 806
# 29 tests, one per check, by collection over the cluster files.
# cluster_tests 3362 -> 3416 for #1341's test_parallel_export_parquet.py, the twin of parallel_export_parquet.sh:
# 54 tests, one per check, by collection over the cluster files.
-cluster_tests 3416
+cluster_tests 4072
diff --git a/test/pytest/expected_unrunnable.txt b/test/pytest/expected_unrunnable.txt
index 0ebca301f..9699cc8ee 100644
--- a/test/pytest/expected_unrunnable.txt
+++ b/test/pytest/expected_unrunnable.txt
@@ -109,6 +109,14 @@
18 test_pg_upgrade.py::test_pg_upgrade_preserves_a_columnar_table
19 test_pg_upgrade.py::test_pg_upgrade_preserves_a_columnar_table
+# Every major: test_objstore_s3_read.py's real-S3 arm needs PGC_S3_INTEGRATION_ENDPOINT,
+# which no matrix run sets; the shell records nothing for it there either.
+15 test_objstore_s3_read.py::test_a_real_s3_implementation_reads_what_the_local_file_reads
+16 test_objstore_s3_read.py::test_a_real_s3_implementation_reads_what_the_local_file_reads
+17 test_objstore_s3_read.py::test_a_real_s3_implementation_reads_what_the_local_file_reads
+18 test_objstore_s3_read.py::test_a_real_s3_implementation_reads_what_the_local_file_reads
+19 test_objstore_s3_read.py::test_a_real_s3_implementation_reads_what_the_local_file_reads
+
# Every major: test_concurrency.py's timeout mirror records the shell's fixed
# wait_timeout names only where a bounded wait timed out, as the shell records them
# only on its timeout path. On a green run there is nothing to record, so it declines.
@@ -175,6 +183,15 @@
18 test_pg19_vacuum_options.py::test_vacuum_verbose_reports_on_heap
18 test_pg19_vacuum_options.py::test_vacuum_verbose_is_silent_on_columnar
+# PostgreSQL 15 to 17: virtual generated columns arrived in PostgreSQL 18, so the
+# virtual arm of test_native_sort_by.py declines, as the shell records its skip.
+15 test_native_sort_by.py::test_nsb_set_options_rejects_a_virtual_generated_sort_by_column
+15 test_native_sort_by.py::test_nsb_vacuum_sorted_rejects_an_explicit_virtual_generated_column
+16 test_native_sort_by.py::test_nsb_set_options_rejects_a_virtual_generated_sort_by_column
+16 test_native_sort_by.py::test_nsb_vacuum_sorted_rejects_an_explicit_virtual_generated_column
+17 test_native_sort_by.py::test_nsb_set_options_rejects_a_virtual_generated_sort_by_column
+17 test_native_sort_by.py::test_nsb_vacuum_sorted_rejects_an_explicit_virtual_generated_column
+
# PostgreSQL 15 and 16 refuse PARTITION BY ... USING pgcolumnar, so audit.sh's
# partitioned-parent arm declines there under its check_skip name (#1341).
15 test_audit.py::test_audit_a_partitioned_parent_carries_the_am
diff --git a/test/pytest/test_compare_to_bash.py b/test/pytest/test_compare_to_bash.py
index cee2387e7..d9b679cf7 100644
--- a/test/pytest/test_compare_to_bash.py
+++ b/test/pytest/test_compare_to_bash.py
@@ -253,9 +253,11 @@
"native_fetch_bigcap",
"native_fetch_cache",
"native_fetch_coalesce",
+ "native_fetch_interrupt",
"native_fetch_group_memo",
"native_fetch_position",
"native_fetch_projection",
+ "native_fold_deferral",
"native_fetch_sort_context",
"native_gap",
"native_groupagg",
@@ -267,31 +269,61 @@
"native_ios",
"native_join_runtime_filter",
"native_join_vector_agg",
+ "native_late_materialization",
+ "native_metadata_flush",
+ "native_lazy_slot",
"native_ownership",
+ "native_parquet_codecs",
+ "native_page_offset_bound",
"native_parquet_dict_oob",
+ "native_parquet_fieldid",
"native_parquet_flba",
+ "native_parquet_hardening",
"native_parquet_multifile",
+ "native_parquet_fdw",
+ "native_parquet_partition",
+ "native_parquet_streaming",
"native_parquet_pushdown",
+ "native_parquet_projection",
+ "native_param_pushdown",
"native_parquet_schema",
+ "native_parquet_stack",
+ "native_parquet_units",
"native_projection",
+ "native_read_parquet",
"native_reclaim",
"native_reclaim_cycles",
"native_reclaim_frag",
+ "native_rewrite_conc",
+ "native_reclaim_reconcile",
"native_recluster",
+ "native_rewrite",
"native_repack",
"native_roundtrip",
"native_saop_pushdown",
"native_skip",
+ "native_sort_by",
"native_toasted_write",
"native_truncate",
"native_upgrade_converge",
"native_vacuum_race",
"native_varlena_bound",
+ "native_vecdecode",
"native_vecskip",
+ "native_zonemap_narrow",
+ "native_zonemap_session",
"native_writer",
+ "objstore_addressing",
+ "native_zonemap",
"objstore_allowlist",
"objstore_credentials",
"objstore_crlf",
+ "objstore_module",
+ "objstore_http_read",
+ "objstore_tls_read",
+ "objstore_s3_read",
+ "objstore_stash_recovery",
+ "objstore_listing",
"objstore_userinfo",
"parallel_am_scan",
"parallel_scan_cost",
@@ -300,6 +332,7 @@
"partman_columnar",
"pg_dump_roundtrip",
"pg_upgrade",
+ "phase2",
"prepared_across_sessions",
"prepared_writes",
"projection_drop_column",
@@ -309,6 +342,7 @@
"projection_scan_io",
"projection_update",
"projections",
+ "phase3",
"recovery",
"recovery_minimal_wal",
"replication",
@@ -362,6 +396,11 @@
# having no SKIP verdict for an arm a run chooses not to make, does not carry.
"large_values": "the 1 MB to 200 MB round trips and the TOASTed-update arms are "
"ported; the opt-in chunk-over-1-GB refusal is not",
+ # #1341's remote sink: the opt-in real-S3 arms (PGC_S3_INTEGRATION_ENDPOINT, never
+ # set in CI) need an S3 implementation this layer does not start, and it has no
+ # SKIP verdict for an arm a run chooses not to make.
+ "objstore_sink_write": "every arm against the SigV4 fixture server is ported; the "
+ "two opt-in real-S3 integration arms are not",
}
diff --git a/test/pytest/test_harness_deps.py b/test/pytest/test_harness_deps.py
index ef9041091..b5d44058a 100644
--- a/test/pytest/test_harness_deps.py
+++ b/test/pytest/test_harness_deps.py
@@ -162,6 +162,7 @@
# connection, no cluster, no driver.
"test_docs_style.py",
"test_parquet_level_width.py",
+ "test_native_fetch_interrupt.py",
]
diff --git a/test/pytest/test_native_fetch_interrupt.py b/test/pytest/test_native_fetch_interrupt.py
new file mode 100644
index 000000000..dddda18f4
--- /dev/null
+++ b/test/pytest/test_native_fetch_interrupt.py
@@ -0,0 +1,55 @@
+"""The by-row-number fetch is cancellable (#212). The pytest twin of
+`test/native_fetch_interrupt.sh`, every check by the shell's name (#1341).
+
+pgcolumnar_fetch_row is reached once per candidate item pointer by _bt_check_unique()
+during a unique INSERT, and before #212 it had no CHECK_FOR_INTERRUPTS, so a unique
+INSERT whose conflict check did a lot of fetch work could not be cancelled and never
+noticed postmaster death. The behavioural proof is not a stable CI assertion (see the
+shell's header), so this reads the source, as the shell does: the guard is in
+pgcolumnar_fetch_row, it precedes the pgcolumnar_lookup_row_group call it guards, and
+that helper is where the catalog read lives.
+
+INDEPENDENT OF THE SHELL. The shell awks and greps; this reads the file in Python and
+matches with `re`. Both read the same tree.
+
+COMMENTS ARE NOT CODE. Each body has its comments blanked, multi-line ones included,
+before anything is counted: with the call written as /* CHECK_FOR_INTERRUPTS(); */ every
+check passed (#1341). A blanked comment keeps its newlines, so positions in the body
+still order the code.
+"""
+import pathlib
+import re
+
+READER = pathlib.Path(__file__).resolve().parents[2] / "src" / "columnar_reader.c"
+
+
+def _body(fn):
+ """fn's definition, from its signature line to the first closing brace at column 0,
+ as the shell's awk takes it, with its comments blanked; empty when the file or the
+ function is not there."""
+ try:
+ text = READER.read_text()
+ except OSError:
+ return ""
+ m = re.search(r"^%s\(.*?^}" % re.escape(fn), text, re.M | re.S)
+ if not m:
+ return ""
+ body = re.sub(r"/\*.*?\*/", lambda c: "\n" * c.group(0).count("\n"), m.group(0), flags=re.S)
+ return re.sub(r"//[^\n]*", "", body)
+
+
+def test_fetch_row_carries_an_interrupt_check(expect):
+ expect.text("yes" if "CHECK_FOR_INTERRUPTS" in _body("pgcolumnar_fetch_row") else "no", "yes",
+ "pgcolumnar_fetch_row carries an interrupt check")
+
+
+def test_the_check_precedes_the_row_group_resolution(expect):
+ body = _body("pgcolumnar_fetch_row")
+ cfi, rgl = body.find("CHECK_FOR_INTERRUPTS"), body.find("pgcolumnar_lookup_row_group(")
+ expect.text("yes" if 0 <= cfi < rgl else f"no (the check at offset {cfi}, the call at {rgl}; -1 is absent)", "yes",
+ "the interrupt check precedes the row-group resolution")
+
+
+def test_the_resolution_is_where_the_catalog_read_lives(expect):
+ expect.text("yes" if "PgColumnarReadRowGroupList" in _body("pgcolumnar_lookup_row_group") else "no",
+ "yes", "the row-group resolution is where the catalog read lives")
diff --git a/test/pytest/test_native_fold_deferral.py b/test/pytest/test_native_fold_deferral.py
new file mode 100644
index 000000000..ae825068d
--- /dev/null
+++ b/test/pytest/test_native_fold_deferral.py
@@ -0,0 +1,207 @@
+"""Fold-path payload deferral, with the work measured (#405 step 2). The pytest twin of
+`test/native_fold_deferral.sh`, every check by the shell's name (#1341).
+
+On the batch-fold path the payload (non-key) columns of a row group are materialized
+only for rows that pass the scan keys, gated per group: defer unless the survival seen
+so far exceeds half, the first group optimistic. EXPLAIN ANALYZE reports the work as
+`Columnar Fold Payload Loads` (on a deferred group, survivors times payload width) and
+the gate as `Columnar Fold Deferred Groups: X of Y`.
+
+The answers are compared three ways: the fold, the row path (the vectorized aggregate
+off) and a heap mirror. THE MIRROR IS WRITTEN FROM THE GENERATOR, not copied from the
+columnar table: a copy reads through the scan under test, and with the writer storing
+one value off by one the shell passed every check (#1339).
+
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order; each test
+asserts one reading, so one red cannot stop the next. A failed query is a
+`query_error()`, so two failures never compare equal, and a count that could not be
+read is never defaulted to 0.
+"""
+import re
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_fold_deferral"
+GUC = "SET pgcolumnar.enable_ungrouped_vector_agg = on"
+ROW = "SET pgcolumnar.enable_ungrouped_vector_agg = off"
+NP = 4 # payload columns
+ROWS = 120000
+QSEL = "SELECT sum(p1), sum(p2), sum(p3), sum(p4) FROM fd_t WHERE q <= 1"
+QHI = "SELECT sum(p1), sum(p2), sum(p3), sum(p4) FROM fd_t WHERE q <= 99"
+GEN = f"SELECT 1 + g % 100, g, g*2, g*3, g*4 FROM generate_series(1,{ROWS}) g"
+
+
+def _failed(exc):
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _row(dsn, sql, setting=None):
+ """-> the first row's columns joined by '|', as psql -At prints them, or the failure
+ as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ if setting:
+ conn.execute(setting)
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return _failed(exc)
+ if row is None:
+ return query_error(f"no row from {sql}")
+ return "|".join("" if c is None else str(c) for c in row)
+
+
+def _int(dsn, sql):
+ """-> the single value as an int, or why there is none."""
+ v = _row(dsn, sql)
+ if v.lstrip("-").isdigit():
+ return int(v)
+ return v if v.startswith("QUERY_ERROR") else query_error(f"not a number from {sql}: [{v}]")
+
+
+def _plan_line(dsn, label, sql):
+ """-> the EXPLAIN ANALYZE line carrying `label` under this suite's GUC, stripped, or
+ a query_error() when the plan failed or has no such line."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(GUC)
+ lines = [r[0] for r in conn.execute(
+ f"EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF, SUMMARY OFF) {sql}").fetchall()]
+ except psycopg.Error as exc:
+ return _failed(exc)
+ for line in lines:
+ if label in line:
+ return line.strip()
+ return query_error(f"the plan has no {label} line")
+
+
+def _trailing_int(line):
+ m = re.search(r"(\d+)$", line) if isinstance(line, str) and not line.startswith("QUERY_ERROR") else None
+ return int(m.group(1)) if m else query_error(f"no number at the end of [{line}]")
+
+
+def _run(dsn, sql):
+ """Run one statement and swallow its failure, as the shell's psql_run does."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error:
+ pass
+
+
+@pytest.fixture(scope="module")
+def fd(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ _run(dsn, "CREATE TABLE fd_t (q int4, p1 float8, p2 float8, p3 float8, p4 float8) USING pgcolumnar")
+ _run(dsn, "SELECT pgcolumnar.set_options('fd_t', stripe_row_limit => 20000)")
+ _run(dsn, f"INSERT INTO fd_t {GEN}")
+ _run(dsn, "CREATE TABLE fd_heap (q int4, p1 float8, p2 float8, p3 float8, p4 float8)")
+ _run(dsn, f"INSERT INTO fd_heap {GEN}")
+ r = {}
+ fold = _plan_line(dsn, "Columnar Batch Fold", QSEL)
+ r["takes fold"] = (fold if fold.startswith("QUERY_ERROR")
+ else (re.search(r"yes|no", fold) or [""])[0] or f"no yes/no in [{fold}]")
+ r["ngroups"] = _int(dsn, "SELECT count(*) FROM pgcolumnar.stats('fd_t'::regclass)")
+ r["surv"] = _int(dsn, "SELECT count(*) FROM fd_t WHERE q <= 1")
+ r["cand"] = _int(dsn, "SELECT count(*) FROM fd_t")
+ r["fold sel"] = _row(dsn, QSEL, GUC)
+ r["row sel"] = _row(dsn, QSEL, ROW)
+ r["heap sel"] = _row(dsn, "SELECT sum(p1), sum(p2), sum(p3), sum(p4) FROM fd_heap WHERE q <= 1")
+ r["fold hi"] = _row(dsn, QHI, GUC)
+ r["heap hi"] = _row(dsn, "SELECT sum(p1), sum(p2), sum(p3), sum(p4) FROM fd_heap WHERE q <= 99")
+ r["loads line"] = _plan_line(dsn, "Columnar Fold Payload Loads", QSEL)
+ r["defer line"] = _plan_line(dsn, "Columnar Fold Deferred Groups", QSEL)
+ r["defer hi line"] = _plan_line(dsn, "Columnar Fold Deferred Groups", QHI)
+ r["loads hi line"] = _plan_line(dsn, "Columnar Fold Payload Loads", QHI)
+ r["surv hi"] = _int(dsn, "SELECT count(*) FROM fd_t WHERE q <= 99")
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def _of(line):
+ """-> 'X of Y' from a Deferred Groups line, or why there is none."""
+ m = re.search(r"(\d+) of (\d+)", line) if not line.startswith("QUERY_ERROR") else None
+ return m if m else None
+
+
+def test_fold_deferral_the_selective_query_takes_the_fold(fd, expect):
+ expect.text(fd["takes fold"], "yes", "the selective query takes the fold (ANALYZE, post-#602)")
+
+
+def test_fold_deferral_the_selective_predicate_is_selective(fd, expect):
+ s, c = fd["surv"], fd["cand"]
+ got = ("yes" if s * 50 <= c else f"no ({s} of {c})") if isinstance(s, int) and isinstance(c, int) \
+ else f"no reading ({s}, {c})"
+ expect.text(got, "yes", "premise: the selective predicate is genuinely selective (<= 2%)")
+
+
+def test_fold_deferral_deferred_fold_equals_row_path(fd, expect):
+ expect.text(fd["fold sel"], fd["row sel"], "deferred fold == row path (selective)")
+
+
+def test_fold_deferral_deferred_fold_equals_heap_mirror(fd, expect):
+ expect.text(fd["fold sel"], fd["heap sel"], "deferred fold == heap mirror (selective)")
+
+
+def test_fold_deferral_high_survival_fold_equals_heap_mirror(fd, expect):
+ expect.text(fd["fold hi"], fd["heap hi"], "fold == heap mirror (high survival)")
+
+
+def test_fold_deferral_the_loads_counter_exists(fd, expect):
+ line = fd["loads line"]
+ expect.text("no" if line.startswith("QUERY_ERROR") else "yes", "yes",
+ "the loads counter exists under ANALYZE")
+
+
+def test_fold_deferral_the_predicate_has_survivors(fd, expect):
+ s = fd["surv"]
+ expect.text("yes" if isinstance(s, int) and s > 0 else f"no ({s})", "yes",
+ "premise: the predicate has survivors (the identity is not 0 == 0)")
+
+
+def test_fold_deferral_loads_equal_survivors_times_payload(fd, expect):
+ s = fd["surv"]
+ want = s * NP if isinstance(s, int) else s
+ expect.num(_trailing_int(fd["loads line"]), want,
+ "deferred: loads == survivors x payload width (the identity)")
+
+
+def test_fold_deferral_explain_group_total_matches_stats(fd, expect):
+ m = _of(fd["defer line"])
+ got = int(m.group(2)) if m else query_error(f"no 'X of Y' in [{fd['defer line']}]")
+ expect.num(got, fd["ngroups"], "EXPLAIN group total == stats() group count")
+
+
+def test_fold_deferral_the_fixture_spans_several_groups(fd, expect):
+ m = _of(fd["defer line"])
+ expect.text("yes" if m and int(m.group(2)) >= 3 else f"no ({fd['defer line']})", "yes",
+ "the fixture spans several groups")
+
+
+def test_fold_deferral_every_group_deferred_at_one_percent(fd, expect):
+ m = _of(fd["defer line"])
+ got = f"{m.group(1)} of {m.group(2)}" if m else query_error(f"no 'X of Y' in [{fd['defer line']}]")
+ want = f"{m.group(2)} of {m.group(2)}" if m else "all of them"
+ expect.text(got, want, "deferred: every group deferred at 1% survival")
+
+
+def test_fold_deferral_only_the_first_group_defers_at_high_survival(fd, expect):
+ m = _of(fd["defer hi line"])
+ got = int(m.group(1)) if m else query_error(f"no 'X of Y' in [{fd['defer hi line']}]")
+ expect.num(got, 1, "adaptive: only the optimistic first group deferred at 99% survival")
+
+
+def test_fold_deferral_eager_groups_load_more_than_survivors(fd, expect):
+ loads, s = _trailing_int(fd["loads hi line"]), fd["surv hi"]
+ got = (("yes" if loads > s * NP * 99 // 100 else f"no ({loads} loads, {s} survivors)")
+ if isinstance(loads, int) and isinstance(s, int) else f"no reading ({loads}, {s})")
+ expect.text(got, "yes", "adaptive: eager groups load more than survivors alone (work measured, not assumed)")
diff --git a/test/pytest/test_native_late_materialization.py b/test/pytest/test_native_late_materialization.py
new file mode 100644
index 000000000..ad229bb91
--- /dev/null
+++ b/test/pytest/test_native_late_materialization.py
@@ -0,0 +1,189 @@
+"""Late materialization (#452 phase 1a): a row the qual rejects does not have its payload
+columns built. The pytest twin of `test/native_late_materialization.sh`, every check by
+the shell's name (#1341).
+
+A leading-wildcard LIKE builds no scan key and no min/max can prune it, so nothing is
+pruned and what is measured is the per-row path alone. One row in 1024 carries the
+needle, so every vector keeps a survivor and phase 2's vector skipping rules none out.
+"Columnar Rows Filtered Before Materialization" counts the rows the qual rejected on
+this path; "Rows Removed by Filter" must keep counting them too. The heap mirror is
+built from the generator and is the oracle for every answer, with the feature on, off,
+and refused for a volatile qual.
+
+EVERY READING FAILS CLOSED. A counter that could not be read is a query_error() or a
+"no reading" string, never 0: four checks want exactly 0, and the shell's version of
+them passed with the server stopped. NOT MEASURABLE HERE, as the shell says: whether a
+rejected row's payload is actually left unbuilt (no counter reports per-row
+materialization, and the answers are the same by design).
+
+ON A PRIVATE DATABASE.
+"""
+import re
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_late_materialization"
+ROWS = 20480
+NEEDLE = "zqx"
+GEN = (f"SELECT g AS id, "
+ f"CASE WHEN g % 1024 = 7 THEN 'aaa{NEEDLE}bbb' ELSE 'aaaaaabbb' END AS k, "
+ f"repeat(md5(g::text), 4) AS p1, repeat(md5((g+1)::text), 4) AS p2, "
+ f"repeat(md5((g+2)::text), 4) AS p3 FROM generate_series(1, {ROWS}) g")
+Q = f"SELECT * FROM n WHERE k LIKE '%{NEEDLE}%'"
+QH = f"SELECT * FROM h WHERE k LIKE '%{NEEDLE}%'"
+QV = f"SELECT * FROM n WHERE k LIKE '%{NEEDLE}%' AND random() < 2"
+QVH = f"SELECT * FROM h WHERE k LIKE '%{NEEDLE}%' AND random() < 2"
+OFF = "SET pgcolumnar.enable_late_materialization = off"
+EARLY = "Columnar Rows Filtered Before Materialization"
+# The shell's cluster runs serial; a parallel plan prints per-worker averages.
+SERIAL = "SET max_parallel_workers_per_gather = 0"
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _one(dsn, sql, setting=None):
+ """-> the first column of the first row as text ("" for none or NULL), or the
+ failed query as query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(SERIAL)
+ if setting:
+ conn.execute(setting)
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "" if row is None or row[0] is None else str(row[0])
+
+
+def _counter(dsn, label, sql, setting=None):
+ """-> the integer on the EXPLAIN ANALYZE line holding label, a query_error() when
+ the EXPLAIN failed, or "no reading" when no such line was printed. These counters
+ are always printed, so a missing line is a failed reading, not 0."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(SERIAL)
+ if setting:
+ conn.execute(setting)
+ plan = [r[0] for r in conn.execute(
+ f"EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) {sql}").fetchall()]
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ for line in plan:
+ if label in line:
+ m = re.search(r"\d+", line[line.index(label) + len(label):])
+ if m:
+ return int(m.group(0))
+ return f'no reading (no "{label}" line)'
+
+
+def _int(v):
+ return int(v) if isinstance(v, str) and v.isdigit() else v
+
+
+@pytest.fixture(scope="module")
+def nlm(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ c.execute("CREATE TABLE h (id int, k text, p1 text, p2 text, p3 text)")
+ c.execute("CREATE TABLE n (id int, k text, p1 text, p2 text, p3 text) USING pgcolumnar")
+ c.execute("SELECT pgcolumnar.set_options('n', stripe_row_limit => 65536, "
+ "chunk_group_row_limit => 1024)")
+ c.execute(f"INSERT INTO h {GEN}")
+ c.execute(f"INSERT INTO n {GEN}")
+ c.execute("ANALYZE h")
+ c.execute("ANALYZE n")
+ r = {
+ "rows": _int(_one(dsn, "SELECT count(*) FROM h")),
+ "n match": _int(_one(dsn, f"SELECT count(*) FROM n WHERE k LIKE '%{NEEDLE}%'")),
+ "h match": _int(_one(dsn, f"SELECT count(*) FROM h WHERE k LIKE '%{NEEDLE}%'")),
+ "proj": _counter(dsn, "Columnar Projected Columns", Q),
+ "groups removed": _counter(dsn, "Columnar Chunk Groups Removed by Filter", Q),
+ "vectors skipped": _counter(dsn, "Columnar Vectors Skipped", Q),
+ "early": _counter(dsn, EARLY, Q),
+ "removed": _counter(dsn, "Rows Removed by Filter", Q),
+ "heap md5": _one(dsn, f"SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ({QH}) t"),
+ "col md5": _one(dsn, f"SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ({Q}) t"),
+ "off early": _counter(dsn, EARLY, Q, OFF),
+ "off md5": _one(dsn, f"SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ({Q}) t", OFF),
+ "vol early": _counter(dsn, EARLY, QV),
+ "vol heap md5": _one(dsn, f"SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ({QVH}) t"),
+ "vol col md5": _one(dsn, f"SELECT md5(string_agg(t::text, '|' ORDER BY id)) FROM ({QV}) t"),
+ "same shape early": _counter(dsn, EARLY, Q),
+ }
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def _rejected(r):
+ """-> ROWS minus the matches, or the failed match reading itself."""
+ return ROWS - r["n match"] if isinstance(r["n match"], int) else r["n match"]
+
+
+def test_late_materialization_fixture_loaded_every_row(nlm, expect):
+ expect.num(nlm["rows"], ROWS, "premise: the fixture loaded every row")
+
+
+def test_late_materialization_needle_is_selective(nlm, expect):
+ expect.num(nlm["h match"], 20, "premise: the needle is selective, not most of the table")
+
+
+def test_late_materialization_heap_agrees_on_matches(nlm, expect):
+ expect.num(nlm["h match"], nlm["n match"], "heap agrees on how many rows match")
+
+
+def test_late_materialization_query_runs_on_the_custom_scan(nlm, expect):
+ p = nlm["proj"]
+ expect.text("yes" if isinstance(p, int) else f"no ({p})", "yes",
+ "the query runs on the columnar custom scan")
+
+
+def test_late_materialization_nothing_pruned_at_group_level(nlm, expect):
+ expect.num(nlm["groups removed"], 0, "nothing was pruned at the group level")
+
+
+def test_late_materialization_nothing_pruned_at_vector_level(nlm, expect):
+ expect.num(nlm["vectors skipped"], 0, "nothing was pruned at the vector level")
+
+
+def test_late_materialization_rejected_row_not_materialized(nlm, expect):
+ expect.num(nlm["early"], _rejected(nlm), "a rejected row does not materialize its payload columns")
+
+
+def test_late_materialization_executor_still_reports_filtered_rows(nlm, expect):
+ expect.num(nlm["removed"], _rejected(nlm), "the executor still reports the rows the scan filtered")
+
+
+def test_late_materialization_rows_identical_to_heap(nlm, expect):
+ expect.text(nlm["col md5"], nlm["heap md5"], "the rows are identical to the heap mirror")
+
+
+def test_late_materialization_off_filters_nothing_early(nlm, expect):
+ expect.num(nlm["off early"], 0, "with the feature off nothing is filtered early")
+
+
+def test_late_materialization_off_answers_unchanged(nlm, expect):
+ expect.text(nlm["off md5"], nlm["heap md5"], "and the answers are the same with it off")
+
+
+def test_late_materialization_volatile_qual_refused(nlm, expect):
+ expect.num(nlm["vol early"], 0, "a volatile qual is refused the two-pass path")
+
+
+def test_late_materialization_volatile_matches_heap(nlm, expect):
+ expect.text(nlm["vol col md5"], nlm["vol heap md5"], "and the volatile query still matches the heap mirror")
+
+
+def test_late_materialization_same_shape_uses_the_path(nlm, expect):
+ expect.num(nlm["same shape early"], _rejected(nlm), "the same shape without the volatile term does use it")
diff --git a/test/pytest/test_native_lazy_slot.py b/test/pytest/test_native_lazy_slot.py
new file mode 100644
index 000000000..98f6e8ece
--- /dev/null
+++ b/test/pytest/test_native_lazy_slot.py
@@ -0,0 +1,247 @@
+"""Deferred column decode on the index fetch path (#157). The pytest twin of
+`test/native_lazy_slot.sh`, every check by the shell's name (#1341).
+
+An index fetch hands the executor a slot carrying the row's address, and the columns
+are decoded when slot_getsomeattrs asks for them, the smallest prefix it needs.
+Asserted, through an index scan with the custom scan off: values match a heap table for
+every column position and for count(*); a row still in this transaction's write buffer
+reads correctly; the paths that need a whole row (sort, CREATE TABLE AS, a unique
+check) still get one; and a wide table is not in a different class from a narrow one
+for the same one-column fetches.
+
+THE HEAP MIRROR IS WRITTEN FROM THE GENERATOR, as the shell's is: a copy read through
+the columnar reader agreed with it on a value the writer stored wrong.
+
+READINGS ARE VALUES: a failed query is a query_error(), which every comparison refuses
+by name, so two failed readings never agree; a timing whose statement failed is no
+reading rather than a fast one.
+
+ON A PRIVATE DATABASE, built in the shell's order by a module fixture.
+"""
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_lazy_slot"
+ROWS = 20000
+NCOLS = 40
+CLIFF_ROWS = 30000
+FORCE = ("SET enable_seqscan = off", "SET enable_bitmapscan = off",
+ "SET pgcolumnar.enable_custom_scan = off", "SET max_parallel_workers_per_gather = 0")
+
+
+def _message(exc):
+ diag = getattr(exc, "diag", None)
+ primary = diag.message_primary if diag is not None else None
+ return primary or (str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _value(dsn, sql, force=True):
+ """-> the first column of the last row as text, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ if force:
+ for s in FORCE:
+ conn.execute(s)
+ cur = conn.execute(sql)
+ rows = cur.fetchall()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if not rows or rows[-1][0] is None:
+ return query_error(f"no value from {sql}")
+ return str(rows[-1][0])
+
+
+def _lines(dsn, sql):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for s in FORCE:
+ conn.execute(s)
+ return [r[0] for r in conn.execute(sql).fetchall()]
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+
+
+def _setup(dsn, statements):
+ """-> None, or the failure as a query_error(), so a setup that fails reds the
+ readings that depend on it by name instead of erroring the module."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for s in statements:
+ conn.execute(s)
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return None
+
+
+def _ms(dsn, table):
+ """-> milliseconds for the shell's 2000 one-column fetches, or a query_error() when
+ the statement failed: a statement that errors at once is fast, and two of them
+ must not pass the ratio."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for s in FORCE:
+ conn.execute(s)
+ t0 = time.perf_counter()
+ conn.execute(f"SELECT count(c1) FROM {table} WHERE id <= 2000").fetchall()
+ t1 = time.perf_counter()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return int((t1 - t0) * 1000)
+
+
+def _build_wide(table, ncols):
+ cols = "".join(f", c{i} text" for i in range(1, ncols + 1))
+ vals = "".join(", repeat('x', 40)" for _ in range(ncols))
+ return [f"DROP TABLE IF EXISTS {table}",
+ f"CREATE TABLE {table} (id int{cols}) USING pgcolumnar",
+ f"INSERT INTO {table} SELECT g{vals} FROM generate_series(1, {CLIFF_ROWS}) g",
+ f"CREATE INDEX {table}_id ON {table} (id)"]
+
+
+@pytest.fixture(scope="module")
+def lz(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ cols = "".join(f", c{i} bigint" for i in range(1, NCOLS + 1))
+ sel = "".join(f", g * {i}" for i in range(1, NCOLS + 1))
+ gen = f"SELECT g{sel}, 'v' || g FROM generate_series(1, {ROWS}) g"
+ r = {}
+ err = _setup(dsn, [
+ "CREATE EXTENSION IF NOT EXISTS pgcolumnar",
+ f"CREATE TABLE lz_c (id int{cols}, txt text) USING pgcolumnar",
+ f"INSERT INTO lz_c {gen}",
+ "CREATE INDEX lz_c_id ON lz_c (id)",
+ "CREATE TABLE lz_h (LIKE lz_c)",
+ # from the generator, not from lz_c (see the module docstring)
+ f"INSERT INTO lz_h {gen}",
+ "CREATE INDEX lz_h_id ON lz_h (id)"])
+
+ plan = _lines(dsn, "EXPLAIN (COSTS off) SELECT c1 FROM lz_c WHERE id = 5")
+ r["index scan"] = err or (plan if isinstance(plan, str)
+ else str(sum(1 for line in plan if "Index Scan" in line)))
+
+ bad = []
+ for expr in ("c1", "c2", "c20", f"c{NCOLS}", "txt", f"c1 + c{NCOLS}", "id"):
+ cv = _value(dsn, f"SELECT {expr} FROM lz_c WHERE id = 7777")
+ hv = _value(dsn, f"SELECT {expr} FROM lz_h WHERE id = 7777")
+ # failures are query_error() values, unique per failure, so two never agree
+ if cv != hv:
+ bad.append(f"[{expr}: {cv} vs {hv}]")
+ r["positions"] = " ".join(bad) if bad else "same"
+
+ for key, c_sql, h_sql in (
+ ("star", "SELECT md5(lz_c::text) FROM lz_c WHERE id = 999",
+ "SELECT md5(lz_h::text) FROM lz_h WHERE id = 999"),
+ ("range", f"SELECT sum(c1) + sum(c{NCOLS}) FROM lz_c WHERE id <= 2000",
+ f"SELECT sum(c1) + sum(c{NCOLS}) FROM lz_h WHERE id <= 2000"),
+ ("count", "SELECT count(*) FROM lz_c WHERE id <= 2000",
+ "SELECT count(*) FROM lz_h WHERE id <= 2000"),
+ ("order", "SELECT string_agg(c1::text, ',' ORDER BY c1) "
+ "FROM (SELECT c1 FROM lz_c WHERE id <= 20) s",
+ "SELECT string_agg(c1::text, ',' ORDER BY c1) "
+ "FROM (SELECT c1 FROM lz_h WHERE id <= 20) s")):
+ r[key] = (_value(dsn, c_sql), _value(dsn, h_sql))
+
+ # a row still in this transaction's write buffer
+ try:
+ with psycopg.connect(dsn, connect_timeout=10) as conn:
+ for s in FORCE:
+ conn.execute(s)
+ conn.execute("INSERT INTO lz_c (id, c1, txt) VALUES (999999, 4242, 'buffered')")
+ row = conn.execute("SELECT c1 || '|' || txt FROM lz_c WHERE id = 999999").fetchone()
+ conn.rollback()
+ r["buffered"] = row[0] if row and row[0] is not None else query_error("no buffered row")
+ except psycopg.Error as exc:
+ r["buffered"] = query_error(_message(exc))
+
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for s in FORCE:
+ conn.execute(s)
+ conn.execute("DROP TABLE IF EXISTS lz_copy")
+ conn.execute("CREATE TABLE lz_copy AS SELECT * FROM lz_c WHERE id <= 500")
+ n = conn.execute(f"SELECT count(*) FROM lz_copy c JOIN lz_h h USING (id) "
+ f"WHERE c.c1 IS DISTINCT FROM h.c1 "
+ f"OR c.c{NCOLS} IS DISTINCT FROM h.c{NCOLS} "
+ f"OR c.txt IS DISTINCT FROM h.txt").fetchone()[0]
+ r["copy"] = str(n)
+ except psycopg.Error as exc:
+ r["copy"] = query_error(_message(exc))
+
+ u_err = _setup(dsn, ["DROP TABLE IF EXISTS lz_u",
+ "CREATE TABLE lz_u (id int, v bigint) USING pgcolumnar",
+ "INSERT INTO lz_u SELECT g, g FROM generate_series(1, 1000) g",
+ "CREATE UNIQUE INDEX lz_u_id ON lz_u (id)"])
+ if u_err:
+ r["unique"] = u_err
+ else:
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute("INSERT INTO lz_u VALUES (500, 500)")
+ r["unique"] = "no (the duplicate was accepted)"
+ except psycopg.Error as exc:
+ msg = _message(exc)
+ r["unique"] = "yes" if "duplicate key" in msg else f"no ({msg})"
+
+ w_err = _setup(dsn, _build_wide("lz_n", 2) + _build_wide("lz_w", NCOLS))
+ narrow = w_err or _ms(dsn, "lz_n")
+ wide = w_err or _ms(dsn, "lz_w")
+ if isinstance(narrow, int) and isinstance(wide, int):
+ r["cliff"] = "yes" if narrow > 0 and wide / narrow < 25 else f"no ({narrow}ms vs {wide}ms)"
+ else:
+ r["cliff"] = f"no reading ({narrow} / {wide})"
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_lazy_slot_the_fetch_is_an_index_scan(lz, expect):
+ expect.text(lz["index scan"], "1", "the fetch really is an index scan")
+
+
+def test_lazy_slot_every_column_position_matches_heap(lz, expect):
+ expect.text(lz["positions"], "same", "single-row index fetches match heap for every column position")
+
+
+def test_lazy_slot_select_star_matches_heap(lz, expect):
+ c, h = lz["star"]
+ expect.text(c, h, "SELECT * through the index matches heap")
+
+
+def test_lazy_slot_a_range_sums_the_same_as_heap(lz, expect):
+ c, h = lz["range"]
+ expect.text(c, h, "a range of rows sums the same as heap")
+
+
+def test_lazy_slot_count_star_matches_heap(lz, expect):
+ c, h = lz["count"]
+ expect.text(c, h, "count(*) through the index matches heap")
+
+
+def test_lazy_slot_a_buffered_row_reads_correctly(lz, expect):
+ expect.text(lz["buffered"], "4242|buffered", "a row fetched before it is flushed reads correctly")
+
+
+def test_lazy_slot_an_order_by_matches_heap(lz, expect):
+ c, h = lz["order"]
+ expect.text(c, h, "an ORDER BY over index-fetched rows matches heap")
+
+
+def test_lazy_slot_a_copy_keeps_every_column(lz, expect):
+ expect.text(lz["copy"], "0", "copying index-fetched rows into a heap table keeps every column")
+
+
+def test_lazy_slot_a_unique_violation_is_detected(lz, expect):
+ expect.text(lz["unique"], "yes", "a unique violation is still detected through the fetch path")
+
+
+def test_lazy_slot_a_wide_table_is_not_in_another_class(lz, expect):
+ expect.text(lz["cliff"], "yes", "a wide table is not in a different class from a narrow one")
diff --git a/test/pytest/test_native_metadata_flush.py b/test/pytest/test_native_metadata_flush.py
new file mode 100644
index 000000000..be832a75b
--- /dev/null
+++ b/test/pytest/test_native_metadata_flush.py
@@ -0,0 +1,174 @@
+"""A stripe flush opens each metadata table ONCE, not once per row (#445). The pytest twin
+of `test/native_metadata_flush.sh`, every check by the shell's name (#1341).
+
+The witness is the DEBUG1 line each flush emits, "pgcolumnar metadata flush: opens=N
+tables=M", where N counts the real metadata relation opens of that flush. Batched, a
+3-column and a 20-column flush open the same small set of tables; un-batched, the wide
+one opens one relation per metadata row (141 against 22, measured). The count is held
+from both sides: no more than six, and at least the three tables every flush writes, so
+a witness that counted nothing (opens=0) cannot pass as "small and equal".
+
+The written data is compared with a heap mirror built FROM THE GENERATOR, not copied
+from the columnar table, so a writer that stored a wrong value cannot agree with itself.
+And the range read must rule out a vector by its zone row alone (Skipped minus Ruled Out
+by Value), because the reader also rules vectors out by value after decoding, with or
+without a usable zone map.
+
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order; each test
+asserts one reading, and a failed query is a `query_error()`.
+"""
+import re
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_metadata_flush"
+ROWS = 2000
+LIMITS = ("SET pgcolumnar.stripe_row_limit = 2000", "SET pgcolumnar.chunk_group_row_limit = 500")
+WIDE_COLS = [f"c{i:02d}" for i in range(20)]
+WIDE_SUM = "+".join(WIDE_COLS)
+GEN20 = ",".join(["g"] * 20)
+RANGE = "c00 BETWEEN 500 AND 1500"
+
+
+def _failed(exc):
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _run(dsn, sql):
+ """Run one statement and swallow its failure, as the shell's q does for setup: a
+ failed step shows up in the readings after it."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error:
+ pass
+
+
+def _one(dsn, sql):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return _failed(exc)
+ if row is None or row[0] is None:
+ return query_error(f"no value from {sql}")
+ return str(row[0])
+
+
+def _opens(dsn, sql):
+ """-> N from the last 'metadata flush: opens=N' DEBUG1 line the statement raised in a
+ session holding the stripe limits, as the shell's opens() reads it; or a failure."""
+ messages = []
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.add_notice_handler(lambda d: messages.append(d.message_primary or ""))
+ conn.execute("SET client_min_messages = debug1")
+ for s in LIMITS:
+ conn.execute(s)
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ return _failed(exc)
+ found = [m.group(1) for line in messages for m in [re.search(r"metadata flush: opens=(\d+)", line)] if m]
+ return found[-1] if found else query_error("no 'metadata flush: opens=' line from the flush")
+
+
+def _plan_field(plan, label):
+ if not isinstance(plan, list):
+ return plan
+ for line in plan:
+ m = re.search(re.escape(label) + r": (\d+)", line)
+ if m:
+ return m.group(1)
+ return query_error(f"no '{label}' line in the range read's plan")
+
+
+def _plan(dsn, sql):
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("SET max_parallel_workers_per_gather = 0")
+ return [r[0] for r in conn.execute(sql).fetchall()]
+ except psycopg.Error as exc:
+ return _failed(exc)
+
+
+@pytest.fixture(scope="module")
+def mf(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ r = {}
+ _run(dsn, "CREATE TABLE tprobe (a int, b int, c int) USING pgcolumnar")
+ r["probe"] = _opens(dsn, f"INSERT INTO tprobe SELECT g, g*2, g%7 FROM generate_series(1,{ROWS}) g")
+ _run(dsn, "CREATE TABLE tnarrow (a int, b int, c int) USING pgcolumnar")
+ _run(dsn, "CREATE TABLE twide (" + ", ".join(f"{c} int" for c in WIDE_COLS) + ") USING pgcolumnar")
+ r["narrow"] = _opens(dsn, f"INSERT INTO tnarrow SELECT g, g*2, g%7 FROM generate_series(1,{ROWS}) g")
+ r["wide"] = _opens(dsn, f"INSERT INTO twide SELECT {GEN20} FROM generate_series(1,{ROWS}) g")
+ # The mirror is written from the generator, not copied from twide (#1339).
+ _run(dsn, "CREATE TABLE twide_h (LIKE twide)")
+ _run(dsn, f"INSERT INTO twide_h SELECT {GEN20} FROM generate_series(1,{ROWS}) g")
+ r["count"] = _one(dsn, "SELECT count(*) FROM twide")
+ r["sum col"] = _one(dsn, f"SELECT sum({WIDE_SUM}) FROM twide")
+ r["sum heap"] = _one(dsn, f"SELECT sum({WIDE_SUM}) FROM twide_h")
+ r["range col"] = _one(dsn, f"SELECT count(*) FROM twide WHERE {RANGE}")
+ r["range heap"] = _one(dsn, f"SELECT count(*) FROM twide_h WHERE {RANGE}")
+ plan = _plan(dsn, f"EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM twide WHERE {RANGE}")
+ r["skipped"] = _plan_field(plan, "Columnar Vectors Skipped")
+ r["by value"] = _plan_field(plan, "Columnar Vectors Ruled Out by Value")
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def _num(v):
+ return int(v) if isinstance(v, str) and v.isdigit() else v
+
+
+def test_native_metadata_flush_emits_the_witness(mf, expect):
+ expect.text("yes" if isinstance(mf["probe"], str) and mf["probe"].isdigit() else f"no ({mf['probe']})", "yes",
+ "the flush emits the metadata-open witness")
+
+
+def test_native_metadata_flush_narrow_and_wide_open_the_same(mf, expect):
+ expect.num(_num(mf["narrow"]), _num(mf["wide"]),
+ "narrow (3 col) and wide (20 col) flush open the same number of metadata relations")
+
+
+def test_native_metadata_flush_wide_opens_a_small_constant(mf, expect):
+ w = mf["wide"]
+ got = "small" if isinstance(w, str) and w.isdigit() and int(w) <= 6 else f"big:[{w}]"
+ expect.text(got, "small", "a 20-column flush opens a small constant number of metadata relations, not one per row")
+
+
+def test_native_metadata_flush_wide_opens_at_least_three(mf, expect):
+ w = mf["wide"]
+ got = "yes" if isinstance(w, str) and w.isdigit() and int(w) >= 3 else f"no:[{w}]"
+ expect.text(got, "yes", "and the 20-column flush opens at least the three tables every flush writes")
+
+
+def test_native_metadata_flush_row_count_survives(mf, expect):
+ expect.num(_num(mf["count"]), ROWS, "the wide table's row count survives the batched flush")
+
+
+def test_native_metadata_flush_every_column_reads_back(mf, expect):
+ expect.num(_num(mf["sum col"]), _num(mf["sum heap"]),
+ "every column reads back correct (sum over all 20 columns matches the heap mirror)")
+
+
+def test_native_metadata_flush_range_read_matches_heap(mf, expect):
+ expect.num(_num(mf["range col"]), _num(mf["range heap"]),
+ "a zone-map-pruned range read matches heap (the batched zone rows are correct)")
+
+
+def test_native_metadata_flush_zone_row_rules_out_a_vector(mf, expect):
+ s, b = mf["skipped"], mf["by value"]
+ ok = isinstance(s, str) and s.isdigit() and isinstance(b, str) and b.isdigit() and int(s) - int(b) >= 1
+ expect.text("yes" if ok else f"no:[skipped {s}, by value {b}]", "yes",
+ "and that range read rules out a vector by its zone row alone")
diff --git a/test/pytest/test_native_page_offset_bound.py b/test/pytest/test_native_page_offset_bound.py
new file mode 100644
index 000000000..7364fb361
--- /dev/null
+++ b/test/pytest/test_native_page_offset_bound.py
@@ -0,0 +1,210 @@
+"""A corrupt column_chunk.page_offset is refused by the containment guard on both
+reads, and the backend survives (#1341). The pytest twin of
+`test/native_page_offset_bound.sh`, every check by the shell's name.
+
+The whole-group read (pgcolumnar.enable_column_projection = off) reads a group as one
+span and derives each chunk's base from page_offset; the projected read computes its
+ranges from it. Each guard refuses a chunk outside its group with XX001 and "... lies
+outside row group", overflow-safe for page_offset = -1.
+
+A REFUSAL IS THE CONTAINMENT GUARD'S OWN: a reading is "XX001 (containment guard)"
+only when the primary message is the guard's, so a crash or a later decode error is
+not a refusal. A BACKEND THAT DIED DOES NOT PASS AS ONE THAT SURVIVED: survival also
+reads the server log's count of backends killed by a signal since the arm began, and
+an unreadable log fails it. AN INTACT TABLE STILL READS: the controls read rows
+behind an OFFSET 0 fence, which an aggregate over an intact table would not, so a
+guard that refuses a legal chunk is caught.
+
+ON A PRIVATE DATABASE, built in the shell's order; each test asserts one reading.
+"""
+import re
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_page_offset_bound"
+GUARD_RE = r"lies outside row group"
+WANT = "XX001 (containment guard)"
+EVERY_1000 = ",".join(str(i) for i in range(1000, 20001, 1000))
+OFF = "SET pgcolumnar.enable_column_projection = off"
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _run(dsn, sql, off=False):
+ """-> None when every statement ran, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ if off:
+ conn.execute(OFF)
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return None
+
+
+def _value(dsn, sql, off=False):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ if off:
+ conn.execute(OFF)
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None or row[0] is None:
+ return query_error(f"no value from {sql}")
+ return str(row[0])
+
+
+def _refusal(dsn, sql, off=False):
+ """-> "XX001 (containment guard)" when the primary message is the guard's,
+ ": " for any other error, or what happened instead."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ if off:
+ conn.execute(OFF)
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return "no refusal: the connection was lost"
+ msg = exc.diag.message_primary or ""
+ return f"{exc.sqlstate} (containment guard)" if re.search(GUARD_RE, msg) else f"{exc.sqlstate}: {msg}"
+ return "no error"
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ """-> "1" when the server answers and no backend died since `before`; after a
+ crash, waits out the recovery so the arms after it are not collateral."""
+ up, now = _value(dsn, "SELECT 1"), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ if up == "1" and now == before:
+ return "1"
+ for _ in range(60):
+ if _value(dsn, "SELECT 1") == "1":
+ break
+ time.sleep(0.5)
+ return f"no ([{up if up == '1' else ''}], {now - before} backend crashes)"
+
+
+def _mkc(dsn):
+ _run(dsn, "DROP TABLE IF EXISTS c")
+ _run(dsn, "CREATE TABLE c (id int, r bigint, t text) USING pgcolumnar")
+ _run(dsn, "INSERT INTO c SELECT g, (random()*9e18)::bigint, 'v'||(g%5) FROM generate_series(1,20000) g")
+
+
+def _corrupt(dsn, setexpr, column_index):
+ _run(dsn, f"UPDATE pgcolumnar.column_chunk SET page_offset = {setexpr} "
+ f"WHERE storage_id = pgcolumnar.get_storage_id('c') AND column_index = {column_index}")
+
+
+@pytest.fixture(scope="module")
+def pob(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ crashes = lambda: _crashes(pgc_cluster) # noqa: E731
+ alive = lambda b: _survived(dsn, pgc_cluster, b) # noqa: E731
+ r = {}
+
+ _mkc(dsn)
+ r["intact off"] = _value(dsn, "SELECT string_agg(id::text, ',' ORDER BY id) FROM "
+ "(SELECT * FROM c WHERE id % 1000 = 0 AND r IS NOT NULL "
+ "AND t LIKE 'v%' OFFSET 0) s", off=True)
+ r["intact proj"] = _value(dsn, "SELECT string_agg(id::text, ',' ORDER BY id) FROM "
+ "(SELECT id FROM c WHERE id % 1000 = 0 OFFSET 0) s")
+
+ _mkc(dsn)
+ _corrupt(dsn, "page_offset + 999999999", 1)
+ b = crashes()
+ r["proj overrun"] = _refusal(dsn, "SELECT sum(r) FROM c")
+ r["proj overrun up"] = alive(b)
+
+ _mkc(dsn)
+ _corrupt(dsn, "page_offset + 999999999", 1)
+ b = crashes()
+ r["whole overrun"] = _refusal(dsn, "SELECT * FROM c", off=True)
+ r["whole overrun up"] = alive(b)
+
+ _mkc(dsn)
+ _corrupt(dsn, "0", 2)
+ b = crashes()
+ r["whole wrap"] = _refusal(dsn, "SELECT * FROM c", off=True)
+ r["whole wrap up"] = alive(b)
+
+ _mkc(dsn)
+ _corrupt(dsn, "-1", 1)
+ b = crashes()
+ r["whole minus1"] = _refusal(dsn, "SELECT * FROM c", off=True)
+ r["whole minus1 up"] = alive(b)
+ b = crashes()
+ r["proj minus1"] = _refusal(dsn, "SELECT sum(r) FROM c")
+ r["proj minus1 up"] = alive(b)
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_an_intact_table_reads_every_row_with_projection_off(pob, expect):
+ expect.text(pob["intact off"], EVERY_1000, "an intact table reads every row and value with projection off")
+
+
+def test_an_intact_tables_projected_read_returns_every_id(pob, expect):
+ expect.text(pob["intact proj"], EVERY_1000, "an intact table's projected read returns every id")
+
+
+def test_a_projected_page_offset_overrun_is_refused_by_the_guard(pob, expect):
+ expect.text(pob["proj overrun"], WANT, "projected page_offset overrun is refused by the containment guard")
+
+
+def test_the_backend_survives_the_projected_overrun(pob, expect):
+ expect.text(pob["proj overrun up"], "1", "alive after projected overrun")
+
+
+def test_a_whole_group_page_offset_overrun_is_refused_by_the_guard(pob, expect):
+ expect.text(pob["whole overrun"], WANT, "whole-group page_offset overrun is refused by the containment guard")
+
+
+def test_the_backend_survives_the_whole_group_overrun(pob, expect):
+ expect.text(pob["whole overrun up"], "1", "alive after whole-group overrun")
+
+
+def test_a_whole_group_page_offset_of_zero_is_refused_by_the_guard(pob, expect):
+ expect.text(pob["whole wrap"], WANT, "whole-group page_offset=0 is refused by the containment guard")
+
+
+def test_the_backend_survives_the_whole_group_wrap(pob, expect):
+ expect.text(pob["whole wrap up"], "1", "alive after whole-group wrap")
+
+
+def test_a_whole_group_page_offset_of_minus_one_hits_the_guard(pob, expect):
+ expect.text(pob["whole minus1"], WANT, "whole-group page_offset=-1 hits the containment guard")
+
+
+def test_the_backend_survives_the_whole_group_minus_one(pob, expect):
+ expect.text(pob["whole minus1 up"], "1", "alive after whole-group page_offset=-1")
+
+
+def test_a_projected_page_offset_of_minus_one_hits_the_guard(pob, expect):
+ expect.text(pob["proj minus1"], WANT, "projected page_offset=-1 hits the containment guard")
+
+
+def test_the_backend_survives_the_projected_minus_one(pob, expect):
+ expect.text(pob["proj minus1 up"], "1", "alive after projected page_offset=-1")
diff --git a/test/pytest/test_native_param_pushdown.py b/test/pytest/test_native_param_pushdown.py
new file mode 100644
index 000000000..6bd5ae1ab
--- /dev/null
+++ b/test/pytest/test_native_param_pushdown.py
@@ -0,0 +1,189 @@
+"""Chunk-group skipping for parameterized predicates. The pytest twin of
+`test/native_param_pushdown.sh`, every check by the shell's name (#1341).
+
+The scan-key builder accepts only a Const operand, so Begin freezes execution-stable
+operands (a PARAM_EXTERN, or a subexpression with no Var, no PARAM_EXEC and no volatile
+function) into Consts before building the keys. A correlated PARAM_EXEC (a nestloop
+inner scan's reference to the outer row) changes per rescan and the keys are built
+once, so it must NOT be frozen.
+
+Three things the shell's first version could not see, now read in both twins:
+- with the PARAM_EXEC guard removed the lower-bound correlated arm still answered
+ 3002, because Begin runs before any outer row sets the param and "ts >= " prunes nothing. The arm now reads the scan's own "Usable Skip
+ Predicates" and asks the upper bound too, where a frozen initial value prunes all;
+- every arm ran one execution per session, so a value frozen once and reused by a
+ later execution passed. Two executions in one session now each count and prune
+ with their own value;
+- survival was a bare SELECT 1; it now also reads the server log's crash count.
+
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order; each test
+asserts one reading. A failed query is a `query_error()`, so two failures never agree.
+"""
+import re
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_param_pushdown"
+CORR_GE = "SELECT sum(c) FROM thr, LATERAL (SELECT count(*) c FROM t WHERE t.ts >= thr.lo) s"
+CORR_LE = "SELECT sum(c) FROM thr, LATERAL (SELECT count(*) c FROM t WHERE t.ts <= thr.lo) s"
+GENERIC = "SET plan_cache_mode = force_generic_plan"
+
+
+def _failed(exc):
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _await_up(dsn):
+ for _ in range(60):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _session(dsn, statements):
+ """Run `statements` in ONE session and return the rows of each, as text lists, or
+ the failure as a query_error()."""
+ _await_up(dsn)
+ out = []
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ for sql in statements:
+ cur = conn.execute(sql)
+ rows = cur.fetchall() if cur.description else []
+ out.append(["" if r[0] is None else str(r[0]) for r in rows])
+ except psycopg.Error as exc:
+ return _failed(exc)
+ return out
+
+
+def _value(dsn, sql):
+ res = _session(dsn, [sql])
+ if isinstance(res, str):
+ return res
+ return res[0][0] if res[0] else query_error(f"no row from {sql}")
+
+
+def _field(lines, label):
+ """-> the number after `label:` in an EXPLAIN, or why there is none."""
+ if isinstance(lines, str):
+ return lines
+ for line in lines:
+ m = re.search(re.escape(label) + r": (\d+)", line)
+ if m:
+ return m.group(1)
+ return query_error(f"the plan has no {label} line")
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ up, now = _value(dsn, "SELECT 1"), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ if up == "1" and now == before:
+ return "1"
+ _await_up(dsn)
+ return f"no ([{up}], {now - before} backend crashes)"
+
+
+@pytest.fixture(scope="module")
+def pp(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ # 40000 rows / 2000 = 20 monotonic groups; ts >= 39000 lives in the last group.
+ _session(dsn, ["CREATE EXTENSION IF NOT EXISTS pgcolumnar",
+ "SET pgcolumnar.stripe_row_limit = 2000",
+ "CREATE TABLE t (id int, ts int) USING pgcolumnar",
+ "INSERT INTO t SELECT g, g FROM generate_series(1, 40000) g",
+ "CREATE TABLE thr (lo int)",
+ "INSERT INTO thr VALUES (39000), (38000)"])
+ before = _crashes(pgc_cluster)
+ r = {}
+ plan = _session(dsn, [GENERIC, "DEALLOCATE ALL",
+ "PREPARE p(int) AS SELECT count(*) FROM t WHERE ts >= $1",
+ "EXPLAIN (ANALYZE, TIMING off, SUMMARY off) EXECUTE p(39000)"])
+ r["removed"] = _field(plan if isinstance(plan, str) else plan[-1], "Chunk Groups Removed by Filter")
+ cnt = _session(dsn, [GENERIC, "DEALLOCATE ALL",
+ "PREPARE c(int) AS SELECT count(*) FROM t WHERE ts >= $1",
+ "EXECUTE c(39000)"])
+ r["prep count"] = cnt if isinstance(cnt, str) else (cnt[-1][0] if cnt[-1] else query_error("no count"))
+ r["literal count"] = _value(dsn, "SELECT count(*) FROM t WHERE ts >= 39000")
+ corr = _session(dsn, [f"EXPLAIN (ANALYZE, TIMING off, SUMMARY off, COSTS off) {CORR_GE}"])
+ corr = corr if isinstance(corr, str) else corr[0]
+ r["corr rescans"] = (corr if isinstance(corr, str) else
+ str(sum(1 for l in corr if re.search(
+ r"Custom Scan \(PgColumnarScan\) on t \(actual rows=[\d.]+ loops=2\)", l))))
+ r["corr skip preds"] = _field(corr, "Columnar Usable Skip Predicates")
+ r["corr ge"] = _value(dsn, CORR_GE)
+ r["corr le"] = _value(dsn, CORR_LE)
+ twice = _session(dsn, [GENERIC, "DEALLOCATE ALL",
+ "PREPARE w(int) AS SELECT count(*) FROM t WHERE ts >= $1",
+ "EXECUTE w(39000)", "EXECUTE w(38000)",
+ "EXPLAIN (ANALYZE, TIMING off, SUMMARY off) EXECUTE w(38000)"])
+ if isinstance(twice, str):
+ r["twice count"] = r["twice removed"] = twice
+ else:
+ r["twice count"] = twice[4][0] if twice[4] else query_error("no second count")
+ r["twice removed"] = _field(twice[5], "Chunk Groups Removed by Filter")
+ r["survived"] = _survived(dsn, pgc_cluster, before)
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_param_pushdown_a_generic_plan_parameterized_qual_prunes(pp, expect):
+ expect.text(pp["removed"], "19",
+ "a generic-plan parameterized qual prunes chunk groups (19 of 20 removed)")
+
+
+def test_param_pushdown_the_parameterized_count_matches_the_literal(pp, expect):
+ expect.text(pp["prep count"], "1001", "parameterized count matches the literal count")
+
+
+def test_param_pushdown_the_literal_count_is_the_expected_value(pp, expect):
+ expect.text(pp["literal count"], "1001", "literal count is the expected value")
+
+
+def test_param_pushdown_the_correlated_arm_rescans_the_columnar_scan(pp, expect):
+ expect.text(pp["corr rescans"], "1", "the correlated arm rescans the columnar scan once per outer row")
+
+
+def test_param_pushdown_a_correlated_param_exec_builds_no_skip_predicate(pp, expect):
+ expect.text(pp["corr skip preds"], "0", "a correlated PARAM_EXEC builds no skip predicate")
+
+
+def test_param_pushdown_a_correlated_param_exec_is_not_mis_pruned(pp, expect):
+ expect.text(pp["corr ge"], "3002", "a correlated PARAM_EXEC is not mis-pruned (results stay correct)")
+
+
+def test_param_pushdown_a_correlated_upper_bound_is_not_mis_pruned(pp, expect):
+ expect.text(pp["corr le"], "77000", "a correlated PARAM_EXEC upper bound is not mis-pruned either")
+
+
+def test_param_pushdown_a_second_execution_counts_with_its_own_value(pp, expect):
+ expect.text(pp["twice count"], "2001", "a second execution in the same session counts with its own value")
+
+
+def test_param_pushdown_a_second_execution_prunes_with_its_own_value(pp, expect):
+ expect.text(pp["twice removed"], "18", "and prunes with its own value too (18 of 20 removed)")
+
+
+def test_param_pushdown_the_backend_is_alive(pp, expect):
+ expect.text(pp["survived"], "1", "backend alive")
diff --git a/test/pytest/test_native_parquet_codecs.py b/test/pytest/test_native_parquet_codecs.py
new file mode 100644
index 000000000..b7065bd2f
--- /dev/null
+++ b/test/pytest/test_native_parquet_codecs.py
@@ -0,0 +1,248 @@
+"""Parquet compression-codec read coverage (Phase G). The pytest twin of
+`test/native_parquet_codecs.sh`, every check by the shell's name (#1341).
+
+Files pyarrow writes with GZIP, ZSTD and LZ4_RAW, plus an uncompressed control, are
+read back through `read_parquet` and the FDW, and a gzip page whose header declares an
+uncompressed size of -1 must be refused with the reader's own decode error. Asserted:
+each file carries the codec it claims (read from its own metadata, so no arm passes on
+a file pyarrow left uncompressed); every file decodes to the generator's rows, built in
+SQL from the same generator the crafter wrote them from -- not to the uncompressed file
+read back through the reader under test, which a defect after decompression corrupts
+alike; each FDW count and a sum read every value; and the crafted header is a clean
+refusal that kills no backend.
+
+The files come from `test/craft_parquet_codecs.py`, the shell's own crafter, run as a
+subprocess so the fixture never imports pyarrow: each test declines under the shell's
+skip name first, and only then asks for the fixture.
+
+ON A PRIVATE DATABASE; each test asserts one reading (#1339). A failed reading is a
+query_error(), never a value: two failed reads never agree.
+"""
+import pathlib
+import subprocess
+import sys
+
+import psycopg
+import pytest
+
+from pgc_vacuity import QUERY_ERROR, query_error
+
+DB = "pgc_native_parquet_codecs"
+CRAFTER = pathlib.Path(__file__).resolve().parents[1] / "craft_parquet_codecs.py"
+COLS = "id int, grp int, val float8, s text"
+GEN = ("SELECT g::int, (g % 50)::int, (g * 1.5)::float8, 'row-' || (g % 100) "
+ "FROM generate_series(0, 19999) g")
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _value(dsn, sql):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None or row[0] is None:
+ return query_error(f"no value from {sql}")
+ return str(row[0])
+
+
+def _num(dsn, sql):
+ v = _value(dsn, sql)
+ try:
+ return int(v)
+ except ValueError:
+ return v if v.startswith(QUERY_ERROR) else query_error(f"not a number: {v}")
+
+
+def _set_hash(dsn, query):
+ """-> the shell's pgc_set_hash: md5 of the sorted row texts, EMPTY for no rows."""
+ return _value(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ( {query} ) _row) _s")
+
+
+def _crashes(cluster):
+ try:
+ text = (cluster.datadir / "server.log").read_text(errors="replace")
+ except OSError as exc:
+ return query_error(f"server log unreadable: {exc}")
+ return sum(1 for line in text.splitlines() if "terminated by signal" in line)
+
+
+def _survived(cluster, dsn, before):
+ """-> "1" when the server answers and no backend died since `before`, as the shell's."""
+ up = _value(dsn, "SELECT 1")
+ now = _crashes(cluster)
+ if not (isinstance(before, int) and isinstance(now, int)):
+ return "no (crash count unreadable)"
+ if up == "1" and now == before:
+ return "1"
+ return f"no ([{up}], {now - before} backend crashes)"
+
+
+def _verdict(dsn, sql):
+ """-> CLEAN DECODE ERROR when `sql` is refused with the reader's own decode error,
+ "no error" when it runs, or the error it raised instead."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return "CLEAN DECODE ERROR" if "could not decode" in str(exc) else query_error(_message(exc))
+ return "no error"
+
+
+@pytest.fixture(scope="module")
+def npc(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ w = server_dir / "npc"
+ w.mkdir(exist_ok=True)
+ w.chmod(0o777)
+ crafted = subprocess.run([sys.executable, str(CRAFTER), str(w)], capture_output=True, text=True)
+ for f in w.iterdir():
+ f.chmod(0o644)
+ codecs = {}
+ try:
+ for line in (w / "codecs.txt").read_text().splitlines():
+ k, _, v = line.partition(" ")
+ codecs[k] = v
+ except OSError as exc:
+ codecs = {}
+ crafted_err = f"codecs.txt unreadable: {exc}"
+ else:
+ crafted_err = ""
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ conn.execute("CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ for c in ("gzip", "zstd", "lz4"):
+ conn.execute(f"CREATE FOREIGN TABLE fc_{c} ({COLS}) SERVER pq "
+ f"OPTIONS (path '{w}/c_{c}.parquet')")
+
+ def rp(c):
+ return _set_hash(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/c_{c}.parquet') AS t({COLS})")
+
+ def codec(c):
+ if c in codecs:
+ return codecs[c]
+ why = crafted_err or (crafted.stderr.strip().splitlines() or ["no codec recorded"])[-1]
+ return f"unread ({why})"
+
+ r = {f"codec {c}": codec(c) for c in ("none", "gzip", "zstd", "lz4")}
+ r["gen"] = _set_hash(dsn, GEN)
+ for c in ("none", "gzip", "zstd", "lz4"):
+ r[f"rows {c}"] = rp(c)
+ for c in ("gzip", "zstd", "lz4"):
+ r[f"fdw {c}"] = _num(dsn, f"SELECT count(*) FROM fc_{c}")
+ r["gzip sum"] = _num(dsn, f"SELECT sum(id) FROM pgcolumnar.read_parquet('{w}/c_gzip.parquet') AS t({COLS})")
+ before = _crashes(pgc_cluster)
+ r["hdr"] = _verdict(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/badhdr.parquet') AS t(a int)")
+ r["hdr survived"] = _survived(pgc_cluster, dsn, before)
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_the_uncompressed_file_is_uncompressed(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["codec none"], "UNCOMPRESSED", "premise: the uncompressed file is UNCOMPRESSED")
+
+
+def test_the_gzip_file_is_gzip_compressed(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["codec gzip"], "GZIP", "premise: the gzip file is GZIP-compressed")
+
+
+def test_the_zstd_file_is_zstd_compressed(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["codec zstd"], "ZSTD", "premise: the zstd file is ZSTD-compressed")
+
+
+def test_the_lz4_file_is_lz4_compressed(request, expect):
+ # pyarrow names the LZ4_RAW codec "LZ4" in its metadata; the reader decodes only LZ4_RAW.
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["codec lz4"], "LZ4", "premise: the lz4 file is LZ4-compressed")
+
+
+def test_uncompressed_decodes_to_the_generators_rows(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["rows none"], r["gen"], "uncompressed decodes to the generator's rows")
+
+
+def test_gzip_decodes_to_the_generators_rows(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["rows gzip"], r["gen"], "gzip decodes to the generator's rows")
+
+
+def test_zstd_decodes_to_the_generators_rows(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["rows zstd"], r["gen"], "zstd decodes to the generator's rows")
+
+
+def test_lz4_decodes_to_the_generators_rows(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["rows lz4"], r["gen"], "lz4 decodes to the generator's rows")
+
+
+def test_the_gzip_fdw_counts_every_row(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.num(r["fdw gzip"], 20000, "gzip FDW count")
+
+
+def test_the_zstd_fdw_counts_every_row(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.num(r["fdw zstd"], 20000, "zstd FDW count")
+
+
+def test_the_lz4_fdw_counts_every_row(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.num(r["fdw lz4"], 20000, "lz4 FDW count")
+
+
+def test_the_gzip_sum_matches_the_generator(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.num(r["gzip sum"], 199990000, "gzip sum matches the generator")
+
+
+def test_a_crafted_uncompressed_size_is_a_clean_decode_error(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["hdr"], "CLEAN DECODE ERROR", "crafted uncompressed size gives a clean decode error")
+
+
+def test_the_backend_survives_the_crafted_header(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; codec suite needs it to write compressed files") is None:
+ return
+ r = request.getfixturevalue("npc") # after the decline: the crafter needs pyarrow
+ expect.text(r["hdr survived"], "1", "backend survived the crafted header")
diff --git a/test/pytest/test_native_parquet_fdw.py b/test/pytest/test_native_parquet_fdw.py
new file mode 100644
index 000000000..4bdd88768
--- /dev/null
+++ b/test/pytest/test_native_parquet_fdw.py
@@ -0,0 +1,290 @@
+"""The pgcolumnar_parquet foreign-data wrapper reads an exported Parquet file as a
+foreign table (Phase G). The pytest twin of `test/native_parquet_fdw.sh`, every check
+by the shell's name.
+
+The oracle is a heap table written from the generator, not the columnar table the
+file was exported from. Projection and row-group skipping are read from the scan's
+own EXPLAIN ANALYZE counters: every arm used to compare answers only, which the
+executor's recheck and a discarding projection keep right, so a scan that decoded
+every column or skipped no row group passed. Each refusal wants its own guard's
+SQLSTATE and message, not any error: with the scan's missing-path guard removed the
+backend crashed, and with the short-file guard removed a later guard refused, and
+both passed. Every reading waits for the server first, and survival reads the server
+log's count of killed backends, since SELECT 1 answers again after crash recovery.
+
+ON A PRIVATE DATABASE, with its own server; the files live in `server_dir`.
+"""
+import re
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_parquet_fdw"
+
+SCALAR_COLS = ("c_i2 int2, c_i4 int4, c_i8 int8, c_f4 float4, c_f8 float8, c_bool bool, "
+ "c_text text, c_bytea bytea, c_date date, c_time time, c_ts timestamp")
+GEN = ("SELECT (g%100)::int2, g, g::int8*1000, (g/2.0)::float4, (g/4.0)::float8, (g%2=0), "
+ "'row'||g, decode(lpad(to_hex(g),8,'0'),'hex'), date '2020-01-01' + g, "
+ "time '00:00:00' + (g||' seconds')::interval, "
+ "timestamp '2020-01-01' + (g||' minutes')::interval "
+ "FROM generate_series(1, 5000) g")
+NULLROW = "VALUES (NULL::int2,NULL::int4,NULL::int8,NULL::float4,NULL::float8,NULL::bool,NULL::text,NULL::bytea,NULL::date,NULL::time,NULL::timestamp)"
+EXPLAIN = "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) "
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _wait_up(dsn):
+ for _ in range(60):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _run(dsn, sql):
+ """Run a setup statement after waiting for the server; a failure is left to the
+ readings that depend on it, which then fail by name."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error:
+ pass
+
+
+def _rows(dsn, sql, settings=()):
+ """-> the rows joined as the shell's psql -qtA prints them, or the failure as a
+ query_error(). `settings` run first on the same connection."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ for st in settings:
+ conn.execute(st)
+ rows = conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "\n".join("|".join("" if v is None else str(v) for v in row) for row in rows)
+
+
+def _set_hash(dsn, query):
+ """The port of pgc_set_hash: a failed query is a query_error(), unique per call."""
+ return _rows(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ({query}) _row) _s")
+
+
+def _field(plan, name):
+ """-> the integer after ": " in an EXPLAIN text, or "no reading"."""
+ if not isinstance(plan, str) or plan.startswith("QUERY_ERROR"):
+ return "no reading"
+ m = re.search(rf"{re.escape(name)}: (\d+)", plan)
+ return m.group(1) if m else "no reading"
+
+
+def _refusal(dsn, sql, regex, label):
+ """-> " ()" when the statement raised the wanted refusal,
+ ": " for any other error, or what happened instead."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return "no refusal: the connection was lost"
+ msg = exc.diag.message_primary or ""
+ return f"{exc.sqlstate} ({label})" if re.search(regex, msg) else f"{exc.sqlstate}: {msg}"
+ return "no error"
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ """-> "1" when the server answers and no backend died since `before`."""
+ _wait_up(dsn)
+ up, now = _rows(dsn, "SELECT 1"), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ if up == "1" and now == before:
+ return "1"
+ return f"no ([{up if up == '1' else ''}], {now - before} backend crashes)"
+
+
+def _int(v):
+ return int(v) if isinstance(v, str) and v.isdigit() else v
+
+
+@pytest.fixture(scope="module")
+def pfdw(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ scalar = server_dir / "fdw_scalar.parquet"
+ nested = server_dir / "fdw_nested.parquet"
+ skf = server_dir / "fdw_skip.parquet"
+ bad = server_dir / "fdw_bad.dat"
+ r = {}
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ _run(dsn, "CREATE SERVER pqsrv FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ _run(dsn, f"CREATE TABLE s ({SCALAR_COLS}) USING pgcolumnar")
+ _run(dsn, f"INSERT INTO s {GEN}")
+ _run(dsn, f"INSERT INTO s {NULLROW}")
+ _run(dsn, f"CREATE TABLE h ({SCALAR_COLS}) USING heap")
+ _run(dsn, f"INSERT INTO h {GEN}")
+ _run(dsn, f"INSERT INTO h {NULLROW}")
+ r["scalar export"] = _rows(dsn, f"SELECT pgcolumnar.export_parquet('s', '{scalar}')")
+ _run(dsn, f"CREATE FOREIGN TABLE fs ({SCALAR_COLS}) SERVER pqsrv OPTIONS (path '{scalar}')")
+ r["full fdw"] = _set_hash(dsn, "SELECT * FROM fs")
+ r["full src"] = _set_hash(dsn, "SELECT * FROM h")
+ r["count"] = _rows(dsn, "SELECT count(*) FROM fs")
+ r["proj fdw"] = _set_hash(dsn, "SELECT c_text FROM fs")
+ r["proj src"] = _set_hash(dsn, "SELECT c_text FROM h")
+ r["pred fdw"] = _set_hash(dsn, "SELECT c_i4, c_text FROM fs WHERE c_i4 > 2500 AND c_bool")
+ r["pred src"] = _set_hash(dsn, "SELECT c_i4, c_text FROM h WHERE c_i4 > 2500 AND c_bool")
+ nl = ("SET enable_hashjoin=off", "SET enable_mergejoin=off")
+ r["join fdw"] = _rows(dsn, "SELECT count(*) FROM fs a JOIN fs b ON a.c_i4 = b.c_i4 WHERE a.c_i4 < 200", nl)
+ r["join src"] = _rows(dsn, "SELECT count(*) FROM h a JOIN h b ON a.c_i4 = b.c_i4 WHERE a.c_i4 < 200", nl)
+ plan = _rows(dsn, "EXPLAIN (COSTS OFF) SELECT * FROM fs")
+ r["foreign scan"] = (str(sum(1 for line in plan.splitlines() if "Foreign Scan" in line))
+ if not plan.startswith("QUERY_ERROR") else plan)
+ r["proj plan"] = _rows(dsn, EXPLAIN + "SELECT c_text FROM fs")
+ _run(dsn, "CREATE TABLE sk (id int8) USING pgcolumnar")
+ _run(dsn, "INSERT INTO sk SELECT g FROM generate_series(1, 200000) g")
+ r["skip export"] = _rows(dsn, f"SELECT pgcolumnar.export_parquet('sk', '{skf}')")
+ _run(dsn, f"CREATE FOREIGN TABLE fsk (id int8) SERVER pqsrv OPTIONS (path '{skf}')")
+ r["skip plan"] = _rows(dsn, EXPLAIN + "SELECT count(*) FROM fsk WHERE id > 196608::int8")
+ # the plan's own query: a plain 196608 is cross-type and skips nothing (#1711 review)
+ r["skip count"] = _rows(dsn, "SELECT count(*) FROM fsk WHERE id > 196608::int8")
+ _run(dsn, "CREATE TYPE fdw_npc AS (x int, y text)")
+ _run(dsn, "CREATE TABLE np (id int, ia int[], ta text[], c fdw_npc) USING pgcolumnar")
+ _run(dsn, "INSERT INTO np SELECT g, "
+ "CASE WHEN g%11=0 THEN NULL WHEN g%7=0 THEN '{}'::int[] ELSE ARRAY[g,g+1] END, "
+ "CASE WHEN g%13=0 THEN NULL ELSE ARRAY['a'||g,'b'||g] END, "
+ "CASE WHEN g%5=0 THEN NULL ELSE ROW(g,'c'||g)::fdw_npc END "
+ "FROM generate_series(1, 3000) g")
+ r["nested export"] = _rows(dsn, f"SELECT pgcolumnar.export_parquet('np', '{nested}')")
+ _run(dsn, f"CREATE FOREIGN TABLE fnp (id int, ia int[], ta text[], c fdw_npc) SERVER pqsrv OPTIONS (path '{nested}')")
+ r["nested fdw"] = _set_hash(dsn, "SELECT * FROM fnp")
+ r["nested src"] = _set_hash(dsn, "SELECT * FROM np")
+
+ before = _crashes(pgc_cluster)
+ r["bad option"] = _refusal(dsn, f"CREATE FOREIGN TABLE fbad ({SCALAR_COLS}) SERVER pqsrv "
+ f"OPTIONS (path '{scalar}', bogus 'x')",
+ r'invalid option "bogus"', "invalid option")
+ r["empty path"] = _refusal(dsn, f"CREATE FOREIGN TABLE fempty ({SCALAR_COLS}) SERVER pqsrv OPTIONS (path '')",
+ r'"path" option cannot be empty', "empty path")
+ _run(dsn, f"CREATE FOREIGN TABLE fnopath ({SCALAR_COLS}) SERVER pqsrv")
+ r["no path"] = _refusal(dsn, "SELECT * FROM fnopath",
+ r'foreign table "fnopath" has no "path" option', "no path")
+ bad.write_text("not parquet")
+ bad.chmod(0o644)
+ _run(dsn, f"CREATE FOREIGN TABLE fbadfile ({SCALAR_COLS}) SERVER pqsrv OPTIONS (path '{bad}')")
+ r["not parquet"] = _refusal(dsn, "SELECT * FROM fbadfile",
+ r'bad\.dat" is not a Parquet file$', "too short")
+ _run(dsn, "CREATE FOREIGN TABLE fwrongtype (c_i2 int2, c_i4 text, c_i8 int8, c_f4 float4, "
+ "c_f8 float8, c_bool bool, c_text text, c_bytea bytea, c_date date, c_time time, "
+ f"c_ts timestamp) SERVER pqsrv OPTIONS (path '{scalar}')")
+ r["wrong type"] = _refusal(dsn, "SELECT * FROM fwrongtype",
+ r'physical type is not compatible with target column "c_i4"', "type mismatch")
+ r["survived"] = _survived(dsn, pgc_cluster, before)
+ yield r
+ finally:
+ _wait_up(pgc_cluster.dsn())
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_the_scalar_table_exports_every_row(pfdw, expect):
+ expect.text(pfdw["scalar export"], "5001", "scalar export rows")
+
+
+def test_a_full_foreign_scan_equals_the_source(pfdw, expect):
+ expect.text(pfdw["full fdw"], pfdw["full src"], "full scan: fdw == source")
+
+
+def test_the_foreign_table_counts_every_row(pfdw, expect):
+ expect.num(_int(pfdw["count"]), 5001, "fdw row count")
+
+
+def test_a_projection_equals_the_source(pfdw, expect):
+ expect.text(pfdw["proj fdw"], pfdw["proj src"], "projection: fdw == source")
+
+
+def test_a_predicate_equals_the_source(pfdw, expect):
+ expect.text(pfdw["pred fdw"], pfdw["pred src"], "predicate: fdw == source")
+
+
+def test_a_nested_loop_self_join_rescans_correctly(pfdw, expect):
+ expect.num(_int(pfdw["join fdw"]), _int(pfdw["join src"]), "nested-loop self-join (rescan): fdw == source")
+
+
+def test_explain_shows_a_foreign_scan(pfdw, expect):
+ expect.text(pfdw["foreign scan"], "1", "EXPLAIN shows Foreign Scan")
+
+
+def test_a_one_column_query_reads_one_column(pfdw, expect):
+ p = pfdw["proj plan"]
+ expect.text(f"{_field(p, 'Columns Read')}/{_field(p, 'Columns Total')}", "1/11",
+ "a one-column query reads one of the eleven columns")
+
+
+def test_the_skip_table_exports_every_row(pfdw, expect):
+ expect.num(_int(pfdw["skip export"]), 200000, "skip export rows")
+
+
+def test_the_skip_file_has_four_row_groups(pfdw, expect):
+ expect.text(_field(pfdw["skip plan"], "Row Groups"), "4", "the skip file has four row groups")
+
+
+def test_a_selective_predicate_skips_three_row_groups(pfdw, expect):
+ expect.text(_field(pfdw["skip plan"], "Row Groups Skipped"), "3",
+ "a selective predicate skips the three row groups it cannot match")
+
+
+def test_the_skipped_scan_returns_every_matching_row(pfdw, expect):
+ expect.num(_int(pfdw["skip count"]), 3392, "and the skipped scan still returns every matching row")
+
+
+def test_the_fdw_nested_table_exports_every_row(pfdw, expect):
+ expect.text(pfdw["nested export"], "3000", "nested export rows")
+
+
+def test_a_nested_full_scan_equals_the_source(pfdw, expect):
+ expect.text(pfdw["nested fdw"], pfdw["nested src"], "nested full scan: fdw == source")
+
+
+def test_an_invalid_table_option_is_rejected(pfdw, expect):
+ expect.text(pfdw["bad option"], "HV00D (invalid option)", "reject invalid table option (HV00D)")
+
+
+def test_an_empty_path_option_is_rejected(pfdw, expect):
+ expect.text(pfdw["empty path"], "HV00D (empty path)", "reject empty path option (HV00D)")
+
+
+def test_a_scan_without_a_path_option_errors(pfdw, expect):
+ expect.text(pfdw["no path"], "HV00J (no path)", "scan without path option errors (HV00J)")
+
+
+def test_a_scan_of_a_non_parquet_file_errors(pfdw, expect):
+ expect.text(pfdw["not parquet"], "XX001 (too short)", "scan of non-parquet file errors (XX001)")
+
+
+def test_a_scan_with_an_incompatible_declared_type_errors(pfdw, expect):
+ expect.text(pfdw["wrong type"], "42804 (type mismatch)", "scan with incompatible declared type errors (42804)")
+
+
+def test_the_backend_is_still_up_after_the_fdw_refusals(pfdw, expect):
+ expect.text(pfdw["survived"], "1", "backend still up after the refusals")
diff --git a/test/pytest/test_native_parquet_fieldid.py b/test/pytest/test_native_parquet_fieldid.py
new file mode 100644
index 000000000..2f8bf9d60
--- /dev/null
+++ b/test/pytest/test_native_parquet_fieldid.py
@@ -0,0 +1,249 @@
+"""read_parquet(path, field_ids) binds each output column to the file column carrying
+the requested field id (#388 phase 3c). The pytest twin of
+`test/native_parquet_fieldid.sh`, every check by the shell's name.
+
+pyarrow, an independent writer, writes a file whose field ids are out of order and
+disjoint from position (alpha=7, beta=3, gamma=12), so a reader that bound by position
+or by name could not pass the projection arms. The refusal arms each want their own
+guard's message, not only the SQLSTATE: four of them share 22023, and with the "no
+field ids" guard removed an id-less file fell through to the absent-id refusal and
+still passed. Every reading waits for the server first, and survival also reads the
+server log's count of killed backends, since SELECT 1 answers again after crash
+recovery.
+
+ON A PRIVATE DATABASE. The shell skips whole without pyarrow, so every test declines
+under the shell's skip name before it asks for the fixture: the writer needs pyarrow,
+and it runs as a subprocess so this module imports none. The files live in
+`server_dir`.
+"""
+import re
+import subprocess
+import sys
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_parquet_fieldid"
+
+# the same writer the shell runs
+WRITER = r'''
+import sys, pyarrow as pa, pyarrow.parquet as pq
+fid, noid, dup = sys.argv[1], sys.argv[2], sys.argv[3]
+schema = pa.schema([
+ pa.field("alpha", pa.int32(), metadata={b"PARQUET:field_id": b"7"}),
+ pa.field("beta", pa.string(), metadata={b"PARQUET:field_id": b"3"}),
+ pa.field("gamma", pa.int32(), metadata={b"PARQUET:field_id": b"12"}),
+])
+pq.write_table(pa.table({
+ "alpha": pa.array([10, 20, 30], pa.int32()),
+ "beta": pa.array(["x", "y", "z"], pa.string()),
+ "gamma": pa.array([100, 200, 300], pa.int32()),
+}, schema=schema), fid)
+pq.write_table(pa.table({"n": pa.array([1, 2], pa.int32())}), noid)
+dschema = pa.schema([
+ pa.field("p", pa.int32(), metadata={b"PARQUET:field_id": b"5"}),
+ pa.field("q", pa.int32(), metadata={b"PARQUET:field_id": b"5"}),
+])
+pq.write_table(pa.table({"p": pa.array([1], pa.int32()),
+ "q": pa.array([2], pa.int32())}, schema=dschema), dup)
+'''
+
+# the refusal each arm wants, matched against the primary message as the shell does
+ABSENT = (r"no Parquet column has field id 999", "absent id")
+DUPFILE = (r"field id 5 is not unique among", "duplicate in the file")
+SCALAR = (r"supports only scalar columns", "not scalar")
+COUNT = (r"field_ids has 2 element\(s\) but the column definition list has 1 column", "count mismatch")
+NOIDS = (r"carries no field ids", "no field ids")
+DUPREQ = (r"field id 7 is requested more than once", "requested twice")
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _wait_up(dsn):
+ for _ in range(60):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _rows(dsn, sql):
+ """-> the rows joined as the shell's psql -qtA prints them, or the failure as a
+ query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ rows = conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "\n".join("|".join("" if v is None else str(v) for v in row) for row in rows)
+
+
+def _refusal(dsn, sql, want):
+ """-> " ()" when the statement raised the wanted refusal,
+ ": " for any other error, or what happened instead."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return "no refusal: the connection was lost"
+ msg = exc.diag.message_primary or ""
+ regex, label = want
+ return f"{exc.sqlstate} ({label})" if re.search(regex, msg) else f"{exc.sqlstate}: {msg}"
+ return "no error"
+
+
+def _names_the_id(dsn, sql):
+ """-> how many lines of the error text say the id was requested more than once,
+ as the shell's grep -c does over psql's output."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return str(sum(1 for line in str(exc).splitlines() if "requested more than once" in line))
+ return "0"
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ """-> "1" when the server answers and no backend died since `before`."""
+ _wait_up(dsn)
+ up, now = _rows(dsn, "SELECT 1"), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ if up == "1" and now == before:
+ return "1"
+ return f"no ([{up if up == '1' else ''}], {now - before} backend crashes)"
+
+
+@pytest.fixture(scope="module")
+def pfid(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ w = server_dir / "pfid"
+ w.mkdir(exist_ok=True)
+ w.chmod(0o777)
+ fid, noid, dup = w / "fieldid.parquet", w / "noid.parquet", w / "dup.parquet"
+ subprocess.run([sys.executable, "-c", WRITER, str(fid), str(noid), str(dup)],
+ capture_output=True, text=True)
+ for f in (fid, noid, dup):
+ if f.exists():
+ f.chmod(0o644)
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ r = {}
+ r["reorder"] = _rows(dsn, f"SELECT g || '|' || a FROM pgcolumnar.read_parquet('{fid}', ARRAY[12,7]) "
+ f"AS t(g int, a int) ORDER BY a")
+ r["subset"] = _rows(dsn, f"SELECT b FROM pgcolumnar.read_parquet('{fid}', ARRAY[3]) AS t(b text) ORDER BY b")
+ r["positional"] = _rows(dsn, f"SELECT alpha || '|' || beta || '|' || gamma FROM pgcolumnar.read_parquet('{fid}') "
+ f"AS t(alpha int, beta text, gamma int) ORDER BY alpha")
+ before = _crashes(pgc_cluster)
+ r["absent"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{fid}', ARRAY[999]) AS t(x int)", ABSENT)
+ r["dup file"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{dup}', ARRAY[5]) AS t(x int)", DUPFILE)
+ r["nested"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{fid}', ARRAY[7]) AS t(a int[])", SCALAR)
+ r["count"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{fid}', ARRAY[7,3]) AS t(a int)", COUNT)
+ r["no ids"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{noid}', ARRAY[1]) AS t(x int)", NOIDS)
+ dupreq = f"SELECT * FROM pgcolumnar.read_parquet('{fid}', ARRAY[7,7]) AS t(a1 int, a2 int)"
+ r["dup req"] = _refusal(dsn, dupreq, DUPREQ)
+ r["names id"] = _names_the_id(dsn, dupreq)
+ r["survived"] = _survived(dsn, pgc_cluster, before)
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_fieldid_projection_binds_and_reorders_by_id(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["reorder"], "100|10\n200|20\n300|30", "field-id projection binds and reorders by id (g=gamma, a=alpha)")
+
+
+def test_fieldid_projection_reads_a_subset(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["subset"], "x\ny\nz", "field-id projection reads a subset (only id 3 = beta)")
+
+
+def test_positional_read_parquet_is_unaffected_by_fieldid_binding(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["positional"], "10|x|100\n20|y|200\n30|z|300", "positional read_parquet is unaffected")
+
+
+def test_an_absent_field_id_is_refused(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["absent"], "22023 (absent id)", "an absent field id is refused (22023)")
+
+
+def test_a_duplicate_field_id_in_the_parquet_file_is_refused(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["dup file"], "42702 (duplicate in the file)", "a duplicate field id in the file is refused (42702)")
+
+
+def test_a_nested_fieldid_output_column_is_refused(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["nested"], "0A000 (not scalar)", "a nested/array output column is refused (0A000)")
+
+
+def test_a_field_ids_column_count_mismatch_is_refused(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["count"], "22023 (count mismatch)", "a field_ids/column-count mismatch is refused (22023)")
+
+
+def test_a_parquet_file_with_no_field_ids_is_refused(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["no ids"], "22023 (no field ids)", "a file with no field ids is refused (22023)")
+
+
+def test_a_duplicate_requested_field_id_is_refused(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["dup req"], "22023 (requested twice)", "a duplicate requested field id is refused (22023)")
+
+
+def test_the_duplicate_requested_id_error_names_the_id(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["names id"], "1", "the duplicate-requested-id error names the id, not the file")
+
+
+def test_the_backend_survives_the_fieldid_refusals(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow is needed to write the field-id fixture") is None:
+ return
+ r = request.getfixturevalue("pfid")
+ expect.text(r["survived"], "1", "backend still up after the refusals")
diff --git a/test/pytest/test_native_parquet_hardening.py b/test/pytest/test_native_parquet_hardening.py
new file mode 100644
index 000000000..e6357cdc5
--- /dev/null
+++ b/test/pytest/test_native_parquet_hardening.py
@@ -0,0 +1,310 @@
+"""The Parquet reader refuses values that do not fit the column's type, and a row group
+whose column-chunk list is shorter than the schema. The pytest twin of
+`test/native_parquet_hardening.sh`, every check by the shell's name.
+
+int2 and date ride on INT32, time and timestamp on INT64, because Parquet has no
+narrower physical type. An out-of-range value must raise, not wrap. Four of the five
+range refusals share 22008, so each check wants its guard's own message ("value out of
+range for type ") as well as the SQLSTATE. Each "still reads" check compares the
+decoded values with the ones the writer stored, not a row count, and the no-wrap check
+reads the out-of-range file itself, which must refuse. Every reading waits for the
+server first, and survival also reads the server log's count of killed backends, since
+SELECT 1 answers again after crash recovery.
+
+ON A PRIVATE DATABASE. The shell skips whole without pyarrow, so every test declines
+under the shell's skip name before it asks for the fixture. The files are written by
+`test/craft_parquet_hardening.py`, the shell's own crafter, run as a subprocess so this
+module imports no pyarrow, into `server_dir`.
+"""
+import pathlib
+import subprocess
+import sys
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_parquet_hardening"
+CRAFTER = pathlib.Path(__file__).resolve().parent.parent / "craft_parquet_hardening.py"
+SKIP = "pyarrow not available; Parquet hardening suite needs it"
+
+TABLES = [
+ ("f_i2_bad", "int2", "i2_bad"), ("f_i2_ok", "int2", "i2_ok"),
+ ("f_time_bad", "time", "time_bad"), ("f_time_ok", "time", "time_ok"),
+ ("f_date_bad", "date", "date_bad"), ("f_date_ovf", "date", "date_ovf"),
+ ("f_date_ok", "date", "date_ok"),
+ ("f_ts_bad", "timestamp", "ts_bad"), ("f_ts_ok", "timestamp", "ts_ok"),
+]
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _wait_up(dsn):
+ for _ in range(60):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _value(dsn, sql):
+ """-> the first column of the first row as text, as the shell's q prints it ("" for
+ no row or NULL), or the failure as a query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "" if row is None or row[0] is None else str(row[0])
+
+
+def _exec(dsn, sql):
+ """Run one setup statement; -> None, or the failure as a query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return None
+
+
+def _sqlstate(dsn, sql):
+ """-> pgc_try's reading of SQL: "SQLSTATE: message", or NO ERROR."""
+ return _value(dsn, f"SELECT pgc_try($q${sql}$q$)")
+
+
+def _short_verdict(dsn, sql):
+ """-> CLEAN REJECT when SQL raised the chunk-count guard's refusal, NO ERROR when it
+ raised nothing, or any other failure as a query_error() that names it."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return query_error("no refusal: the connection was lost")
+ msg = exc.diag.message_primary or ""
+ detail = exc.diag.message_detail or ""
+ # five guards share the message; only this guard's detail is the chunk-count one
+ if ("has a malformed row group" in msg
+ and "carries 1 column chunks, but the schema has 2 leaf columns" in detail):
+ return "CLEAN REJECT"
+ return query_error(f"{exc.sqlstate}: {msg}")
+ return "NO ERROR"
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ """-> "1" when the server answers and no backend died since `before`."""
+ _wait_up(dsn)
+ up, now = _value(dsn, "SELECT 1"), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ if up == "1" and now == before:
+ return "1"
+ return f"no ([{up if up == '1' else ''}], {now - before} backend crashes)"
+
+
+def _int(v):
+ """-> v as an int when it is one, else v unchanged, so num() names a failed reading."""
+ return int(v) if isinstance(v, str) and v.lstrip("-").isdigit() else v
+
+
+@pytest.fixture(scope="module")
+def pqh(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ w = server_dir / "pqh"
+ w.mkdir(exist_ok=True)
+ w.chmod(0o777)
+ subprocess.run([sys.executable, str(CRAFTER), str(w)], capture_output=True, text=True)
+ for f in w.glob("*.parquet"):
+ f.chmod(0o644)
+ r = {}
+ before = _crashes(pgc_cluster)
+ setup = ["CREATE EXTENSION IF NOT EXISTS pgcolumnar",
+ "CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet"]
+ setup += [f"CREATE FOREIGN TABLE {t} (c {typ}) SERVER pq OPTIONS (path '{w}/{f}.parquet')"
+ for t, typ, f in TABLES]
+ setup += [
+ "CREATE FUNCTION pgc_try(q text) RETURNS text LANGUAGE plpgsql AS $$ "
+ "BEGIN EXECUTE q; RETURN 'NO ERROR'; "
+ "EXCEPTION WHEN OTHERS THEN RETURN SQLSTATE || ': ' || SQLERRM; END $$",
+ "CREATE FUNCTION pgc_value(q text) RETURNS text LANGUAGE plpgsql AS $$ "
+ "DECLARE r text; BEGIN EXECUTE q INTO r; RETURN coalesce(r, ''); "
+ "EXCEPTION WHEN OTHERS THEN RETURN SQLSTATE; END $$",
+ "CREATE TABLE imp_bad (c int2)",
+ f"CREATE FOREIGN TABLE f_short (a int4, b int4) SERVER pq OPTIONS (path '{w}/short_chunks.parquet')",
+ ]
+ for stmt in setup:
+ # a failed setup step surfaces in the readings that depend on it, by name
+ _exec(dsn, stmt)
+
+ r["i2 bad"] = _sqlstate(dsn, "SELECT * FROM f_i2_bad")
+ r["time bad"] = _sqlstate(dsn, "SELECT * FROM f_time_bad")
+ r["date bad"] = _sqlstate(dsn, "SELECT * FROM f_date_bad")
+ r["date ovf"] = _sqlstate(dsn, "SELECT * FROM f_date_ovf")
+ r["ts bad"] = _sqlstate(dsn, "SELECT * FROM f_ts_bad")
+ r["i2 ok"] = _value(dsn, "SELECT string_agg(c::text, ' ' ORDER BY c) FROM f_i2_ok")
+ r["time ok"] = _value(dsn, "SELECT string_agg(c::text, ' ' ORDER BY c) FROM f_time_ok")
+ r["date ok"] = _value(dsn, "SELECT string_agg(c::text, ' ' ORDER BY c) FROM f_date_ok")
+ r["ts ok"] = _value(dsn, "SELECT string_agg(c::text, ',' ORDER BY c) FROM f_ts_ok")
+ r["no wrap"] = _value(dsn, "SELECT pgc_value($q$SELECT count(*) FROM f_i2_bad WHERE c = (-25536)::int2$q$)")
+ r["rp bad"] = _sqlstate(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/i2_bad.parquet') AS t(c int2)")
+ r["rp ok"] = _value(dsn, f"SELECT string_agg(c::text, ' ' ORDER BY c) FROM "
+ f"pgcolumnar.read_parquet('{w}/i2_ok.parquet') AS t(c int2)")
+ r["imp bad"] = _sqlstate(dsn, f"SELECT pgcolumnar.import_parquet('imp_bad'::regclass, '{w}/i2_bad.parquet')")
+ r["imp rows"] = _value(dsn, "SELECT count(*) FROM imp_bad")
+ r["short"] = _short_verdict(dsn, "SELECT * FROM f_short")
+ r["survived"] = _survived(dsn, pgc_cluster, before)
+ r["schema"] = _value(dsn, f"SELECT count(*) FROM pgcolumnar.parquet_schema('{w}/short_chunks.parquet')")
+ r["two"] = _value(dsn, f"SELECT string_agg(a || '|' || b, ' ' ORDER BY a) FROM "
+ f"pgcolumnar.read_parquet('{w}/two.parquet') AS t(a int4, b int4)")
+ yield r
+ finally:
+ _wait_up(pgc_cluster.dsn())
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_parquet_hardening_int2_out_of_range_raises_22003(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["i2 bad"], "22003: value out of range for type smallint", "int2 out of range -> 22003")
+
+
+def test_parquet_hardening_time_out_of_day_raises_22008(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["time bad"], "22008: value out of range for type time", "time out of day -> 22008")
+
+
+def test_parquet_hardening_invalid_date_raises_22008(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["date bad"], "22008: value out of range for type date", "date invalid -> 22008")
+
+
+def test_parquet_hardening_date_offset_overflow_raises_22008(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["date ovf"], "22008: value out of range for type date", "date offset overflow -> 22008")
+
+
+def test_parquet_hardening_timestamp_overflow_raises_22008(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["ts bad"], "22008: value out of range for type timestamp", "timestamp overflow -> 22008")
+
+
+def test_parquet_hardening_int2_in_range_still_reads(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["i2 ok"], "-30000 0 30000", "int2 in range still reads")
+
+
+def test_parquet_hardening_time_in_range_still_reads(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["time ok"], "00:00:00 01:00:00", "time in range still reads")
+
+
+def test_parquet_hardening_date_in_range_still_reads(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["date ok"], "1970-01-01 2022-01-08", "date in range still reads")
+
+
+def test_parquet_hardening_timestamp_in_range_still_reads(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["ts ok"], "1970-01-01 00:00:00,2023-11-14 22:13:20", "timestamp in range still reads")
+
+
+def test_parquet_hardening_no_wrapped_value_appears(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["no wrap"], "22003", "no wrapped -25536 appears")
+
+
+def test_parquet_hardening_read_parquet_also_raises_22003(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["rp bad"], "22003: value out of range for type smallint", "read_parquet also raises 22003")
+
+
+def test_parquet_hardening_read_parquet_in_range_still_reads(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["rp ok"], "-30000 0 30000", "read_parquet in range still reads")
+
+
+def test_parquet_hardening_import_parquet_also_raises_22003(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["imp bad"], "22003: value out of range for type smallint", "import_parquet also raises 22003")
+
+
+def test_parquet_hardening_import_parquet_left_no_wrapped_rows(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.num(_int(r["imp rows"]), 0, "import_parquet left no wrapped rows")
+
+
+def test_parquet_hardening_short_chunk_list_is_a_clean_reject(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["short"], "CLEAN REJECT", "short chunk list rejected with a specific error")
+
+
+def test_parquet_hardening_backend_survives_the_short_chunk_list(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["survived"], "1", "backend survived short chunk list")
+
+
+def test_parquet_hardening_parquet_schema_still_describes_the_bad_file(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["schema"], "2", "parquet_schema still describes a bad chunk list")
+
+
+def test_parquet_hardening_a_valid_two_column_file_still_reads(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet hardening suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqh")
+ expect.text(r["two"], "1|4 2|5 3|6", "valid two-column file still reads")
diff --git a/test/pytest/test_native_parquet_partition.py b/test/pytest/test_native_parquet_partition.py
new file mode 100644
index 000000000..797bb6ce5
--- /dev/null
+++ b/test/pytest/test_native_parquet_partition.py
@@ -0,0 +1,530 @@
+"""Hive-style partition pruning for the Parquet FDW (Phase G). The pytest twin of
+`test/native_parquet_partition.sh`, every check by the shell's name.
+
+A tree like events/dt=2026-01-02/region=eu/part.parquet carries column values in its
+directory names, declared through the partition_columns option. Asserted: the values
+materialize as typed columns; a predicate on a partition column drops whole files
+before they are opened (the EXPLAIN counters, never timing); partition and file
+predicates combine; a volatile clause never decides a whole file; a tree that does not
+match the declaration fails loudly; and Hive value encoding (percent-escapes, the null
+marker, a refused NUL or invalid byte) decodes as Hive and Spark write it.
+
+The pruning counters are read fail-closed: EXPLAIN prints "Files Pruned" only when it
+is above 0, so a missing line counts as 0 only when the plan's Files line shows the
+scan ran; a failed reading is "no reading", never 0. Each refusal wants its own
+guard's SQLSTATE and message, since any error used to pass: with the unknown-column
+guard removed a later guard's 42804 stood in, and with the explicit encoded-NUL guard
+removed the encoding check's 22021 did. Every reading waits for the server, and
+survival also reads the server log's count of killed backends.
+
+ON A PRIVATE DATABASE. The shell skips whole without pyarrow, so every test declines
+under the shell's skip name before it asks for the fixture: the writer needs pyarrow,
+and it runs as a subprocess so this module imports none. The trees live in
+`server_dir`.
+"""
+import re
+import shutil
+import subprocess
+import sys
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_parquet_partition"
+
+# the same writers the shell runs
+WRITER = r'''
+import os, sys
+import pyarrow as pa, pyarrow.parquet as pq
+ROOT, ENC = sys.argv[1], sys.argv[2]
+plan = [("2026-01-01", "eu", 0), ("2026-01-01", "us", 1000),
+ ("2026-01-02", "eu", 2000), ("2026-01-02", "us", 3000)]
+for dt, region, base in plan:
+ d = os.path.join(ROOT, f"dt={dt}", f"region={region}")
+ os.makedirs(d, exist_ok=True)
+ t = pa.table({"id": pa.array(range(base, base + 1000), pa.int32()),
+ "v": pa.array([f"r{i}" for i in range(base, base + 1000)])})
+ pq.write_table(t, os.path.join(d, "part.parquet"), compression="none",
+ row_group_size=250)
+plan = [("a%3Db", 0), ("__HIVE_DEFAULT_PARTITION__", 1000), ("plain", 2000)]
+for region, base in plan:
+ d = os.path.join(ENC, "region=" + region)
+ os.makedirs(d, exist_ok=True)
+ pq.write_table(pa.table({"id": pa.array(range(base, base + 100), pa.int32())}),
+ os.path.join(d, "part.parquet"), compression="none")
+'''
+
+# the refusal each arm wants, matched against the primary message as the shell does
+MISSING = (r'has no "region=" directory component', "its own message")
+UNKNOWN = (r'"nosuch" named in "partition_columns" does not exist', "its own message")
+EMPTY = (r'"partition_columns" option cannot be empty', "its own message")
+DUPCOL = (r'has no "v=" directory component', "its own message")
+NUL = (r"contains an encoded null byte", "its own message")
+BADBYTE = (r"invalid byte sequence for encoding", "its own message")
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _wait_up(dsn):
+ for _ in range(60):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _rows(dsn, sql, sep="|"):
+ """-> the rows joined as the shell's psql -qtA prints them, or the failure as a
+ query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ rows = conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "\n".join(sep.join("" if v is None else str(v) for v in row) for row in rows)
+
+
+def _explain(dsn, where, table="ev"):
+ return _rows(dsn, f"EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) "
+ f"SELECT count(*) FROM {table} WHERE {where}")
+
+
+def _counter(plan, name):
+ for line in plan.splitlines():
+ m = re.match(rf"\s*{re.escape(name)}:\s*(\d+)", line)
+ if m:
+ return m.group(1)
+ return None
+
+
+def _files_for(dsn, where, table="ev"):
+ plan = _explain(dsn, where, table)
+ n = _counter(plan, "Files")
+ return n if n is not None else "no reading (no Files line)"
+
+
+def _pruned_for(dsn, where, table="ev"):
+ """-> files dropped before opening; "Files Pruned" prints only when above 0, so a
+ missing line is 0 only when the Files line shows the scan ran."""
+ plan = _explain(dsn, where, table)
+ if _counter(plan, "Files") is None:
+ return "no reading (no Files line)"
+ n = _counter(plan, "Files Pruned")
+ return n if n is not None else "0"
+
+
+def _refusal(dsn, sql, want):
+ """-> " ()" when the statement raised the wanted refusal,
+ ": " for any other error, or what happened instead."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return "no refusal: the connection was lost"
+ msg = exc.diag.message_primary or ""
+ regex, label = want
+ return f"{exc.sqlstate} ({label})" if re.search(regex, msg) else f"{exc.sqlstate}: {msg}"
+ return "no error"
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ """-> "1" when the server answers and no backend died since `before`."""
+ _wait_up(dsn)
+ up, now = _rows(dsn, "SELECT 1"), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ if up == "1" and now == before:
+ return "1"
+ return f"no ([{up if up == '1' else ''}], {now - before} backend crashes)"
+
+
+def _opened(path):
+ """Let the server, which may run as another user, traverse and read the tree."""
+ for p in [path, *path.rglob("*")]:
+ p.chmod(0o777 if p.is_dir() else 0o644)
+
+
+@pytest.fixture(scope="module")
+def ppart(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ w = server_dir / "ppart"
+ shutil.rmtree(w, ignore_errors=True)
+ w.mkdir()
+ root, enc = w / "events", w / "enc"
+ subprocess.run([sys.executable, "-c", WRITER, str(root), str(enc)], capture_output=True, text=True)
+ _opened(w)
+ r = {}
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ conn.execute("CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ conn.execute(f"CREATE FOREIGN TABLE ev (id int, v text, dt date, region text) "
+ f"SERVER pq OPTIONS (path '{root}', partition_columns 'dt,region')")
+ except psycopg.Error as exc:
+ r["setup"] = query_error(_message(exc))
+ before = _crashes(pgc_cluster)
+
+ # ---- the values are real columns
+ r["all four"] = _rows(dsn, "SELECT count(*) FROM ev")
+ r["materialize"] = _rows(dsn, "SELECT dt || ' ' || region FROM ev WHERE id = 2500")
+ r["typed"] = _rows(dsn, "SELECT count(*) FROM ev WHERE dt = DATE '2026-01-01'")
+ g = _rows(dsn, "SELECT region || '=' || count(*) FROM ev GROUP BY region ORDER BY region")
+ r["group by"] = g if g.startswith("QUERY_ERROR") else " ".join(g.splitlines()) + " "
+
+ # ---- pruning drops whole files before they are opened
+ r["prunes half"] = _pruned_for(dsn, "dt = DATE '2026-01-02'")
+ r["reads half"] = _files_for(dsn, "dt = DATE '2026-01-02'")
+ r["both columns"] = _pruned_for(dsn, "dt = DATE '2026-01-02' AND region = 'eu'")
+ r["right rows"] = _rows(dsn, "SELECT count(*), min(id), max(id) FROM ev "
+ "WHERE dt = DATE '2026-01-02' AND region = 'eu'", sep=" ")
+ r["inequality"] = _pruned_for(dsn, "dt > DATE '2026-01-01'")
+ r["in list"] = _pruned_for(dsn, "region IN ('eu')")
+ r["everything"] = _pruned_for(dsn, "dt >= DATE '2026-01-01'")
+ r["file column"] = _pruned_for(dsn, "id < 10")
+ ga = _counter(_rows(dsn, "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM ev"),
+ "Row Groups") or ""
+ go = _counter(_explain(dsn, "dt = DATE '2026-01-02' AND region = 'eu'"), "Row Groups") or ""
+ r["groups"] = f"{ga} {go}"
+
+ # ---- partition and file predicates combine
+ r["combine"] = _rows(dsn, "SELECT count(*) FROM ev WHERE region = 'eu' AND id BETWEEN 2100 AND 2199")
+ r["groups skipped"] = _counter(_explain(dsn, "region = 'eu' AND id BETWEEN 2100 AND 2199"),
+ "Row Groups Skipped") or ""
+
+ # ---- a volatile clause must not decide a whole file
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("CREATE SEQUENCE volseq")
+ conn.execute("CREATE FUNCTION vol_odd() RETURNS boolean LANGUAGE sql VOLATILE "
+ "AS $$ SELECT nextval('volseq') % 2 = 1 $$")
+ except psycopg.Error as exc:
+ r["volatile setup"] = query_error(_message(exc))
+ r["volatile prunes"] = _pruned_for(dsn, "dt = DATE '2026-01-02' OR vol_odd()")
+ r["volatile keeps"] = _rows(dsn, "SELECT count(*) FROM ev "
+ "WHERE (dt = DATE '2026-01-02' OR vol_odd()) AND dt = DATE '2026-01-02'")
+ r["stable"] = _pruned_for(dsn, "dt = (DATE '2026-01-02' + 0)")
+
+ # ---- a tree that does not match the declaration fails loudly
+ loose = root / "dt=2026-01-03"
+ loose.mkdir()
+ shutil.copy(root / "dt=2026-01-01" / "region=eu" / "part.parquet", loose / "loose.parquet")
+ _opened(loose)
+ r["missing"] = _refusal(dsn, "SELECT count(*) FROM ev", MISSING)
+ shutil.rmtree(loose)
+
+ decoy = root / "dt=2026-01-01" / "region=eu" / "dt=1999-01-01.parquet"
+ shutil.copy(root / "dt=2026-01-01" / "region=eu" / "part.parquet", decoy)
+ decoy.chmod(0o644)
+ r["basename"] = _rows(dsn, "SELECT count(*) FROM ev WHERE dt = DATE '1999-01-01'")
+ decoy.unlink()
+
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(f"CREATE FOREIGN TABLE ev_bad (id int, v text, dt date, region text) "
+ f"SERVER pq OPTIONS (path '{root}', partition_columns 'dt,nosuch')")
+ conn.execute(f"CREATE FOREIGN TABLE ev_dup (id int, v text, dt date, region text) "
+ f"SERVER pq OPTIONS (path '{root}', partition_columns 'dt,region,v')")
+ except psycopg.Error as exc:
+ r["declarations"] = query_error(_message(exc))
+ r["unknown"] = _refusal(dsn, "SELECT count(*) FROM ev_bad", UNKNOWN)
+ r["empty"] = _refusal(dsn, f"CREATE FOREIGN TABLE ev_empty (id int) SERVER pq "
+ f"OPTIONS (path '{root}', partition_columns '')", EMPTY)
+ r["dup column"] = _refusal(dsn, "SELECT count(*) FROM ev_dup", DUPCOL)
+ r["survived declarations"] = _survived(dsn, pgc_cluster, before)
+
+ # ---- Hive value encoding
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(f"CREATE FOREIGN TABLE ev_enc (id int, region text) "
+ f"SERVER pq OPTIONS (path '{enc}', partition_columns 'region')")
+ except psycopg.Error as exc:
+ r["enc setup"] = query_error(_message(exc))
+ r["decodes"] = _rows(dsn, "SELECT DISTINCT region FROM ev_enc WHERE id < 100")
+ r["as itself"] = _rows(dsn, "SELECT count(*) FROM ev_enc WHERE region = 'a=b'")
+ r["null marker"] = _rows(dsn, "SELECT count(*) FROM ev_enc WHERE region IS NULL")
+ r["not literal"] = _rows(dsn, "SELECT count(*) FROM ev_enc WHERE region = '__HIVE_DEFAULT_PARTITION__'")
+ r["plain"] = _rows(dsn, "SELECT count(*) FROM ev_enc WHERE region = 'plain'")
+ r["three"] = _rows(dsn, "SELECT count(*) FROM ev_enc")
+ r["null prunes"] = _pruned_for(dsn, "region IS NOT NULL", "ev_enc")
+
+ nul, ff = enc / "region=a%00b", enc / "region=a%FFb"
+ for d in (nul, ff):
+ d.mkdir()
+ shutil.copy(enc / "region=plain" / "part.parquet", d / "part.parquet")
+ _opened(d)
+ r["nul"] = _refusal(dsn, "SELECT count(*) FROM ev_enc", NUL)
+ shutil.rmtree(nul)
+ r["bad byte"] = _refusal(dsn, "SELECT count(*) FROM ev_enc", BADBYTE)
+ shutil.rmtree(ff)
+ r["reads again"] = _rows(dsn, "SELECT count(*) FROM ev_enc")
+ r["survived values"] = _survived(dsn, pgc_cluster, before)
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_all_four_partitions_read(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["all four"], "4000", "all four partitions read")
+
+
+def test_partition_values_materialize(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["materialize"], "2026-01-02 eu", "partition values materialize")
+
+
+def test_a_partition_column_is_typed_not_text(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["typed"], "2000", "partition column is typed, not text")
+
+
+def test_group_by_a_partition_column(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["group by"], "eu=2000 us=2000 ", "group by a partition column")
+
+
+def test_one_partition_value_prunes_half_the_files(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["prunes half"], "2", "one partition value prunes half the files")
+
+
+def test_and_reads_the_other_half(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["reads half"], "2", "and reads the other half")
+
+
+def test_both_partition_columns_prune_down_to_one_file(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["both columns"], "3", "both columns prune down to one file")
+
+
+def test_a_pruned_scan_still_returns_the_right_rows(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["right rows"], "1000 2000 2999", "pruned scan still returns the right rows")
+
+
+def test_an_inequality_on_a_partition_column_prunes(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["inequality"], "2", "an inequality on a partition column prunes")
+
+
+def test_in_on_a_partition_column_prunes(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["in list"], "2", "IN on a partition column prunes")
+
+
+def test_a_predicate_matching_everything_prunes_nothing(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["everything"], "0", "a predicate matching everything prunes nothing")
+
+
+def test_a_predicate_on_a_file_column_prunes_no_files(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["file column"], "0", "a predicate on a file column prunes no files")
+
+
+def test_a_pruned_files_row_groups_are_never_counted(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["groups"], "16 4", "a pruned file's row groups are never counted")
+
+
+def test_partition_prune_plus_row_group_skip(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["combine"], "100", "partition prune plus row-group skip")
+
+
+def test_row_groups_are_still_skipped_inside_the_surviving_files(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["groups skipped"], "7", "row groups are still skipped inside the surviving files")
+
+
+def test_a_volatile_clause_prunes_nothing(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["volatile prunes"], "0", "a volatile clause prunes nothing")
+
+
+def test_a_volatile_clause_keeps_every_matching_partition_row(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["volatile keeps"], "2000", "a volatile clause keeps every matching partition row")
+
+
+def test_a_stable_clause_still_prunes(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["stable"], "2", "a stable clause still prunes")
+
+
+def test_a_file_missing_a_declared_partition_component_errors(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["missing"], "HV004 (its own message)", "a file missing a declared partition component errors")
+
+
+def test_a_basename_that_looks_like_a_partition_component_is_unaffected(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["basename"], "0", "a file whose basename looks like a partition component is unaffected")
+
+
+def test_an_unknown_partition_column_errors(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["unknown"], "42703 (its own message)", "an unknown partition column errors rather than being ignored")
+
+
+def test_an_empty_partition_columns_option_is_rejected(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["empty"], "HV00D (its own message)", "an empty partition_columns option is rejected")
+
+
+def test_declaring_a_file_column_as_a_partition_column_errors(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["dup column"], "HV004 (its own message)", "declaring a file column as a partition column errors")
+
+
+def test_the_backend_survives_the_bad_declarations(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["survived declarations"], "1", "backend survived the bad declarations")
+
+
+def test_a_percent_encoded_partition_value_decodes(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["decodes"], "a=b", "a percent-encoded partition value decodes")
+
+
+def test_the_encoded_value_is_queryable_as_itself(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["as itself"], "100", "the encoded value is queryable as itself")
+
+
+def test_hive_default_partition_reads_as_null(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["null marker"], "100", "__HIVE_DEFAULT_PARTITION__ reads as null")
+
+
+def test_a_null_partition_value_is_not_the_literal_marker(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["not literal"], "0", "a null partition value is not the literal marker")
+
+
+def test_an_unencoded_value_is_unaffected(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["plain"], "100", "an unencoded value is unaffected")
+
+
+def test_all_three_encoded_partitions_still_read(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["three"], "300", "all three partitions still read")
+
+
+def test_a_null_partition_value_prunes(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["null prunes"], "1", "a null partition value prunes")
+
+
+def test_an_encoded_null_byte_in_a_partition_value_is_rejected(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["nul"], "HV004 (its own message)", "an encoded null byte in a partition value is rejected")
+
+
+def test_a_byte_invalid_in_the_server_encoding_is_rejected(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["bad byte"], "22021 (its own message)", "a byte invalid in the server encoding is rejected")
+
+
+def test_the_encoded_tree_reads_again_once_both_are_gone(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["reads again"], "300", "the tree reads again once both are gone")
+
+
+def test_the_backend_survives_the_malformed_partition_values(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; partition suite needs it") is None:
+ return
+ r = request.getfixturevalue("ppart")
+ expect.text(r["survived values"], "1", "backend survived the malformed partition values")
diff --git a/test/pytest/test_native_parquet_projection.py b/test/pytest/test_native_parquet_projection.py
new file mode 100644
index 000000000..1d7720f46
--- /dev/null
+++ b/test/pytest/test_native_parquet_projection.py
@@ -0,0 +1,239 @@
+"""Parquet column-projection pushdown (Phase G). The pytest twin of
+`test/native_parquet_projection.sh`, every check by the shell's name (#1341).
+
+The FDW scan decodes only the column chunks a query references. Asserted: each
+projection, a qual on an unselected column, and a projection behind a dropped column
+return the generator's rows; the EXPLAIN "Columns Read" counter reflects each
+projection (0 for count(*), 4 for SELECT *, a qual column counted); and a column whose
+data page is smashed is never decoded when it is projected out.
+
+The oracle is built in SQL from the generator `test/craft_parquet_projection.py` wrote
+the file from, never read back through read_parquet, which shares the scan's decoders.
+The crafter runs as a subprocess so the fixture never imports pyarrow: each test
+declines under the shell's skip name first, and only then asks for the fixture.
+
+ON A PRIVATE DATABASE; each test asserts one reading (#1339). A failed reading is a
+query_error(), never a value: two failed reads never agree.
+"""
+import pathlib
+import re
+import subprocess
+import sys
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_parquet_projection"
+CRAFTER = pathlib.Path(__file__).resolve().parents[1] / "craft_parquet_projection.py"
+COLS = "a int, b int8, c text, d float8"
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _value(dsn, sql):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None or row[0] is None:
+ return query_error(f"no value from {sql}")
+ return str(row[0])
+
+
+def _set_hash(dsn, query):
+ """-> the shell's pgc_set_hash: md5 of the sorted row texts, EMPTY for no rows."""
+ return _value(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ( {query} ) _row) _s")
+
+
+def _cols_read(dsn, query):
+ """-> the scan's EXPLAIN ANALYZE "Columns Read" count, as the shell's cols_read."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ lines = [r[0] for r in conn.execute(
+ f"EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) {query}").fetchall()]
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ for line in lines:
+ m = re.search(r"Columns Read:\s*(\d+)", line)
+ if m:
+ return m.group(1)
+ return query_error(f"no Columns Read line for {query}")
+
+
+def _setup(dsn, w):
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ conn.execute("CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ conn.execute(f"CREATE FOREIGN TABLE ft ({COLS}) SERVER pq OPTIONS (path '{w}/proj.parquet')")
+ conn.execute(f"CREATE TABLE h ({COLS})")
+ # the generator the crafter wrote proj.parquet from, never the reader under test
+ conn.execute("INSERT INTO h SELECT g, g * 2, 's' || (g % 20), g::float8 / 3.0 "
+ "FROM generate_series(0, 4999) g")
+ conn.execute(f"CREATE FOREIGN TABLE ftd (z int, {COLS}) SERVER pq "
+ f"OPTIONS (path '{w}/proj.parquet')")
+ conn.execute("ALTER FOREIGN TABLE ftd DROP COLUMN z")
+ conn.execute(f"CREATE FOREIGN TABLE ftc (g int, bad int) SERVER pq "
+ f"OPTIONS (path '{w}/corrupt.parquet')")
+ conn.execute("CREATE FUNCTION pgc_try_proj(q text) RETURNS text LANGUAGE plpgsql AS $$ "
+ "BEGIN EXECUTE q; RETURN 'OK'; "
+ "EXCEPTION WHEN OTHERS THEN RETURN 'ERROR'; END $$")
+
+
+@pytest.fixture(scope="module")
+def npj(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ dsn = pgc_cluster.dsn(dbname=DB)
+ w = server_dir / "npj"
+ w.mkdir(exist_ok=True)
+ w.chmod(0o777)
+ subprocess.run([sys.executable, str(CRAFTER), str(w)], capture_output=True, text=True)
+ for f in w.iterdir():
+ f.chmod(0o644)
+ try:
+ try:
+ # A setup step that fails is not raised: every reading below then fails
+ # by its own name, as the shell's do, rather than erroring all 17 tests.
+ _setup(dsn, w)
+ except psycopg.Error:
+ pass
+ r = {}
+ for sel in ("a", "c", "a, d", "b, c, d", "*"):
+ r[f"proj {sel}"] = (_set_hash(dsn, f"SELECT {sel} FROM ft"), _set_hash(dsn, f"SELECT {sel} FROM h"))
+ r["qual"] = (_set_hash(dsn, "SELECT a FROM ft WHERE c = 's7'"),
+ _set_hash(dsn, "SELECT a FROM h WHERE c = 's7'"))
+ r["cr one"] = _cols_read(dsn, "SELECT a FROM ft")
+ r["cr two"] = _cols_read(dsn, "SELECT a, d FROM ft")
+ r["cr star"] = _cols_read(dsn, "SELECT * FROM ft")
+ r["cr count"] = _cols_read(dsn, "SELECT count(*) FROM ft")
+ r["drop proj"] = (_set_hash(dsn, "SELECT d FROM ftd"), _set_hash(dsn, "SELECT d FROM h"))
+ r["drop cr"] = _cols_read(dsn, "SELECT d FROM ftd")
+ r["cr qual"] = _cols_read(dsn, "SELECT a FROM ft WHERE c = 's7'")
+ tryq = lambda q: _value(dsn, f"SELECT pgc_try_proj($q${q}$q$)")
+ r["good"] = tryq("SELECT count(g) FROM ftc")
+ r["count"] = tryq("SELECT count(*) FROM ftc")
+ r["bad"] = tryq("SELECT count(bad) FROM ftc")
+ r["sum"] = _value(dsn, "SELECT sum(g)::text FROM ftc")
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+
+
+def test_the_a_projection_matches_the_oracle(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ got, want = request.getfixturevalue("npj")["proj a"]
+ expect.text(got, want, "projection [a] == oracle")
+
+
+def test_the_c_projection_matches_the_oracle(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ got, want = request.getfixturevalue("npj")["proj c"]
+ expect.text(got, want, "projection [c] == oracle")
+
+
+def test_the_a_d_projection_matches_the_oracle(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ got, want = request.getfixturevalue("npj")["proj a, d"]
+ expect.text(got, want, "projection [a, d] == oracle")
+
+
+def test_the_b_c_d_projection_matches_the_oracle(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ got, want = request.getfixturevalue("npj")["proj b, c, d"]
+ expect.text(got, want, "projection [b, c, d] == oracle")
+
+
+def test_the_star_projection_matches_the_oracle(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ got, want = request.getfixturevalue("npj")["proj *"]
+ expect.text(got, want, "projection [*] == oracle")
+
+
+def test_a_qual_on_an_unselected_column_matches_the_oracle(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ got, want = request.getfixturevalue("npj")["qual"]
+ expect.text(got, want, "qual on unselected column == oracle")
+
+
+def test_one_column_reads_one(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["cr one"], "1", "one column reads 1")
+
+
+def test_two_columns_read_two(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["cr two"], "2", "two columns read 2")
+
+
+def test_select_star_reads_four(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["cr star"], "4", "select star reads 4")
+
+
+def test_count_star_reads_zero(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["cr count"], "0", "count(*) reads 0")
+
+
+def test_after_a_dropped_column_the_d_projection_matches_the_oracle(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ got, want = request.getfixturevalue("npj")["drop proj"]
+ expect.text(got, want, "after a dropped column, projection [d] == oracle")
+
+
+def test_after_a_dropped_column_one_column_reads_one(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["drop cr"], "1", "after a dropped column, one column reads 1")
+
+
+def test_a_selected_column_and_a_qual_column_read_two(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["cr qual"], "2", "selected 1 + qual 1 reads 2")
+
+
+def test_selecting_the_good_column_skips_the_corrupt_one(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["good"], "OK", "selecting the good column skips the corrupt one")
+
+
+def test_count_star_decodes_nothing_and_survives_the_corruption(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["count"], "OK", "count(*) decodes nothing, survives corruption")
+
+
+def test_selecting_the_corrupt_column_does_error(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["bad"], "ERROR", "selecting the corrupt column does error")
+
+
+def test_the_good_column_values_are_still_correct(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; projection suite needs it") is None:
+ return
+ expect.text(request.getfixturevalue("npj")["sum"], "499500", "good-column values are still correct")
diff --git a/test/pytest/test_native_parquet_stack.py b/test/pytest/test_native_parquet_stack.py
new file mode 100644
index 000000000..2e440cfb4
--- /dev/null
+++ b/test/pytest/test_native_parquet_stack.py
@@ -0,0 +1,198 @@
+"""Parquet footer recursion-depth hardening (#1341). The pytest twin of
+`test/native_parquet_stack.sh`, every check by the shell's name.
+
+The footer parser has two independent unbounded recursions, each driven by a depth
+read straight from the file: PgColumnarThriftSkip (reached from the raw footer) and
+walk_schema (reached only after the footer parses). Each is guarded by
+check_stack_depth(), which turns a would-be SIGSEGV into a caught ERROR 54001. The
+fixture pins max_stack_depth to its minimum so a ~20 KB crafted footer trips the
+guard without overflowing the real C stack, so removing a guard fails the check by a
+wrong SQLSTATE rather than by crashing. The footers are hand-crafted (no writer makes
+them), so no pyarrow: struct and the compact-protocol bytes build them directly.
+"""
+import struct
+import time
+
+import psycopg
+
+from pgc_vacuity import query_error
+
+
+def _uleb(n):
+ out = bytearray()
+ while True:
+ b = n & 0x7F
+ n >>= 7
+ if n:
+ out.append(b | 0x80)
+ else:
+ out.append(b)
+ return bytes(out)
+
+
+def _wrap(meta):
+ return b"PAR1" + meta + struct.pack(" the SQLSTATE `fn(path)` raises under a pinned stack depth, 'NOERR' if it
+ raises nothing, or a query_error() if the server could not be reached. The call is
+ caught in-session, so only a real crash drops the connection, which the next
+ survival check sees. A failed connection is a value, never an exception, so two
+ failures cannot agree as a bare string."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute("SET max_stack_depth = '100kB'")
+ try:
+ conn.execute(f"SELECT count(*) FROM pgcolumnar.{fn}('{path}')")
+ return "NOERR"
+ except psycopg.Error as exc:
+ return exc.sqlstate or query_error(str(exc).splitlines()[0])
+ except psycopg.Error as exc:
+ return query_error(f"server unreachable: {str(exc).splitlines()[0]}")
+
+
+def _wait_up(dsn):
+ """Wait up to 30 s for the server, so a backend that crashed in one arm fails that
+ arm, not every reading made during its crash recovery."""
+ for _ in range(60):
+ if _alive(dsn) == "1":
+ return
+ time.sleep(0.5)
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ """-> "1" when the server answers and no backend died since `before`. A bare SELECT 1
+ answers again once crash recovery is over (#1708 review), so the log is read too."""
+ _wait_up(dsn)
+ up, now = _alive(dsn), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ if up == "1" and now == before:
+ return "1"
+ return f"no ([{up if up == '1' else ''}], {now - before} backend crashes)"
+
+
+def _read_parquet_state(dsn, path):
+ """-> the SQLSTATE read_parquet raises on `path` under a pinned stack depth, 'NOERR',
+ or a query_error() if the server could not be reached. count(*) over a one-int
+ projection reaches the footer walk."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute("SET max_stack_depth = '100kB'")
+ try:
+ conn.execute(f"SELECT count(*) FROM pgcolumnar.read_parquet('{path}') AS t(c int)")
+ return "NOERR"
+ except psycopg.Error as exc:
+ return exc.sqlstate or query_error(str(exc).splitlines()[0])
+ except psycopg.Error as exc:
+ return query_error(f"server unreachable: {str(exc).splitlines()[0]}")
+
+
+def _alive(dsn):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ return str(conn.execute("SELECT 1").fetchone()[0])
+ except psycopg.Error as exc:
+ return query_error(str(exc).splitlines()[0])
+
+
+def _count(dsn, sql):
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ return str(conn.execute(sql).fetchone()[0])
+ except psycopg.Error as exc:
+ return query_error(str(exc).splitlines()[0])
+
+
+def test_nested_struct_footer_raises_stack_depth(pgc_cluster, server_dir, expect):
+ _write(server_dir)
+ dsn = pgc_cluster.dsn()
+ expect.text(_sqlstate(dsn, "parquet_schema", server_dir / "stack_nested.parquet"), "54001",
+ "nested-struct footer raises stack-depth, not SIGSEGV")
+
+
+def test_backend_survived_the_nested_struct_footer(pgc_cluster, server_dir, expect):
+ _write(server_dir)
+ before = _crashes(pgc_cluster)
+ _sqlstate(pgc_cluster.dsn(), "parquet_schema", server_dir / "stack_nested.parquet")
+ expect.text(_survived(pgc_cluster.dsn(), pgc_cluster, before), "1", "backend survived the nested-struct footer")
+
+
+def test_schema_chain_footer_raises_stack_depth(pgc_cluster, server_dir, expect):
+ _write(server_dir)
+ expect.text(_sqlstate(pgc_cluster.dsn(), "parquet_schema", server_dir / "stack_walk.parquet"), "54001",
+ "schema-chain footer raises stack-depth, not SIGSEGV")
+
+
+def test_backend_survived_the_schema_chain_footer(pgc_cluster, server_dir, expect):
+ _write(server_dir)
+ before = _crashes(pgc_cluster)
+ _sqlstate(pgc_cluster.dsn(), "parquet_schema", server_dir / "stack_walk.parquet")
+ expect.text(_survived(pgc_cluster.dsn(), pgc_cluster, before), "1", "backend survived the schema-chain footer")
+
+
+def test_a_shallow_valid_schema_chain_still_reads(pgc_cluster, server_dir, expect):
+ _write(server_dir)
+ expect.text(_count(pgc_cluster.dsn(),
+ f"SELECT count(*) FROM pgcolumnar.parquet_schema('{server_dir}/stack_walk_ok.parquet')"),
+ "1", "a shallow valid schema chain still reads")
+
+
+def test_backend_survived_the_valid_file(pgc_cluster, server_dir, expect):
+ _write(server_dir)
+ before = _crashes(pgc_cluster)
+ _count(pgc_cluster.dsn(), f"SELECT count(*) FROM pgcolumnar.parquet_schema('{server_dir}/stack_walk_ok.parquet')")
+ expect.text(_survived(pgc_cluster.dsn(), pgc_cluster, before), "1", "backend survived the valid file")
+
+
+def test_read_parquet_rejects_the_nested_struct_footer(pgc_cluster, server_dir, expect):
+ _write(server_dir)
+ # read_parquet reaches the same footer walk; it must refuse the crash input, not
+ # become a way around the guard.
+ st = _read_parquet_state(pgc_cluster.dsn(), server_dir / "stack_nested.parquet")
+ expect.text(st, "54001", "read_parquet rejects the nested-struct footer the same way")
+
+
+def test_backend_survived_read_parquet_on_the_crash_input(pgc_cluster, server_dir, expect):
+ _write(server_dir)
+ # the trigger itself, as the other three survival tests do (#1708 review: this test
+ # never read the file, so a crash in read_parquet could not reach it)
+ before = _crashes(pgc_cluster)
+ _read_parquet_state(pgc_cluster.dsn(), server_dir / "stack_nested.parquet")
+ expect.text(_survived(pgc_cluster.dsn(), pgc_cluster, before), "1",
+ "backend survived read_parquet on the crash input")
diff --git a/test/pytest/test_native_parquet_streaming.py b/test/pytest/test_native_parquet_streaming.py
new file mode 100644
index 000000000..0fdd07342
--- /dev/null
+++ b/test/pytest/test_native_parquet_streaming.py
@@ -0,0 +1,338 @@
+"""Streaming external-Parquet reads: a file over the old 1GB palloc ceiling describes
+itself, the page loop refuses crafted pages cleanly, and the FDW decodes one row group
+at a time. The pytest twin of `test/native_parquet_streaming.sh`, every check by the
+shell's name.
+
+Every file is written by `test/craft_parquet_streaming.py`, the shell's own crafter,
+run as a subprocess (so this module imports no pyarrow) into `server_dir`. A refusal
+must be the page loop's own ("could not decode Parquet column ..."), not any error.
+Every reading waits for the server first, so a crash fails only its own arm, and each
+survival check also reads the server log's count of killed backends since its
+section began, since SELECT 1 answers again once crash recovery has finished.
+
+ON A PRIVATE DATABASE. Without pyarrow every test declines under the shell's skip
+name; on a box that cannot build the oversized sparse file, under the shell's other
+skip name, before asking for the fixture's readings.
+"""
+import pathlib
+import re
+import subprocess
+import sys
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_parquet_streaming"
+CRAFTER = pathlib.Path(__file__).resolve().parent.parent / "craft_parquet_streaming.py"
+NO_PYARROW = "pyarrow not available; streaming suite needs it"
+NO_SPARSE = "could not build the oversized sparse file"
+DECODE = "could not decode Parquet column "
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _wait_up(dsn):
+ for _ in range(60):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _rows(dsn, sql, sep="|"):
+ """-> every row joined as the shell's q prints them (columns by `sep`, rows by
+ newline), or the failure as a query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ rows = conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "\n".join(sep.join("" if v is None else str(v) for v in row) for row in rows)
+
+
+def _exec(dsn, sql):
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return None
+
+
+def _errtext(dsn, sql, timeout=None):
+ """-> "ERROR: " for the error SQL raised, as psql's first ERROR line, or
+ NO ERROR; TIMED OUT when a statement_timeout cancelled it; a lost connection as a
+ query_error()."""
+ _wait_up(dsn)
+ caught = None
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ if timeout:
+ conn.execute(f"SET statement_timeout = '{timeout}s'")
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ caught = exc
+ if caught is None:
+ return "NO ERROR"
+ if isinstance(caught, psycopg.errors.QueryCanceled):
+ return "TIMED OUT (page loop made no progress)"
+ if not caught.sqlstate:
+ return query_error("no refusal: the connection was lost")
+ return "ERROR: " + (caught.diag.message_primary or "")
+
+
+def _decodes(dsn, sql):
+ """-> OK when SQL raised the page loop's refusal, else what it did raise."""
+ out = _errtext(dsn, sql)
+ return "OK" if isinstance(out, str) and out.startswith("ERROR: " + DECODE) else out
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ """-> "1" when the server answers and no backend died since `before`."""
+ _wait_up(dsn)
+ up, now = _rows(dsn, "SELECT 1"), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ if up == "1" and now == before:
+ return "1"
+ return f"no ([{up if up == '1' else ''}], {now - before} backend crashes)"
+
+
+def _decoded_for(dsn, sql):
+ """-> the plan's Row Groups Decoded count, or what was read instead."""
+ plan = _rows(dsn, f"EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) {sql}")
+ m = re.search(r"Row Groups Decoded: (\d+)", plan) if isinstance(plan, str) else None
+ return m.group(1) if m else plan
+
+
+@pytest.fixture(scope="module")
+def pqs(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ w = server_dir / "pqs"
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ w.mkdir(exist_ok=True)
+ w.chmod(0o777)
+ rc = subprocess.run([sys.executable, str(CRAFTER), str(w)],
+ capture_output=True, text=True).returncode
+ for f in w.glob("*.parquet"):
+ f.chmod(0o644)
+ r = {"sparse": rc not in (3, 4), "crafted": "built" if rc == 0 else f"build failed (exit {rc})"}
+ if rc != 0:
+ yield r
+ return
+ _exec(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ before = _crashes(pgc_cluster)
+ r["schema big"] = _rows(dsn, f"SELECT count(*) FROM pgcolumnar.parquet_schema('{w}/huge_sparse.parquet')")
+ r["schema cols"] = _rows(dsn, f"SELECT string_agg(column_name, ',' ORDER BY column_name) "
+ f"FROM pgcolumnar.parquet_schema('{w}/huge_sparse.parquet')")
+ r["sparse read"] = _decodes(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/huge_sparse.parquet') "
+ f"AS t(id int, v text)")
+ r["survived read"] = _survived(dsn, pgc_cluster, before)
+ mid = _crashes(pgc_cluster)
+ r["schema small"] = _rows(dsn, f"SELECT count(*) FROM pgcolumnar.parquet_schema('{w}/small.parquet')")
+ r["read small"] = _rows(dsn, f"SELECT count(*) FROM pgcolumnar.read_parquet('{w}/small.parquet') "
+ f"AS t(id int, v text)")
+ zero = _errtext(dsn, f"SELECT count(*) FROM pgcolumnar.read_parquet('{w}/zero_pages.parquet') "
+ f"AS t(id int)", timeout=60)
+ r["zero"] = "CLEAN REJECT" if isinstance(zero, str) and zero.startswith("ERROR: " + DECODE) else zero
+ r["bad size"] = _errtext(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/bad_size.parquet') AS t(id int)")
+ r["bad offset"] = _errtext(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/bad_offset.parquet') "
+ f"AS t(id int, v text)")
+ r["noise"] = _decodes(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/noise_pages.parquet') AS t(id int)")
+ r["two dicts"] = _errtext(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/two_dicts.parquet') AS t(id int)")
+ r["v2"] = _errtext(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/v2_levels.parquet') AS t(id int)")
+ r["allnull"] = _rows(dsn, f"SELECT count(*), count(c) FROM pgcolumnar.read_parquet('{w}/allnull.parquet') "
+ f"AS t(c int)", sep=" ")
+ r["survived crafted"] = _survived(dsn, pgc_cluster, mid)
+ r["plain"] = _rows(dsn, f"SELECT count(*), sum(id) FROM pgcolumnar.read_parquet('{w}/plain.parquet') "
+ f"AS t(id int)", sep=" ")
+ for stmt in ("CREATE SERVER pqs FOREIGN DATA WRAPPER pgcolumnar_parquet",
+ f"CREATE FOREIGN TABLE fts (id int4, v int8) SERVER pqs "
+ f"OPTIONS (path '{w}/stream_groups.parquet')",
+ "CREATE TABLE outer2 (k int)", "INSERT INTO outer2 VALUES (1),(2)"):
+ _exec(dsn, stmt)
+ r["full"] = _rows(dsn, "SELECT count(*), sum(id), sum(v) FROM fts", sep=" ")
+ r["dec all"] = _decoded_for(dsn, "SELECT * FROM fts")
+ r["dec 10"] = _decoded_for(dsn, "SELECT * FROM fts LIMIT 10")
+ r["dec 1010"] = _decoded_for(dsn, "SELECT * FROM fts LIMIT 1010")
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("SET enable_hashjoin = off")
+ conn.execute("SET enable_mergejoin = off")
+ r["rescan"] = str(conn.execute(
+ "SELECT count(*) FROM outer2 o JOIN fts ON fts.id = o.k").fetchone()[0])
+ except psycopg.Error as exc:
+ r["rescan"] = query_error(_message(exc))
+ yield r
+ finally:
+ for f in w.glob("*.parquet"):
+ f.unlink(missing_ok=True)
+ _wait_up(pgc_cluster.dsn())
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def _readings(request, expect):
+ """-> the fixture's readings, or None after declining as the shell skips."""
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; streaming suite needs it") is None:
+ return None
+ r = request.getfixturevalue("pqs")
+ if not r["sparse"]:
+ expect.cannot_run("UNMET_PRECONDITION", "the box cannot build a 1600MB sparse file",
+ name="could not build the oversized sparse file")
+ return None
+ return r
+
+
+def test_parquet_streaming_crafted_files_were_built(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["crafted"], "built", "crafted files were built")
+
+
+def test_parquet_streaming_schema_reads_a_file_over_the_palloc_ceiling(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("schema big", r["crafted"]), "2", "parquet_schema reads a file over the 1GB palloc ceiling")
+
+
+def test_parquet_streaming_schema_reports_the_oversized_files_columns(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("schema cols", r["crafted"]), "id,v",
+ "parquet_schema reports the columns of the oversized file")
+
+
+def test_parquet_streaming_value_read_of_the_sparse_file_fails_cleanly(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("sparse read", r["crafted"]), "OK", "value read of the sparse file fails cleanly")
+
+
+def test_parquet_streaming_backend_survived_the_failed_read(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("survived read", r["crafted"]), "1", "backend survived the failed read")
+
+
+def test_parquet_streaming_schema_still_reads_a_normal_file(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("schema small", r["crafted"]), "2", "parquet_schema still reads a normal file")
+
+
+def test_parquet_streaming_read_parquet_still_reads_a_normal_file(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("read small", r["crafted"]), "3", "read_parquet still reads a normal file")
+
+
+def test_parquet_streaming_zero_filled_page_area_is_rejected(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("zero", r["crafted"]), "CLEAN REJECT",
+ "zero-filled page area is rejected, not walked byte by byte")
+
+
+def test_parquet_streaming_page_size_past_eof_is_rejected(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("bad size", r["crafted"]), "ERROR: could not decode Parquet column 0 in row group 0",
+ "page size past end of file is rejected by the size bound")
+
+
+def test_parquet_streaming_negative_page_offset_is_rejected(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("bad offset", r["crafted"]), "ERROR: could not decode Parquet column 1 in row group 0",
+ "negative page offset is rejected by the offset check")
+
+
+def test_parquet_streaming_unparseable_page_header_is_rejected(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("noise", r["crafted"]), "OK", "unparseable page header is rejected")
+
+
+def test_parquet_streaming_second_dictionary_page_is_rejected(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("two dicts", r["crafted"]), "ERROR: could not decode Parquet column 0 in row group 0",
+ "a second dictionary page in a chunk is rejected")
+
+
+def test_parquet_streaming_v2_level_lengths_past_the_page_are_rejected(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("v2", r["crafted"]), "ERROR: could not decode Parquet column 0 in row group 0",
+ "v2 level lengths past the page are rejected")
+
+
+def test_parquet_streaming_all_null_column_still_reads(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("allnull", r["crafted"]), "100 0", "all-null column (empty dictionary page) still reads")
+
+
+def test_parquet_streaming_backend_survived_the_crafted_page_areas(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("survived crafted", r["crafted"]), "1", "backend survived the crafted page areas")
+
+
+def test_parquet_streaming_uncompressed_source_file_still_reads(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("plain", r["crafted"]), "200 19900", "the uncompressed source file still reads")
+
+
+def test_parquet_streaming_full_scan_content_is_correct(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("full", r["crafted"]), "4000 7998000 15996000", "streaming: full scan content is correct")
+
+
+def test_parquet_streaming_full_scan_decodes_all_four_row_groups(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("dec all", r["crafted"]), "4", "streaming: a full scan decodes all four row groups")
+
+
+def test_parquet_streaming_limit_10_decodes_one_row_group(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("dec 10", r["crafted"]), "1",
+ "streaming: LIMIT 10 (inside group 0) decodes one row group")
+
+
+def test_parquet_streaming_limit_1010_decodes_two_row_groups(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("dec 1010", r["crafted"]), "2",
+ "streaming: LIMIT 1010 (into group 1) decodes two row groups")
+
+
+def test_parquet_streaming_rescan_under_a_nested_loop_is_correct(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r.get("rescan", r["crafted"]), "2", "streaming: rescan under a nested loop is correct")
diff --git a/test/pytest/test_native_parquet_units.py b/test/pytest/test_native_parquet_units.py
new file mode 100644
index 000000000..c669f1d90
--- /dev/null
+++ b/test/pytest/test_native_parquet_units.py
@@ -0,0 +1,309 @@
+"""Parquet TIME/TIMESTAMP unit handling (Phase G). The pytest twin of
+`test/native_parquet_units.sh`, every check by the shell's name.
+
+Parquet stores a time unit beside TIME and TIMESTAMP columns, in the deprecated
+ConvertedType or in the LogicalType union, which can also say NANOS. PostgreSQL stores
+microseconds, so a reader that ignores the unit returns values off by a factor of 1000,
+in range, so nothing errors. pyarrow writes ConvertedType for millis and micros but only
+LogicalType for nanos, so both metadata paths run.
+
+Two oracles are literals or the generator, never the file read back through the reader
+under test: the nanos truncation checks compare with the toward-zero microseconds as
+text (a second file read the same way agreed on two failed reads), and the two-group
+millis file is compared with the hourly series the fixture wrote (a mirror filled
+through read_parquet agreed with a decode that shifted rows to the next dictionary
+entry). Every reading returns a failure as a value, so a failed query fails its own
+check by name.
+
+ON A PRIVATE DATABASE. The shell skips whole without pyarrow, so every test declines
+under the shell's skip name before it asks for the fixture: the writer needs pyarrow,
+and it runs as a subprocess so this module imports none. The files live in
+`server_dir`.
+"""
+import re
+import subprocess
+import sys
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_parquet_units"
+
+# the same writer the shell runs, less the reference files the literals replaced
+WRITER = r'''
+import sys, pyarrow as pa, pyarrow.parquet as pq
+W = sys.argv[1]
+secs = 1784851200
+for unit, mult, ver in [("ms", 10**3, "1.0"), ("us", 10**6, "1.0"), ("ns", 10**9, "2.6")]:
+ pq.write_table(pa.table({"t": pa.array([secs * mult], pa.int64()).cast(pa.timestamp(unit))}),
+ f"{W}/ts_{unit}.parquet", version=ver)
+pq.write_table(pa.table({"t": pa.array([3723000004], pa.int64()).cast(pa.time64('us'))}),
+ f"{W}/time_us.parquet")
+pq.write_table(pa.table({"t": pa.array([3723000], pa.int32()).cast(pa.time32('ms'))}),
+ f"{W}/time_ms.parquet")
+pq.write_table(pa.table({"t": pa.array([9223372036854776], pa.int64()).cast(pa.timestamp('ms'))}),
+ f"{W}/ms_overflow.parquet")
+for tag, ns in [("frac", 1784851200_123_456_789), ("neg", -1784851200_123_456_789)]:
+ pq.write_table(pa.table({"t": pa.array([ns], pa.int64()).cast(pa.timestamp('ns'))}),
+ f"{W}/tsns_{tag}.parquet", version="2.6")
+HOUR = 3600000
+g0 = [1704067200000 + i * HOUR for i in range(1000)]
+g1 = [1751328000000 + i * HOUR for i in range(1000)]
+pq.write_table(pa.table({"t": pa.array(g0 + g1, pa.int64()).cast(pa.timestamp('ms'))}),
+ f"{W}/ms_groups.parquet", row_group_size=1000)
+assert pq.ParquetFile(f"{W}/ms_groups.parquet").num_row_groups == 2
+'''
+
+# the hourly series the fixture wrote, built in SQL: the oracle for the two-group file
+GENERATOR = ("SELECT timestamp '2024-01-01' + g * interval '1 hour' AS t FROM generate_series(0, 999) g "
+ "UNION ALL SELECT timestamp '2025-07-01' + g * interval '1 hour' FROM generate_series(0, 999) g")
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _run(dsn, sql):
+ """-> None when the statement ran, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return None
+
+
+def _value(dsn, sql):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None or row[0] is None:
+ return query_error(f"no value from {sql}")
+ return str(row[0])
+
+
+def _set_hash(dsn, sql):
+ """-> lib.sh's pgc_set_hash, on the server; a failed query is a query_error()."""
+ return _value(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ({sql}) _row) _s")
+
+
+def _sqlstate(dsn, sql):
+ """-> the SQLSTATE the statement raised, or "NO ERROR", as the shell's pgc_try."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return exc.sqlstate or query_error(_message(exc))
+ return "NO ERROR"
+
+
+def _skipped(dsn, pred):
+ """-> the scan's Row Groups Skipped for `pred`, or a query_error() when unread."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ plan = "\n".join(r[0] for r in conn.execute(
+ "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) "
+ f"SELECT count(*) FROM fmsg WHERE {pred}").fetchall())
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ m = re.search(r"Row Groups Skipped: (\d+)", plan)
+ return m.group(1) if m else query_error("no 'Row Groups Skipped' line in the plan")
+
+
+@pytest.fixture(scope="module")
+def pqu(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ w = server_dir / "pqu"
+ w.mkdir(exist_ok=True)
+ w.chmod(0o777)
+ subprocess.run([sys.executable, "-c", WRITER, str(w)], capture_output=True, text=True)
+ for f in w.glob("*.parquet"):
+ f.chmod(0o644)
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ _run(dsn, "CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ for u in ("ms", "us", "ns"):
+ _run(dsn, f"CREATE FOREIGN TABLE fts_{u} (t timestamp) SERVER pq OPTIONS (path '{w}/ts_{u}.parquet')")
+ _run(dsn, f"CREATE FOREIGN TABLE ftime (t time) SERVER pq OPTIONS (path '{w}/time_us.parquet')")
+ _run(dsn, f"CREATE FOREIGN TABLE ftime_ms (t time) SERVER pq OPTIONS (path '{w}/time_ms.parquet')")
+ r = {}
+ for u in ("ms", "us", "ns"):
+ r[f"ts {u}"] = _value(dsn, f"SELECT t::text FROM fts_{u}")
+ r["time us"] = _value(dsn, "SELECT t::text FROM ftime")
+ r["time ms"] = _value(dsn, "SELECT t::text FROM ftime_ms")
+ for key, f in (("advice time ms", "time_ms"), ("advice ms", "ts_ms"),
+ ("advice us", "ts_us"), ("advice ns", "ts_ns")):
+ r[key] = _value(dsn, f"SELECT data_type FROM pgcolumnar.parquet_schema('{w}/{f}.parquet')")
+ r["ns bigint"] = _value(dsn, f"SELECT t::text FROM pgcolumnar.read_parquet('{w}/ts_ns.parquet') AS t(t int8)")
+ r["overflow"] = _sqlstate(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{w}/ms_overflow.parquet') "
+ "AS t(t timestamp)")
+ r["read_parquet"] = _value(dsn, f"SELECT t::text FROM pgcolumnar.read_parquet('{w}/ts_ms.parquet') "
+ "AS t(t timestamp)")
+ _run(dsn, "CREATE TABLE imp_ms (t timestamp)")
+ _run(dsn, f"SELECT pgcolumnar.import_parquet('imp_ms'::regclass, '{w}/ts_ms.parquet')")
+ r["import"] = _value(dsn, "SELECT t::text FROM imp_ms")
+ r["hash ms"] = _set_hash(dsn, "SELECT t FROM fts_ms")
+ r["hash us"] = _set_hash(dsn, "SELECT t FROM fts_us")
+ r["hash ns"] = _set_hash(dsn, "SELECT t FROM fts_ns")
+ for tag in ("frac", "neg"):
+ r[f"trunc {tag}"] = _value(dsn, f"SELECT t::text FROM pgcolumnar.read_parquet('{w}/tsns_{tag}.parquet') "
+ "AS t(t timestamp)")
+ _run(dsn, f"CREATE FOREIGN TABLE fmsg (t timestamp) SERVER pq OPTIONS (path '{w}/ms_groups.parquet')")
+ r["skipped"] = _skipped(dsn, "t >= timestamp '2025-06-01'")
+ r["file"] = _set_hash(dsn, "SELECT t FROM fmsg")
+ r["file oracle"] = _set_hash(dsn, GENERATOR)
+ r["pushdown"] = _set_hash(dsn, "SELECT t FROM fmsg WHERE t >= timestamp '2025-06-01'")
+ r["pushdown oracle"] = _set_hash(dsn, f"SELECT t FROM ({GENERATOR}) g WHERE t >= timestamp '2025-06-01'")
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_ts_ms_decodes_to_the_right_instant(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["ts ms"], "2026-07-24 00:00:00", "ts_ms decodes to the right instant")
+
+
+def test_ts_us_decodes_to_the_right_instant(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["ts us"], "2026-07-24 00:00:00", "ts_us decodes to the right instant")
+
+
+def test_ts_ns_decodes_to_the_right_instant(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["ts ns"], "2026-07-24 00:00:00", "ts_ns decodes to the right instant")
+
+
+def test_time_micros_decodes_to_the_right_time(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["time us"], "01:02:03.000004", "time micros decodes to the right time")
+
+
+def test_time_millis_decodes_to_the_right_time(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["time ms"], "01:02:03", "time millis decodes to the right time")
+
+
+def test_parquet_schema_advises_time_for_time_millis(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["advice time ms"], "time without time zone", "parquet_schema advises time for TIME_MILLIS")
+
+
+def test_parquet_schema_advises_timestamp_for_millis(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["advice ms"], "timestamp without time zone", "parquet_schema advises timestamp for millis")
+
+
+def test_parquet_schema_advises_timestamp_for_micros(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["advice us"], "timestamp without time zone", "parquet_schema advises timestamp for micros")
+
+
+def test_parquet_schema_advises_bigint_for_nanos(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["advice ns"], "bigint", "parquet_schema advises bigint for nanos (lossless)")
+
+
+def test_nanos_read_as_bigint_is_exact(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["ns bigint"], "1784851200000000000", "nanos read as bigint is exact")
+
+
+def test_millis_to_micros_overflow_raises_22008(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["overflow"], "22008", "millis-to-micros overflow raises 22008")
+
+
+def test_read_parquet_honours_the_unit(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["read_parquet"], "2026-07-24 00:00:00", "read_parquet honours the unit")
+
+
+def test_import_parquet_honours_the_unit(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["import"], "2026-07-24 00:00:00", "import_parquet honours the unit")
+
+
+def test_millis_equals_micros(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["hash ms"], r["hash us"], "millis == micros")
+
+
+def test_nanos_equals_micros(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["hash ns"], r["hash us"], "nanos == micros")
+
+
+def test_nanos_sub_us_truncates_toward_zero_frac(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["trunc frac"], "2026-07-24 00:00:00.123456", "nanos sub-us truncates toward zero [frac]")
+
+
+def test_nanos_sub_us_truncates_toward_zero_neg(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["trunc neg"], "1913-06-10 23:59:59.876544", "nanos sub-us truncates toward zero [neg]")
+
+
+def test_millis_scaled_stats_skip_the_non_matching_group(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["skipped"], "1", "millis-scaled stats skip the non-matching group")
+
+
+def test_the_millis_file_reads_back_exactly_as_written(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["file"], r["file oracle"], "the millis file reads back exactly as written")
+
+
+def test_millis_pushdown_result_equals_the_oracle(request, expect):
+ if expect.require_module("pyarrow.parquet", name="pyarrow not available; Parquet unit suite needs it") is None:
+ return
+ r = request.getfixturevalue("pqu")
+ expect.text(r["pushdown"], r["pushdown oracle"], "millis pushdown result == oracle")
diff --git a/test/pytest/test_native_read_parquet.py b/test/pytest/test_native_read_parquet.py
new file mode 100644
index 000000000..9a9b9e193
--- /dev/null
+++ b/test/pytest/test_native_read_parquet.py
@@ -0,0 +1,254 @@
+"""pgcolumnar.read_parquet reads a server-side Parquet file back as the rows that were
+generated, refuses what it must with its own guard's message, and keeps its own
+pg_read_server_files gate. The pytest twin of `test/native_read_parquet.sh`, every check
+by the shell's name (#1341).
+
+THE ORACLE IS THE GENERATOR. The source table is read through the columnar scan both
+to write the file and, before #1341, as "the source", so a scan that decoded one value
+wrong agreed with itself. Every round trip here hashes the generator's own rows.
+
+A REFUSAL IS ITS OWN GUARD'S. Two refusals share SQLSTATE 42804, so each requires its
+message as well; each waits for the server first, and a survival check reads the server
+log's count of killed backends. A failed reading is a query_error(), never a default.
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order; each test
+asserts one reading.
+"""
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_read_parquet"
+SCALAR_COLS = ("c_i2 int2, c_i4 int4, c_i8 int8, c_f4 float4, c_f8 float8, c_bool bool, "
+ "c_text text, c_bytea bytea, c_date date, c_time time, c_ts timestamp")
+GEN_SCALAR = ("SELECT (g%100)::int2, g, g::int8*1000, (g/2.0)::float4, (g/4.0)::float8, (g%2=0), "
+ "'row'||g, decode(lpad(to_hex(g),8,'0'),'hex'), date '2020-01-01' + g, "
+ "time '00:00:00' + (g||' seconds')::interval, "
+ "timestamp '2020-01-01' + (g||' minutes')::interval "
+ "FROM generate_series(1, 5000) g "
+ "UNION ALL SELECT NULL::int2, NULL::int4, NULL::int8, NULL::float4, NULL::float8, "
+ "NULL::bool, NULL::text, NULL::bytea, NULL::date, NULL::time, NULL::timestamp")
+NESTED_COLS = "id int, ia int[], ta text[], c rp_npc"
+GEN_NESTED = ("SELECT g, "
+ "CASE WHEN g%11=0 THEN NULL WHEN g%7=0 THEN '{}'::int[] ELSE ARRAY[g, g+1, g+2] END, "
+ "CASE WHEN g%13=0 THEN NULL ELSE ARRAY['a'||g, 'b'||g] END, "
+ "CASE WHEN g%5=0 THEN NULL ELSE ROW(g, 'c'||g)::rp_npc END "
+ "FROM generate_series(1, 3000) g")
+GATE = "must be superuser or a member of the pg_read_server_files role to read a server file"
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _up(dsn):
+ """Wait (bounded) until the server answers, so a crash fails only its own reading."""
+ for _ in range(40):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ if conn.execute("SELECT 1").fetchone()[0] == 1:
+ return
+ except psycopg.Error:
+ pass
+ time.sleep(0.5)
+
+
+def _run(dsn, sql):
+ _up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql.encode()) # simple protocol: several statements, as psql -c
+ except psycopg.Error as exc:
+ return _message(exc)
+ return ""
+
+
+def _value(dsn, sql, settings=()):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ _up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ for s in settings:
+ conn.execute(s)
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None or row[0] is None:
+ return query_error(f"no value from {sql}")
+ return str(row[0])
+
+
+def _set_hash(dsn, sql):
+ return _value(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ({sql}) _row) _s")
+
+
+def _refusal(dsn, sql, phrase, label, settings=()):
+ """-> "" when `sql` raised with `phrase` in its message, the bare
+ SQLSTATE when the message lacks it, or what happened instead."""
+ _up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ for s in settings:
+ conn.execute(s)
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return query_error(_message(exc))
+ return exc.sqlstate + (label if phrase in str(exc) else "")
+ return "no error"
+
+
+def _crashes(cluster):
+ try:
+ text = (cluster.datadir / "server.log").read_text(errors="replace")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+ return text.count("terminated by signal")
+
+
+@pytest.fixture(scope="module")
+def rp(pgc_cluster, server_dir):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ before = _crashes(pgc_cluster)
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ scalar = server_dir / "rp_scalar.parquet"
+ nested = server_dir / "rp_nested.parquet"
+ bad = server_dir / "rp_bad.dat"
+ r = {}
+
+ _run(dsn, f"CREATE TABLE s ({SCALAR_COLS}) USING pgcolumnar")
+ _run(dsn, f"INSERT INTO s {GEN_SCALAR}")
+ r["scalar export"] = _value(dsn, f"SELECT pgcolumnar.export_parquet('s', '{scalar}')")
+ rp_scalar = f"SELECT * FROM pgcolumnar.read_parquet('{scalar}') AS t({SCALAR_COLS})"
+ r["scalar rp"] = _set_hash(dsn, rp_scalar)
+ r["scalar gen"] = _set_hash(dsn, GEN_SCALAR)
+ r["scalar count"] = _value(dsn, f"SELECT count(*) FROM pgcolumnar.read_parquet('{scalar}') "
+ f"AS t({SCALAR_COLS})")
+ r["proj rp"] = _set_hash(dsn, f"SELECT c_text FROM pgcolumnar.read_parquet('{scalar}') "
+ f"AS t({SCALAR_COLS})")
+ r["proj gen"] = _set_hash(dsn, "SELECT 'row'||g FROM generate_series(1, 5000) g "
+ "UNION ALL SELECT NULL::text")
+
+ _run(dsn, "CREATE TYPE rp_npc AS (x int, y text)")
+ _run(dsn, "CREATE TABLE np (id int, ia int[], ta text[], c rp_npc) USING pgcolumnar")
+ _run(dsn, f"INSERT INTO np {GEN_NESTED}")
+ r["nested export"] = _value(dsn, f"SELECT pgcolumnar.export_parquet('np', '{nested}')")
+ r["nested rp"] = _set_hash(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{nested}') "
+ f"AS t({NESTED_COLS})")
+ r["nested gen"] = _set_hash(dsn, GEN_NESTED)
+
+ r["no list"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{scalar}')",
+ "a column definition list is required", " (no column list)")
+ r["fewer"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{scalar}') AS t(c_i2 int2)",
+ "Parquet file has 11 leaf columns, target table expands to 1",
+ " by the leaf-count guard")
+ r["type"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{scalar}') "
+ "AS t(c_i2 int2, c_i4 text, c_i8 int8, c_f4 float4, c_f8 float8, "
+ "c_bool bool, c_text text, c_bytea bytea, c_date date, c_time time, "
+ "c_ts timestamp)",
+ 'physical type is not compatible with target column "c_i4"',
+ " by the physical-type guard")
+ bad.write_text("not parquet")
+ bad.chmod(0o644)
+ r["bad"] = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{bad}') AS t(a int)",
+ "is not a Parquet file", " by the magic check")
+
+ _run(dsn, "DROP ROLE IF EXISTS t_rp_none")
+ _run(dsn, "DROP ROLE IF EXISTS t_rp_files")
+ _run(dsn, "CREATE ROLE t_rp_none NOSUPERUSER LOGIN")
+ _run(dsn, "CREATE ROLE t_rp_files NOSUPERUSER LOGIN")
+ _run(dsn, "GRANT USAGE ON SCHEMA pgcolumnar TO t_rp_none, t_rp_files")
+ _run(dsn, "GRANT pg_read_server_files TO t_rp_files")
+ r["gate"] = _refusal(dsn, f"SELECT count(*) FROM pgcolumnar.read_parquet('{scalar}') "
+ f"AS t({SCALAR_COLS})", GATE, " by the file gate",
+ settings=("SET ROLE t_rp_none",))
+ r["member"] = _value(dsn, f"SELECT count(*) FROM pgcolumnar.read_parquet('{scalar}') "
+ f"AS t({SCALAR_COLS})", settings=("SET ROLE t_rp_files",))
+
+ _up(dsn)
+ up, now = _value(dsn, "SELECT 'alive'"), _crashes(pgc_cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ r["alive"] = query_error(f"crash count unreadable: {before} / {now}")
+ elif up == "alive" and now == before:
+ r["alive"] = "alive"
+ else:
+ r["alive"] = f"no ([{up}], {now - before} backend crashes)"
+
+ _run(dsn, f"CREATE TABLE imp ({SCALAR_COLS}) USING pgcolumnar")
+ r["import"] = _value(dsn, f"SELECT pgcolumnar.import_parquet('imp', '{scalar}')")
+ r["import rp"] = _set_hash(dsn, rp_scalar)
+ r["import imp"] = _set_hash(dsn, "SELECT * FROM imp")
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute("DROP ROLE IF EXISTS t_rp_none")
+ a.execute("DROP ROLE IF EXISTS t_rp_files")
+
+
+def test_read_parquet_fixture_exports_every_scalar_row(rp, expect):
+ expect.text(rp["scalar export"], "5001", "scalar export rows")
+
+
+def test_read_parquet_returns_the_generated_scalar_rows(rp, expect):
+ expect.text(rp["scalar rp"], rp["scalar gen"], "scalar round-trip: read_parquet == the generated rows")
+
+
+def test_read_parquet_counts_every_scalar_row(rp, expect):
+ expect.text(rp["scalar count"], "5001", "scalar read_parquet row count")
+
+
+def test_a_projected_column_round_trips(rp, expect):
+ expect.text(rp["proj rp"], rp["proj gen"], "projected column round-trips")
+
+
+def test_read_parquet_fixture_exports_every_nested_row(rp, expect):
+ expect.text(rp["nested export"], "3000", "nested export rows")
+
+
+def test_read_parquet_returns_the_generated_nested_rows(rp, expect):
+ expect.text(rp["nested rp"], rp["nested gen"], "nested round-trip: read_parquet == the generated rows")
+
+
+def test_a_missing_column_definition_list_is_refused(rp, expect):
+ expect.text(rp["no list"], "42601 (no column list)", "premise: reject missing column definition list")
+
+
+def test_fewer_declared_columns_than_the_file_is_refused(rp, expect):
+ expect.text(rp["fewer"], "42804 by the leaf-count guard", "reject fewer columns than file")
+
+
+def test_an_incompatible_declared_type_is_refused(rp, expect):
+ expect.text(rp["type"], "42804 by the physical-type guard", "reject incompatible declared type")
+
+
+def test_a_file_that_is_not_parquet_is_refused(rp, expect):
+ expect.text(rp["bad"], "XX001 by the magic check", "reject non-parquet file")
+
+
+def test_read_parquet_keeps_its_own_file_gate(rp, expect):
+ expect.text(rp["gate"], "42501 by the file gate",
+ "a role without pg_read_server_files is refused by read_parquet's own gate")
+
+
+def test_a_pg_read_server_files_member_reads_every_row(rp, expect):
+ expect.text(rp["member"], "5001", "and a member of pg_read_server_files reads every row")
+
+
+def test_the_backend_is_still_up_after_the_read_parquet_refusals(rp, expect):
+ expect.text(rp["alive"], "alive", "backend still up after the refusals")
+
+
+def test_import_parquet_loads_every_row(rp, expect):
+ expect.text(rp["import"], "5001", "import parity rows")
+
+
+def test_read_parquet_agrees_with_import_parquet(rp, expect):
+ expect.text(rp["import rp"], rp["import imp"], "read_parquet == import_parquet")
diff --git a/test/pytest/test_native_reclaim_reconcile.py b/test/pytest/test_native_reclaim_reconcile.py
new file mode 100644
index 000000000..4962f7b9e
--- /dev/null
+++ b/test/pytest/test_native_reclaim_reconcile.py
@@ -0,0 +1,177 @@
+"""A reuse op drops a free_space row that overlaps a live group, and only that row
+(Phase F reconcile). The pytest twin of `test/native_reclaim_reconcile.sh`, every check
+by the shell's name (#1341).
+
+A crash in end-truncation's narrow window can leave a free_space row over a live group.
+PgColumnarReconcileFreeList, run at the start of compact_rewrite, compact and truncate,
+drops any free row overlapping a live row-group footprint. The suite injects such a row
+(freed_xid 1, so reusable) over the first live group before each of the three ops and
+asserts it is gone after, the data still equals a heap mirror, and the legitimate free
+ranges come through a plain compact (which does not reuse) unchanged: a reconcile that
+dropped every free row passed all the other checks.
+
+READINGS ARE VALUES: a failed query is a query_error(), so two failed reads never agree,
+and the row sets are compared as an md5 over the sorted rows.
+
+ON A PRIVATE DATABASE, built in the shell's order by a module fixture.
+"""
+import psycopg
+import pytest
+
+from pgc_vacuity import QUERY_ERROR, query_error
+
+DB = "pgc_native_reclaim_reconcile"
+HASH = "SELECT md5(coalesce(string_agg(id || ':' || v, ',' ORDER BY id), '')) FROM {}"
+
+
+def _message(exc):
+ diag = getattr(exc, "diag", None)
+ primary = diag.message_primary if diag is not None else None
+ return primary or (str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _value(dsn, sql):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "" if row is None or row[0] is None else str(row[0])
+
+
+def _run(dsn, sql):
+ """One statement in its own autocommit session (truncate refuses a transaction
+ block). -> None, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return None
+
+
+def _int(v):
+ return int(v) if isinstance(v, str) and v.isdigit() else v
+
+
+@pytest.fixture(scope="module")
+def rr(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ dsn = pgc_cluster.dsn(dbname=DB)
+ try:
+ for s in ("CREATE EXTENSION IF NOT EXISTS pgcolumnar",
+ f"ALTER DATABASE {DB} SET pgcolumnar.enable_end_truncation = on",
+ "CREATE TABLE h (id int, v text)",
+ "CREATE TABLE n (id int, v text) USING pgcolumnar",
+ "SELECT pgcolumnar.set_options('n', stripe_row_limit => 1000, chunk_group_row_limit => 1000)",
+ "INSERT INTO h SELECT g, md5(g::text) FROM generate_series(1, 8000) g",
+ "INSERT INTO n SELECT g, md5(g::text) FROM generate_series(1, 8000) g",
+ "DELETE FROM h WHERE id % 3 = 0",
+ "DELETE FROM n WHERE id % 3 = 0"):
+ _run(dsn, s)
+ r = {}
+ hn = lambda: _value(dsn, HASH.format("n"))
+ hh = lambda: _value(dsn, HASH.format("h"))
+ r["parity before"] = (hn(), hh())
+ sid = _value(dsn, "SELECT pgcolumnar.get_storage_id('n')")
+ first = ("SELECT {} FROM pgcolumnar.stats('n') ORDER BY fileoffset LIMIT 1")
+
+ def inject():
+ off, ln = _value(dsn, first.format("fileoffset")), _value(dsn, first.format("datalength"))
+ _run(dsn, f"INSERT INTO pgcolumnar.free_space VALUES ({sid}, {off}, {ln}, 1)")
+
+ stale = lambda: _value(dsn, "SELECT count(*) FROM pgcolumnar.free_space WHERE freed_xid = 1")
+ legit = lambda: _value(dsn, "SELECT count(*) || '|' || coalesce(sum(byte_length), 0) "
+ f"FROM pgcolumnar.free_space WHERE storage_id = {sid} AND freed_xid <> 1")
+
+ inject()
+ r["injected"] = stale()
+ _run(dsn, "SELECT pgcolumnar.compact_rewrite('n', 0.1)")
+ r["rewrite reconciled"] = stale()
+ r["rewrite intact"] = (hn(), hh())
+ r["count n"] = _value(dsn, "SELECT count(*) FROM n")
+ r["count h"] = _value(dsn, "SELECT count(*) FROM h")
+
+ inject()
+ r["legit before"] = legit()
+ _run(dsn, "SELECT pgcolumnar.compact('n')")
+ r["compact reconciled"] = stale()
+ r["legit after"] = legit()
+ r["compact intact"] = (hn(), hh())
+
+ inject()
+ _run(dsn, "SELECT pgcolumnar.truncate('n')")
+ r["truncate reconciled"] = stale()
+ r["truncate intact"] = (hn(), hh())
+
+ for cyc in (1, 2):
+ _run(dsn, f"DELETE FROM h WHERE id % 5 = {cyc}")
+ _run(dsn, f"DELETE FROM n WHERE id % 5 = {cyc}")
+ _run(dsn, "SELECT pgcolumnar.compact_rewrite('n', 0.0)")
+ r[f"cycle {cyc}"] = (hn(), hh())
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def _legit_present(v):
+ if not isinstance(v, str) or v.startswith(QUERY_ERROR) or v == "" or v.startswith("0|"):
+ return f"no ({v})"
+ return "yes"
+
+
+def test_rr_parity_before(rr, expect):
+ expect.text(rr["parity before"][0], rr["parity before"][1], "parity before")
+
+
+def test_rr_stale_row_injected(rr, expect):
+ expect.text(rr["injected"], "1", "stale overlapping free row injected")
+
+
+def test_rr_compact_rewrite_reconciled(rr, expect):
+ expect.text(rr["rewrite reconciled"], "0", "compact_rewrite reconciled the stale row")
+
+
+def test_rr_data_intact_after_rewrite(rr, expect):
+ expect.text(rr["rewrite intact"][0], rr["rewrite intact"][1], "data intact after reconcile + rewrite")
+
+
+def test_rr_row_count_intact(rr, expect):
+ expect.num(_int(rr["count n"]), _int(rr["count h"]), "row count intact")
+
+
+def test_rr_a_legitimate_free_range_exists(rr, expect):
+ expect.text(_legit_present(rr["legit before"]), "yes",
+ "compact_rewrite recorded the free ranges it left")
+
+
+def test_rr_plain_compact_reconciled(rr, expect):
+ expect.text(rr["compact reconciled"], "0", "plain compact reconciled the stale row")
+
+
+def test_rr_plain_compact_kept_the_legitimate_ranges(rr, expect):
+ expect.text(rr["legit after"], rr["legit before"],
+ "plain compact kept the legitimate free ranges, which overlap no live group")
+
+
+def test_rr_data_intact_after_compact(rr, expect):
+ expect.text(rr["compact intact"][0], rr["compact intact"][1], "data intact after compact reconcile")
+
+
+def test_rr_truncate_reconciled(rr, expect):
+ expect.text(rr["truncate reconciled"], "0", "truncate reconciled the stale row")
+
+
+def test_rr_data_intact_after_truncate(rr, expect):
+ expect.text(rr["truncate intact"][0], rr["truncate intact"][1], "data intact after truncate reconcile")
+
+
+def test_rr_parity_after_reuse_cycle_1(rr, expect):
+ expect.text(rr["cycle 1"][0], rr["cycle 1"][1], "parity after reuse cycle 1")
+
+
+def test_rr_parity_after_reuse_cycle_2(rr, expect):
+ expect.text(rr["cycle 2"][0], rr["cycle 2"][1], "parity after reuse cycle 2")
diff --git a/test/pytest/test_native_rewrite.py b/test/pytest/test_native_rewrite.py
new file mode 100644
index 000000000..71f2e0f71
--- /dev/null
+++ b/test/pytest/test_native_rewrite.py
@@ -0,0 +1,298 @@
+"""Online rewrite of partially-deleted groups, and what vacuum's full rewrite must keep.
+
+The pytest twin of `test/native_rewrite.sh`, every check by the shell's name.
+
+pgcolumnar.compact_rewrite() rewrites groups whose deleted fraction exceeds a
+threshold into fresh groups with fresh row numbers, online under
+ShareUpdateExclusiveLock. Asserted: results match a heap mirror written from the
+generator; the dead rows' space is reclaimed; the threshold holds from both sides (a
+rewrite with nothing deleted, and a group deleted below the threshold, rewrite no
+group); index scans return each live row once and point lookups are right; the
+duplicate is refused by n_id_uq itself; the lock is ShareUpdateExclusiveLock, never
+AccessExclusiveLock; group numbers are never reused; and pgcolumnar.vacuum rewrites
+into fresh storage with every row reachable, through the index as well.
+
+A FAILED READING IS NEVER AGREEMENT. Counts are compared as numbers, so two failed
+reads no longer agree, and "the duplicate was refused" wants 23505 and n_id_uq's own
+message: any failure used to count, so with the server stopped, four checks passed.
+
+THE INDEX AFTER VACUUM IS READ OVER A NARROW RANGE. A stale index entry still fetches
+a row, just the wrong one, and over the whole table the stale entries land on every
+live row once, so a count and a hash through the index both matched with the rebuild
+skipped. A range of eleven ids is what tells.
+
+ONE CHECK PER TEST (#1339). One module fixture takes every reading in the shell's
+order, through `_try`, and each test asserts one reading.
+"""
+
+import hashlib
+
+import psycopg
+import pytest
+
+from pgc_vacuity import QUERY_ERROR, query_error
+
+SCHEMA = "pgc_native_rewrite"
+GEN = ("SELECT g, (g % 1000) AS v, 'p' || (g % 100) AS payload "
+ "FROM generate_series(1, 8192) g")
+EXTRA = "SELECT g + 1000000, g % 1000, 'p' || (g % 100) FROM generate_series(1, 2000) g"
+FORCE_INDEX = ("SET enable_seqscan = off", "SET enable_bitmapscan = off",
+ "SET pgcolumnar.enable_custom_scan = off")
+DUP = 'duplicate key value violates unique constraint "n_id_uq"'
+
+
+def _message(exc):
+ if exc.diag.message_primary:
+ return exc.diag.message_primary
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _try(fn, *args, **kw):
+ """A reading, or the failure as a query_error(), so a failed step reddens its own
+ check rather than erroring the fixture and every check with it (#1522)."""
+ try:
+ return fn(*args, **kw)
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+
+
+def _one(conn, sql):
+ """-> the first column of the last row as text ('' for NULL), as the shell's q."""
+ rows = conn.execute(sql).fetchall()
+ if not rows:
+ return query_error(f"no row from {sql}")
+ v = rows[-1][0]
+ return "" if v is None else str(v)
+
+
+def _read(conn, sql):
+ return _try(_one, conn, sql)
+
+
+def _set_hash(conn, sql):
+ """-> an order-free hash of the rows, or a failed query: never equal to another failure."""
+ def run():
+ rows = sorted(repr(r) for r in conn.execute(sql).fetchall())
+ return f"{len(rows)}:{hashlib.md5(chr(10).join(rows).encode()).hexdigest()}"
+ return _try(run)
+
+
+def _forced(dsn, sql):
+ """-> sql's first column read with the index forced, on its own session, as the
+ shell's idxcount/idxids."""
+ def run():
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"SET search_path = {SCHEMA}, public")
+ for s in FORCE_INDEX:
+ c.execute(s)
+ return _one(c, sql)
+ return _try(run)
+
+
+def _dupfails(dsn):
+ """-> "yes" only when the duplicate is refused by n_id_uq (23505 and its message)."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"SET search_path = {SCHEMA}, public")
+ c.execute("INSERT INTO n VALUES (4097, 0, 'x')")
+ except psycopg.Error as exc:
+ if exc.sqlstate == "23505" and DUP in str(exc):
+ return "yes"
+ return f"no ({exc.sqlstate} {_message(exc)})"
+ return "no (the duplicate was inserted)"
+
+
+def _locks(dsn):
+ """-> "SUE|AE": the relation locks compact_rewrite holds on n inside its transaction."""
+ def run():
+ with psycopg.connect(dsn) as c:
+ c.execute(f"SET search_path = {SCHEMA}, public")
+ c.execute("SELECT pgcolumnar.compact_rewrite('n', 0.0)")
+ v = _one(c, "SELECT count(*) FILTER (WHERE mode='ShareUpdateExclusiveLock')::text || '|' || "
+ "count(*) FILTER (WHERE mode='AccessExclusiveLock')::text "
+ "FROM pg_locks WHERE relation='n'::regclass AND locktype='relation'")
+ c.rollback()
+ return v
+ return _try(run)
+
+
+def _int(v):
+ return int(v) if isinstance(v, str) and v.lstrip("-").isdigit() else v
+
+
+def _gt_zero(v):
+ v = _int(v)
+ return "yes" if isinstance(v, int) and v > 0 else "no"
+
+
+@pytest.fixture(scope="module")
+def nr(pgc_cluster):
+ dsn = pgc_cluster.dsn()
+ conn = psycopg.connect(dsn, autocommit=True)
+ r = {}
+ try:
+ _try(conn.execute, f"DROP SCHEMA IF EXISTS {SCHEMA} CASCADE")
+ _try(conn.execute, f"CREATE SCHEMA {SCHEMA}")
+ _try(conn.execute, f"SET search_path = {SCHEMA}, public")
+ for sql in ("CREATE TABLE h (id int, v int, payload text)",
+ "CREATE TABLE n (id int, v int, payload text) USING pgcolumnar",
+ "SELECT pgcolumnar.set_options('n', stripe_row_limit => 1024, chunk_group_row_limit => 1024)",
+ f"INSERT INTO h {GEN}", f"INSERT INTO n {GEN}",
+ "CREATE UNIQUE INDEX n_id_uq ON n (id)", "CREATE INDEX n_v ON n (v)",
+ "DELETE FROM h WHERE id % 3 = 0", "DELETE FROM n WHERE id % 3 = 0"):
+ _try(conn.execute, sql)
+
+ def deletedrows():
+ return _read(conn, "SELECT COALESCE(sum(deletedrows), 0) FROM pgcolumnar.stats('n'::regclass)")
+
+ def counts():
+ return (_read(conn, "SELECT count(*) FROM n"), _read(conn, "SELECT count(*) FROM h"))
+
+ def parity():
+ return (_set_hash(conn, "SELECT id, v, payload FROM n ORDER BY id"),
+ _set_hash(conn, "SELECT id, v, payload FROM h ORDER BY id"))
+
+ r["count after delete"] = counts()
+ r["deleted rows"] = _gt_zero(deletedrows())
+ r["parity after delete"] = parity()
+
+ r["rewrote"] = _gt_zero(_read(conn, "SELECT pgcolumnar.compact_rewrite('n', 0.2)"))
+ r["reclaimed"] = deletedrows()
+ r["parity after rewrite"] = parity()
+ r["count after rewrite"] = counts()
+
+ r["second rewrite"] = _read(conn, "SELECT pgcolumnar.compact_rewrite('n', 0.2)")
+ _try(conn.execute, "DELETE FROM h WHERE id = 2")
+ _try(conn.execute, "DELETE FROM n WHERE id = 2")
+ r["under threshold"] = _read(conn, "SELECT pgcolumnar.compact_rewrite('n', 0.2)")
+
+ r["index count"] = (_forced(dsn, "SELECT count(*) FROM n WHERE id BETWEEN 1 AND 8192"),
+ _read(conn, "SELECT count(*) FROM h"))
+ r["live id"] = _read(conn, "SELECT count(*) FROM n WHERE id = 4097")
+ r["deleted id"] = _read(conn, "SELECT count(*) FROM n WHERE id = 4098")
+ r["dup"] = _dupfails(dsn)
+
+ locks = _locks(dsn)
+ if isinstance(locks, str) and "|" in locks and not locks.startswith(QUERY_ERROR):
+ r["sue"], r["ae"] = locks.split("|", 1)
+ else:
+ r["sue"] = r["ae"] = locks
+
+ sid_q = "pgcolumnar.get_storage_id('n')"
+ before_max = _read(conn, f"SELECT coalesce(max(group_number), -1) FROM pgcolumnar.row_group "
+ f"WHERE storage_id = {sid_q}")
+ _try(conn.execute, "SELECT pgcolumnar.compact_rewrite('n', 0.1)")
+ _try(conn.execute, f"INSERT INTO n {EXTRA}")
+ _try(conn.execute, f"INSERT INTO h {EXTRA}")
+ bm = _int(before_max)
+ after_min_new = _read(conn, f"SELECT coalesce(min(group_number), -1) FROM pgcolumnar.row_group "
+ f"WHERE storage_id = {sid_q} AND group_number > {bm if isinstance(bm, int) else -1}")
+ r["no reuse"] = _read(conn, f"SELECT (count(*) = count(DISTINCT group_number))::text FROM pgcolumnar.row_group "
+ f"WHERE storage_id = {sid_q}")
+ r["no reuse"] = {"true": "t", "false": "f"}.get(r["no reuse"], r["no reuse"])
+ am = _int(after_min_new)
+ r["past earlier"] = "yes" if isinstance(am, int) and isinstance(bm, int) and am > bm else "no"
+
+ sid_before = _read(conn, f"SELECT {sid_q}")
+ _try(conn.execute, "SELECT pgcolumnar.vacuum('n')")
+ sid_after = _read(conn, f"SELECT {sid_q}")
+ r["fresh storage"] = ("yes" if not str(sid_before).startswith(QUERY_ERROR)
+ and not str(sid_after).startswith(QUERY_ERROR) and sid_before != sid_after else "no")
+ r["count after vacuum"] = counts()
+ r["index range after vacuum"] = (
+ _forced(dsn, "SELECT string_agg(id::text, ',' ORDER BY id) FROM n WHERE id BETWEEN 4090 AND 4100"),
+ _read(conn, "SELECT string_agg(id::text, ',' ORDER BY id) FROM h WHERE id BETWEEN 4090 AND 4100"))
+ yield r
+ finally:
+ try:
+ conn.execute(f"DROP SCHEMA IF EXISTS {SCHEMA} CASCADE")
+ except psycopg.Error:
+ pass
+ conn.close()
+
+
+def test_row_count_after_delete(nr, expect):
+ got, want = nr["count after delete"]
+ expect.num(_int(got), _int(want), "row count after delete")
+
+
+def test_there_are_deleted_rows_to_reclaim(nr, expect):
+ expect.text(nr["deleted rows"], "yes", "there are deleted rows to reclaim")
+
+
+def test_parity_after_delete(nr, expect):
+ got, want = nr["parity after delete"]
+ expect.text(got, want, "parity after delete")
+
+
+def test_rewrote_some_groups(nr, expect):
+ expect.text(nr["rewrote"], "yes", "rewrote some groups")
+
+
+def test_dead_row_space_reclaimed(nr, expect):
+ expect.text(nr["reclaimed"], "0", "dead-row space reclaimed")
+
+
+def test_parity_after_rewrite(nr, expect):
+ got, want = nr["parity after rewrite"]
+ expect.text(got, want, "parity after rewrite")
+
+
+def test_row_count_after_rewrite(nr, expect):
+ got, want = nr["count after rewrite"]
+ expect.num(_int(got), _int(want), "row count after rewrite")
+
+
+def test_a_second_rewrite_with_nothing_deleted_rewrites_no_group(nr, expect):
+ expect.num(_int(nr["second rewrite"]), 0, "a second rewrite with nothing deleted rewrites no group")
+
+
+def test_a_group_deleted_below_the_threshold_is_not_rewritten(nr, expect):
+ expect.num(_int(nr["under threshold"]), 0, "a group deleted below the threshold is not rewritten")
+
+
+def test_index_scan_returns_each_live_row_once(nr, expect):
+ got, want = nr["index count"]
+ expect.num(_int(got), _int(want), "index scan returns each live row once")
+
+
+def test_live_id_present_via_index(nr, expect):
+ expect.text(nr["live id"], "1", "live id present via index")
+
+
+def test_deleted_id_absent_via_index(nr, expect):
+ expect.text(nr["deleted id"], "0", "deleted id absent via index")
+
+
+def test_unique_constraint_still_enforced(nr, expect):
+ expect.text(nr["dup"], "yes", "unique constraint still enforced")
+
+
+def test_compact_rewrite_holds_share_update_exclusive_lock(nr, expect):
+ expect.text(nr["sue"], "1", "compact_rewrite holds ShareUpdateExclusiveLock")
+
+
+def test_compact_rewrite_holds_no_access_exclusive_lock(nr, expect):
+ expect.text(nr["ae"], "0", "compact_rewrite holds no AccessExclusiveLock")
+
+
+def test_a_rewrite_plus_later_writes_reuse_no_group_number(nr, expect):
+ expect.text(nr["no reuse"], "t", "a rewrite plus later writes reuse no group number")
+
+
+def test_group_numbers_issued_after_a_rewrite_are_past_every_earlier_one(nr, expect):
+ expect.text(nr["past earlier"], "yes", "group numbers issued after a rewrite are past every earlier one")
+
+
+def test_vacuum_rewrites_into_a_fresh_storage_id(nr, expect):
+ expect.text(nr["fresh storage"], "yes", "vacuum rewrites into a fresh storage id")
+
+
+def test_every_live_row_still_reads_back_after_rewrite_and_vacuum(nr, expect):
+ got, want = nr["count after vacuum"]
+ expect.num(_int(got), _int(want), "every live row still reads back after rewrite and vacuum")
+
+
+def test_an_index_range_read_after_vacuum_returns_the_rows_asked_for(nr, expect):
+ got, want = nr["index range after vacuum"]
+ expect.text(got, want, "an index range read after vacuum returns the rows asked for")
diff --git a/test/pytest/test_native_rewrite_conc.py b/test/pytest/test_native_rewrite_conc.py
new file mode 100644
index 000000000..5695603bc
--- /dev/null
+++ b/test/pytest/test_native_rewrite_conc.py
@@ -0,0 +1,235 @@
+"""The online rewrite runs alongside writers without losing or resurrecting a row, and
+without blocking them (Phase F3b). The pytest twin of `test/native_rewrite_conc.sh`,
+every check by the shell's name (#1341).
+
+Six workers insert, delete and update disjoint id ranges of a columnar table and of its
+heap oracle, while a compactor loop runs compact_rewrite (online rewrite of partially
+deleted groups) and compact (retire fully-dead groups). A delete or update that races a
+rewrite of its group is refused with a serialization failure and retried, so the final
+state is deterministic and the columnar table must equal the heap oracle. Asserted: every
+worker finished, the compactor moved rows while they wrote (so the race was run, not
+assumed), the two tables agree by whole row, by count and sum, and over a range, and a
+compact_rewrite held mid-verb with its lock taken lets a reader and a writer, each with a
+3 s lock_timeout, finish while it still holds the lock -- a rewrite that took a stronger
+lock would otherwise pass everything above, since the workers would simply wait for it.
+
+ON A PRIVATE DATABASE, built in the shell's order by a module fixture.
+"""
+import threading
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_rewrite_conc"
+WORKERS = 6
+PER = 3000
+COLS = "id int, cat int, v bigint, txt text"
+
+
+def _err(exc):
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+class _Db:
+ def __init__(self, dsn):
+ self.dsn = dsn
+
+ def run(self, sql, settings=()):
+ try:
+ with psycopg.connect(self.dsn, autocommit=True) as conn:
+ for s in settings:
+ conn.execute(s)
+ conn.execute(sql.encode()) # simple protocol, as psql -c
+ except psycopg.Error as exc:
+ return _err(exc)
+ return ""
+
+ def q(self, sql):
+ try:
+ with psycopg.connect(self.dsn, autocommit=True) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return _err(exc)
+ if row is None or row[0] is None:
+ return ""
+ return str(row[0])
+
+ def n(self, sql):
+ v = self.q(sql)
+ return int(v) if v.lstrip("-").isdigit() else v
+
+ def set_hash(self, sql):
+ return self.q("SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ({sql}) _row) _s")
+
+
+def _retry(d, sql, errors):
+ """Run one autocommit statement, retrying on failure as the shell's runsql does (each
+ failed attempt rolls back fully, so a retry applies exactly once). -> True when it
+ succeeded within 200 attempts."""
+ for _ in range(200):
+ e = d.run(sql)
+ if e == "":
+ return True
+ errors.append(e)
+ return False
+
+
+@pytest.fixture(scope="module")
+def rc(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ d = _Db(pgc_cluster.dsn(dbname=DB))
+ d.run("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ d.run(f"CREATE TABLE t_heap ({COLS}) USING heap")
+ d.run(f"CREATE TABLE t_col ({COLS}) USING pgcolumnar")
+ d.run("SELECT pgcolumnar.set_options('t_col', chunk_group_row_limit => 500, stripe_row_limit => 1000)")
+
+ stop = threading.Event()
+ moved = []
+
+ def compactor():
+ while not stop.is_set():
+ v = d.n("SELECT pgcolumnar.compact_rewrite('t_col', 0.0)")
+ if isinstance(v, int):
+ moved.append(v)
+ d.run("SELECT pgcolumnar.compact('t_col')")
+
+ results = [None] * WORKERS
+ errs = [[] for _ in range(WORKERS)]
+
+ def worker(w):
+ lo, hi = w * PER + 1, (w + 1) * PER
+ steps = [
+ f"INSERT INTO t_heap SELECT g, g%5, g*3, md5(g::text) FROM generate_series({lo},{hi}) g",
+ f"INSERT INTO t_col SELECT g, g%5, g*3, md5(g::text) FROM generate_series({lo},{hi}) g",
+ f"DELETE FROM t_heap WHERE id BETWEEN {lo} AND {hi} AND id % 7 = 0",
+ f"DELETE FROM t_col WHERE id BETWEEN {lo} AND {hi} AND id % 7 = 0",
+ f"UPDATE t_heap SET v = v + 1 WHERE id BETWEEN {lo} AND {hi} AND id % 5 = 0",
+ f"UPDATE t_col SET v = v + 1 WHERE id BETWEEN {lo} AND {hi} AND id % 5 = 0",
+ ]
+ results[w] = all(_retry(d, s, errs[w]) for s in steps)
+
+ comp = threading.Thread(target=compactor)
+ comp.start()
+ ws = [threading.Thread(target=worker, args=(w,)) for w in range(WORKERS)]
+ for t in ws:
+ t.start()
+ for t in ws:
+ t.join()
+ stop.set()
+ comp.join()
+
+ r = {
+ "fail": "0" if all(results) else "1",
+ "moved": sum(moved),
+ "retries": sum(1 for e in errs for x in e if "compacted concurrently" in x),
+ }
+ # a final settling pass, then the columnar table must equal the heap oracle
+ d.run("SELECT pgcolumnar.compact_rewrite('t_col', 0.0)")
+ d.run("SELECT pgcolumnar.compact('t_col')")
+ for label, tmpl in (("whole-row", "SELECT * FROM %T"),
+ ("count/sum", "SELECT count(*), sum(v), count(distinct cat) FROM %T"),
+ ("range", "SELECT count(*) FROM %T WHERE id BETWEEN 4000 AND 12000")):
+ hq = d.set_hash(tmpl.replace("%T", "t_heap"))
+ hc = d.set_hash(tmpl.replace("%T", "t_col"))
+ r[label] = (hc or "COL_ERROR", hq or "HEAP_ERROR")
+ r["count"] = d.n("SELECT count(*) FROM t_col")
+
+ # the online rewrite does not block readers or writers
+ before = d.n("SELECT count(*) FROM t_col")
+ holder = {}
+
+ def hold():
+ holder["result"] = d.run("SELECT pgcolumnar.compact_rewrite('t_col', 0.0)",
+ settings=("SET pgcolumnar.maintenance_hold_ms = 12000",))
+
+ def held():
+ return d.n("SELECT count(*) FROM pg_locks l JOIN pg_stat_activity a USING (pid) "
+ "WHERE l.relation = 't_col'::regclass AND l.granted "
+ "AND a.query LIKE '%compact_rewrite%' AND a.pid <> pg_backend_pid()")
+
+ ht = threading.Thread(target=hold)
+ ht.start()
+ for _ in range(50):
+ if held() == 1:
+ break
+ time.sleep(0.2)
+ r["held"] = held()
+ try:
+ with psycopg.connect(d.dsn, autocommit=True) as conn:
+ with conn.transaction():
+ conn.execute("SET LOCAL lock_timeout = '3s'")
+ r["reader"] = conn.execute("SELECT count(*) FROM t_col").fetchone()[0]
+ except psycopg.Error as exc:
+ r["reader"] = _err(exc)
+ try:
+ with psycopg.connect(d.dsn, autocommit=True) as conn:
+ with conn.transaction():
+ conn.execute("SET LOCAL lock_timeout = '3s'")
+ conn.execute("INSERT INTO t_col VALUES (-1, 0, 0, 'probe')")
+ r["writer"] = "inserted"
+ except psycopg.Error:
+ r["writer"] = "refused"
+ r["still held"] = held()
+ ht.join()
+ r["holder"] = "rc=0" if holder.get("result") == "" else f"rc=1 ({holder.get('result')})"
+ r["before"] = before
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_all_rewrite_workers_succeeded(rc, expect):
+ expect.text(rc["fail"], "0", "all workers succeeded (retried past serialization failures)")
+
+
+def test_the_compactor_rewrote_groups_while_the_workers_were_writing(rc, expect):
+ m = rc["moved"]
+ expect.text("yes" if isinstance(m, int) and m > 0 else f"no (moved [{m}])", "yes",
+ "the compactor rewrote groups while the workers were writing")
+
+
+def test_conc_rewrite_whole_row(rc, expect):
+ expect.text(rc["whole-row"][0], rc["whole-row"][1], "conc-rewrite whole-row")
+
+
+def test_conc_rewrite_count_sum(rc, expect):
+ expect.text(rc["count/sum"][0], rc["count/sum"][1], "conc-rewrite count/sum")
+
+
+def test_conc_rewrite_range(rc, expect):
+ expect.text(rc["range"][0], rc["range"][1], "conc-rewrite range")
+
+
+def test_row_count_sane(rc, expect):
+ c = rc["count"]
+ expect.text("ok" if isinstance(c, int) and c > 0 else f"no ({c})", "ok", "row count sane")
+
+
+def test_a_compact_rewrite_holds_its_lock_mid_verb(rc, expect):
+ expect.text(str(rc["held"]), "1", "a compact_rewrite holds its lock on t_col mid-verb")
+
+
+def test_a_reader_finishes_while_the_rewrite_holds_its_lock(rc, expect):
+ expect.num(rc["reader"], rc["before"],
+ "a reader with a 3 s lock_timeout finishes while the rewrite holds its lock")
+
+
+def test_a_writer_inserts_while_the_rewrite_holds_its_lock(rc, expect):
+ expect.text(rc["writer"], "inserted",
+ "a writer with a 3 s lock_timeout inserts while the rewrite holds its lock")
+
+
+def test_the_rewrite_still_held_its_lock_when_both_had_finished(rc, expect):
+ expect.text(str(rc["still held"]), "1", "and the rewrite still held its lock when both had finished")
+
+
+def test_the_held_rewrite_itself_completed(rc, expect):
+ expect.text(rc["holder"], "rc=0", "the held rewrite itself completed")
diff --git a/test/pytest/test_native_sort_by.py b/test/pytest/test_native_sort_by.py
new file mode 100644
index 000000000..c8b4bf033
--- /dev/null
+++ b/test/pytest/test_native_sort_by.py
@@ -0,0 +1,336 @@
+"""The declarative sort_by clustering key (#288). The pytest twin of
+`test/native_sort_by.sh`, every check by the shell's name (#1341).
+
+set_options(t, sort_by => ARRAY[...]) declares a persisted physical sort key, and
+vacuum_sorted(t) with no columns applies it. Asserted: heap parity before and after
+the sort, that sorting a text key enables chunk-group skipping, that the rows really
+lie in (host, ts) order afterwards, that explicit columns lay them out the same way,
+the key's round-trip and reset, clean refusals for no key, a bad column, a dropped
+declared column and (PG18+) a virtual generated column, and that the backend survives
+every refusal.
+
+THE PHYSICAL ORDER IS READ, not inferred: the rows are read in scan order and every
+adjacent pair is compared on (host, ts). Group skipping on host was the only evidence
+before, and a sort that kept hosts together in the wrong order skipped just as well.
+
+A REFUSAL READS ITS OWN GUARD: the SQLSTATE and a phrase of the guard's message, as
+" ". A backend that crashed in place of the refusal used to count as
+refused. Survival reads the server log's count of backends killed by a signal, and
+each reading after a refusal first waits for the server.
+
+ON A PRIVATE DATABASE, built in the shell's order by a module fixture.
+"""
+import re
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_sort_by"
+GEN = ("SELECT g AS id, 'h' || lpad((g % 50)::text, 3, '0') AS host, "
+ "('2024-01-01'::timestamptz + ((g % 1440) * interval '1 minute')) AS ts, "
+ "'p' || (g % 7) AS payload FROM generate_series(1, 40960) g")
+EQ = "host = 'h007'"
+INVERSIONS = ("SELECT count(*) FROM (SELECT host, ts, lag(host) OVER () AS ph, lag(ts) OVER () AS pt "
+ "FROM (SELECT host, ts FROM n OFFSET 0) s) x "
+ "WHERE ph IS NOT NULL AND (ph, pt) > (host, ts)")
+LAYOUT = "SELECT md5(string_agg(host || '|' || ts::text, ',')) FROM (SELECT host, ts FROM n OFFSET 0) s"
+SERIAL = "SET max_parallel_workers_per_gather = 0"
+
+
+def _message(exc):
+ diag = getattr(exc, "diag", None)
+ primary = diag.message_primary if diag is not None else None
+ return primary or (str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _run(dsn, sql):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ return _message(exc)
+ return ""
+
+
+def _value(dsn, sql):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(SERIAL)
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return query_error(f"no value from {sql}") if row is None or row[0] is None else str(row[0])
+
+
+def _await_up(dsn):
+ """Wait, at most 60 s, until the server answers, as the shell's await_up."""
+ for _ in range(120):
+ if _value(dsn, "SELECT 1") == "1":
+ return
+ time.sleep(0.5)
+
+
+def _set_hash(dsn, query):
+ """-> the shell's pgc_set_hash: md5 of the sorted row texts, EMPTY for no rows."""
+ return _value(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ( {query} ) _row) _s")
+
+
+def _plan(dsn, query, analyze):
+ opts = "ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF" if analyze else "COSTS OFF"
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(SERIAL)
+ rows = conn.execute(f"EXPLAIN ({opts}) {query}").fetchall()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "\n".join(r[0] for r in rows)
+
+
+def _skipped(dsn, query):
+ """-> chunk groups removed by the zone maps as text, or a value no number equals."""
+ plan = _plan(dsn, query, analyze=True)
+ m = re.search(r"Columnar Chunk Groups Removed by Filter: (\d+)", plan)
+ return m.group(1) if m else ""
+
+
+def _refusal(dsn, sql, phrase):
+ """-> " " when sql raised with phrase in its message,
+ " other: " for a different refusal, "no error" when it ran,
+ or "no ()" when the connection was lost."""
+ _await_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return f"no ({_message(exc)})"
+ if phrase in str(exc):
+ return f"{exc.sqlstate} {phrase}"
+ return f"{exc.sqlstate} other: {_message(exc)}"
+ return "no error"
+
+
+def _ran_ok(dsn, sql):
+ _await_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ return f"no ({_message(exc)})"
+ return "ok"
+
+
+def _crashes(cluster):
+ try:
+ return (cluster.datadir / "server.log").read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, cluster, before):
+ _await_up(dsn)
+ up, now = _value(dsn, "SELECT 1"), _crashes(cluster)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ return "1" if up == "1" and now == before else f"no ([{up}], {now - before} backend crashes)"
+
+
+def _major(dsn):
+ v = _value(dsn, "SELECT current_setting('server_version_num')::int / 10000")
+ return int(v) if v.isdigit() else v
+
+
+@pytest.fixture(scope="module")
+def nsb(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ r = {}
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ _run(dsn, "CREATE TABLE h (id int, host text, ts timestamptz, payload text)")
+ _run(dsn, "CREATE TABLE n (id int, host text, ts timestamptz, payload text) USING pgcolumnar")
+ _run(dsn, "SELECT pgcolumnar.set_options('n', stripe_row_limit => 2048, chunk_group_row_limit => 1024)")
+ _run(dsn, f"INSERT INTO h {GEN}")
+ _run(dsn, f"INSERT INTO n {GEN}")
+
+ r["rows"] = _value(dsn, "SELECT count(*) FROM n")
+ plan = _plan(dsn, f"SELECT id FROM n WHERE {EQ}", analyze=False)
+ r["scan"] = plan if plan.startswith("QUERY_ERROR") else (
+ "yes" if "Columnar Projected Columns" in plan else "no")
+ r["eq pre"] = (_set_hash(dsn, f"SELECT id, host, ts, payload FROM n WHERE {EQ}"),
+ _set_hash(dsn, f"SELECT id, host, ts, payload FROM h WHERE {EQ}"))
+ inv = _value(dsn, INVERSIONS)
+ r["unsorted"] = "yes" if inv.isdigit() and int(inv) > 0 else f"no [{inv}]"
+ before = _skipped(dsn, f"SELECT id FROM n WHERE {EQ}")
+
+ _run(dsn, "SELECT pgcolumnar.set_options('n', sort_by => ARRAY['host','ts'])")
+ r["roundtrip"] = _value(dsn, "SELECT sort_by::text FROM pgcolumnar.options WHERE regclass = 'n'::regclass")
+ _run(dsn, "SELECT pgcolumnar.vacuum_sorted('n')")
+ r["rows after"] = _value(dsn, "SELECT count(*) FROM n")
+ r["full"] = (_set_hash(dsn, "SELECT id, host, ts, payload FROM n ORDER BY id"),
+ _set_hash(dsn, "SELECT id, host, ts, payload FROM h ORDER BY id"))
+ r["eq post"] = (_set_hash(dsn, f"SELECT id, host, ts, payload FROM n WHERE {EQ}"),
+ _set_hash(dsn, f"SELECT id, host, ts, payload FROM h WHERE {EQ}"))
+ after = _skipped(dsn, f"SELECT id FROM n WHERE {EQ}")
+ if not (before.isdigit() and after.isdigit()):
+ r["skipping"] = f"no reading (before [{before}], after [{after}])"
+ else:
+ r["skipping"] = "yes" if int(after) > int(before) and int(after) > 0 else "no"
+ r["inversions"] = _value(dsn, INVERSIONS)
+ declared = _value(dsn, LAYOUT)
+
+ _run(dsn, "SELECT pgcolumnar.vacuum_sorted('n', 'host', 'ts')")
+ r["explicit"] = (_set_hash(dsn, "SELECT id, host, ts, payload FROM n ORDER BY id"),
+ _set_hash(dsn, "SELECT id, host, ts, payload FROM h ORDER BY id"))
+ r["explicit layout"] = (_value(dsn, LAYOUT), declared)
+
+ _run(dsn, "SELECT pgcolumnar.reset_options('n', sort_by => true)")
+ r["cleared"] = _value(dsn, "SELECT (sort_by IS NULL)::text FROM pgcolumnar.options "
+ "WHERE regclass = 'n'::regclass")
+ before_crash = _crashes(pgc_cluster)
+ r["no key"] = _refusal(dsn, "SELECT pgcolumnar.vacuum_sorted('n')", "has no declared sort_by key")
+ r["bad column"] = _refusal(dsn, "SELECT pgcolumnar.set_options('n', sort_by => ARRAY['nope'])",
+ "cannot be used in sort_by")
+ _await_up(dsn)
+ _run(dsn, "SELECT pgcolumnar.set_options('n', sort_by => ARRAY['payload'])")
+ _run(dsn, "ALTER TABLE n DROP COLUMN payload")
+ r["dropped"] = _refusal(dsn, "SELECT pgcolumnar.vacuum_sorted('n')",
+ 'declared sort_by column "payload" does not exist')
+ _await_up(dsn)
+ _run(dsn, "SELECT pgcolumnar.reset_options('n', sort_by => true)")
+
+ _run(dsn, "CREATE TABLE e (id int, host text) USING pgcolumnar")
+ r["empty"] = _ran_ok(dsn, "SELECT pgcolumnar.vacuum_sorted('e', 'host')")
+ _run(dsn, "INSERT INTO e VALUES (1, 'x')")
+ r["single"] = _ran_ok(dsn, "SELECT pgcolumnar.vacuum_sorted('e', 'host')")
+ r["single rows"] = _value(dsn, "SELECT count(*) FROM e")
+
+ r["major"] = _major(dsn)
+ if isinstance(r["major"], int) and r["major"] >= 18:
+ _await_up(dsn)
+ _run(dsn, "CREATE TABLE v (id int, k int, kv int GENERATED ALWAYS AS (k * 2) VIRTUAL) USING pgcolumnar")
+ _run(dsn, "INSERT INTO v (id, k) SELECT g, g % 10 FROM generate_series(1, 100) g")
+ r["virtual set"] = _refusal(dsn, "SELECT pgcolumnar.set_options('v', sort_by => ARRAY['kv'])",
+ "cannot be used in sort_by")
+ r["virtual sort"] = _refusal(dsn, "SELECT pgcolumnar.vacuum_sorted('v', 'kv')",
+ "is a virtual generated column and cannot be a sort key")
+ r["survived"] = _survived(dsn, pgc_cluster, before_crash)
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_nsb_row_count(nsb, expect):
+ expect.text(nsb["rows"], "40960", "row count")
+
+
+def test_nsb_the_plan_under_test_is_a_columnar_custom_scan(nsb, expect):
+ expect.text(nsb["scan"], "yes", "the plan under test is a columnar custom scan")
+
+
+def test_nsb_equality_parity_pre_sort(nsb, expect):
+ expect.text(nsb["eq pre"][0], nsb["eq pre"][1], "equality parity (pre-sort)")
+
+
+def test_nsb_the_scan_order_starts_unsorted(nsb, expect):
+ expect.text(nsb["unsorted"], "yes", "premise: before sorting, the scan order does not follow (host, ts)")
+
+
+def test_nsb_declared_sort_by_round_trips(nsb, expect):
+ expect.text(nsb["roundtrip"], "{host,ts}", "declared sort_by round-trips")
+
+
+def test_nsb_row_count_after_sort(nsb, expect):
+ expect.text(nsb["rows after"], "40960", "row count after sort")
+
+
+def test_nsb_full_table_parity_after_sort(nsb, expect):
+ expect.text(nsb["full"][0], nsb["full"][1], "full-table parity after sort")
+
+
+def test_nsb_equality_parity_post_sort(nsb, expect):
+ expect.text(nsb["eq post"][0], nsb["eq post"][1], "equality parity (post-sort)")
+
+
+def test_nsb_sorting_a_text_key_enables_group_skipping(nsb, expect):
+ expect.text(nsb["skipping"], "yes", "sorting a text key enables group skipping")
+
+
+def test_nsb_the_scan_order_follows_the_declared_key(nsb, expect):
+ inv = nsb["inversions"]
+ expect.num(int(inv) if inv.isdigit() else inv, 0, "the scan order follows the declared key (host, ts)")
+
+
+def test_nsb_explicit_column_sort_parity(nsb, expect):
+ expect.text(nsb["explicit"][0], nsb["explicit"][1], "explicit-column sort parity")
+
+
+def test_nsb_explicit_columns_lay_the_rows_out_as_the_declared_key_does(nsb, expect):
+ expect.text(nsb["explicit layout"][0], nsb["explicit layout"][1],
+ "explicit columns lay the rows out as the declared key does")
+
+
+def test_nsb_sort_by_cleared(nsb, expect):
+ expect.text(nsb["cleared"], "true", "sort_by cleared")
+
+
+def test_nsb_zero_arg_errors_when_no_key_declared(nsb, expect):
+ expect.text(nsb["no key"], "22023 has no declared sort_by key", "zero-arg errors when no key declared")
+
+
+def test_nsb_set_options_rejects_a_nonexistent_sort_by_column(nsb, expect):
+ expect.text(nsb["bad column"], "P0001 cannot be used in sort_by",
+ "set_options rejects a nonexistent sort_by column")
+
+
+def test_nsb_zero_arg_errors_on_a_dropped_declared_column(nsb, expect):
+ expect.text(nsb["dropped"], '42703 declared sort_by column "payload" does not exist',
+ "zero-arg errors on a dropped declared column")
+
+
+def test_nsb_empty_table_sorts_without_error(nsb, expect):
+ expect.text(nsb["empty"], "ok", "empty table sorts without error")
+
+
+def test_nsb_single_row_table_sorts_without_error(nsb, expect):
+ expect.text(nsb["single"], "ok", "single-row table sorts without error")
+
+
+def test_nsb_single_row_parity(nsb, expect):
+ expect.text(nsb["single rows"], "1", "single-row parity")
+
+
+def test_nsb_set_options_rejects_a_virtual_generated_sort_by_column(nsb, expect):
+ m = nsb["major"]
+ if not isinstance(m, int):
+ expect.text(m, "readable", "could not read the server major, so the virtual arm cannot be gated")
+ return
+ if m < 18:
+ expect.cannot_run("UNSUPPORTED_MAJOR",
+ f"VIRTUAL generated columns arrived in PostgreSQL 18; this server is {m}",
+ name="virtual generated sort keys")
+ return
+ expect.text(nsb["virtual set"], "P0001 cannot be used in sort_by",
+ "set_options rejects a virtual generated sort_by column")
+
+
+def test_nsb_vacuum_sorted_rejects_an_explicit_virtual_generated_column(nsb, expect):
+ m = nsb["major"]
+ if not isinstance(m, int) or m < 18:
+ expect.cannot_run("UNSUPPORTED_MAJOR",
+ f"VIRTUAL generated columns arrived in PostgreSQL 18; this server is {m}")
+ return
+ expect.text(nsb["virtual sort"], "22023 is a virtual generated column and cannot be a sort key",
+ "vacuum_sorted rejects an explicit virtual generated column")
+
+
+def test_nsb_backend_still_up_after_the_refusals(nsb, expect):
+ expect.text(nsb["survived"], "1", "backend still up after the refusals")
diff --git a/test/pytest/test_native_vecdecode.py b/test/pytest/test_native_vecdecode.py
new file mode 100644
index 000000000..8cec90a22
--- /dev/null
+++ b/test/pytest/test_native_vecdecode.py
@@ -0,0 +1,303 @@
+"""Decode honours the skip vector (#452 phase 1b-i). The pytest twin of
+`test/native_vecdecode.sh`, every check by the shell's name (#1341).
+
+One row group of 32 vectors x 1024 rows; id is monotonic, so each vector's zone map is
+tight and a narrow range rules most of them out. The central check is that "Columnar
+Vectors Decoded" moves by EXACTLY the skipped count, read from one plan. The heap mirror
+is written from the generator, never copied from the columnar table.
+
+Each counter must be READ: a failed EXPLAIN is a `query_error()`, never 0, so "decoded
+plus skipped is the group's vectors" cannot pass as 0 + 0 = 0. Every comparison of two
+readings refuses a failed side. The column w is NULL on every seventh row, so the fold's
+use of the validity bitmap is exercised: with the bitmap ignored, the nullable aggregate
+reads shifted values.
+
+The poison check ("the fold does not count rows from a vector it never decoded") has teeth
+only on assert builds, which poison a skipped vector's bytes; on other builds it is a
+parity check.
+
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order; each test
+asserts one reading.
+"""
+import hashlib
+import re
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_vecdecode"
+ROWS = 32768
+GEN = (f"SELECT g AS id, (g % 97) AS v, CASE WHEN g % 7 = 0 THEN NULL ELSE g % 89 END AS w "
+ f"FROM generate_series(1, {ROWS}) g")
+NARROW = "SELECT id, v FROM n WHERE id BETWEEN 2000 AND 2050"
+WIDE = f"SELECT id, v FROM n WHERE id BETWEEN 1 AND {ROWS}"
+FOLD = ("SET max_parallel_workers_per_gather = 0",
+ "SET pgcolumnar.enable_ungrouped_vector_agg = on")
+FQ = "SELECT count(*), sum(v) FROM n WHERE id BETWEEN 2000 AND 2050"
+FQN = "SELECT count(w), sum(w) FROM n WHERE id BETWEEN 2000 AND 2050"
+FQ2 = "SELECT count(*), sum(v) FROM n WHERE id BETWEEN 20000 AND 20050"
+POISONQ = "SELECT count(*), sum(v) FROM n WHERE id BETWEEN -2000000000 AND 3000"
+EXPLAIN = "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) "
+
+
+def _failed(exc):
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _run(dsn, sql):
+ """Run one statement and swallow its failure, as the shell's psql_run does."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error:
+ pass
+
+
+def _rows(dsn, sql, sets=()):
+ """-> the rows as "|"-joined text like psql -At, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ for s in sets:
+ conn.execute(s)
+ rows = conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return _failed(exc)
+ return ["|".join("" if c is None else str(c) for c in r) for r in rows]
+
+
+def _one(dsn, sql, sets=()):
+ rows = _rows(dsn, sql, sets)
+ if not isinstance(rows, list):
+ return rows
+ return rows[-1] if rows else query_error(f"no row from {sql}")
+
+
+def _set_hash(dsn, sql):
+ """-> an order-free hash of the result set, as the shell's pgc_set_hash, or a failure."""
+ rows = _rows(dsn, sql)
+ if not isinstance(rows, list):
+ return rows
+ return hashlib.md5("\n".join(sorted(rows)).encode()).hexdigest()
+
+
+def _has(plan, label):
+ if not isinstance(plan, list):
+ return plan
+ return "yes" if any(label in ln for ln in plan) else "no"
+
+
+def _counter(plan, label):
+ """-> the integer after `label`, or a failure that names the missing line."""
+ if not isinstance(plan, list):
+ return plan
+ for ln in plan:
+ if label in ln:
+ m = re.search(r"\d+", ln[ln.index(label) + len(label):])
+ if m:
+ return m.group(0)
+ return query_error(f"no '{label}' line in the plan")
+
+
+def _fold_flag(plan):
+ if not isinstance(plan, list):
+ return plan
+ for ln in plan:
+ m = re.search(r"Columnar Batch Fold: [a-z]+", ln)
+ if m:
+ return m.group(0)
+ return query_error("no 'Columnar Batch Fold' line in the plan")
+
+
+def _num(x):
+ return isinstance(x, str) and x.isdigit()
+
+
+@pytest.fixture(scope="module")
+def vd(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ _run(dsn, "CREATE TABLE h (id int, v int, w int)")
+ _run(dsn, "CREATE TABLE n (id int, v int, w int) USING pgcolumnar")
+ _run(dsn, "SELECT pgcolumnar.set_options('n', stripe_row_limit => 65536, chunk_group_row_limit => 1024)")
+ _run(dsn, f"INSERT INTO h {GEN}")
+ _run(dsn, f"INSERT INTO n {GEN}")
+ r = {}
+ pn = _rows(dsn, EXPLAIN + NARROW)
+ pw = _rows(dsn, EXPLAIN + WIDE)
+ r["narrow custom"] = _has(pn, "Columnar Projected Columns")
+ r["wide custom"] = _has(pw, "Columnar Projected Columns")
+ r["groups"] = _one(dsn, "SELECT count(*) FROM pgcolumnar.row_group "
+ "WHERE storage_id = pgcolumnar.get_storage_id('n')")
+ r["pruned"] = _counter(pn, "Columnar Chunk Groups Removed by Filter")
+ skip_n = _counter(pn, "Columnar Vectors Skipped")
+ skip_w = _counter(pw, "Columnar Vectors Skipped")
+ r["narrow skips"] = ("yes" if int(skip_n) > 0 else "no") if _num(skip_n) else skip_n
+ r["wide skips"] = skip_w
+ r["has decoded"] = _has(pw, "Columnar Vectors Decoded")
+ dec_w = _counter(pw, "Columnar Vectors Decoded")
+ dec_n = _counter(pn, "Columnar Vectors Decoded")
+ r["wide decodes"] = ("yes" if int(dec_w) > 0 else "no") if _num(dec_w) else dec_w
+ r["fewer"] = (("yes" if int(dec_n) < int(dec_w) else "no") if _num(dec_n) and _num(dec_w)
+ else query_error(f"no reading [{dec_n}] [{dec_w}]"))
+ r["sum"] = (str(int(dec_n) + int(skip_n)) if _num(dec_n) and _num(skip_n)
+ else query_error(f"no reading [{dec_n}] [{skip_n}]"))
+ r["sum want"] = dec_w if _num(dec_w) else query_error(f"no wide reading [{dec_w}]")
+ r["narrow parity"] = (_set_hash(dsn, NARROW),
+ _set_hash(dsn, "SELECT id, v FROM h WHERE id BETWEEN 2000 AND 2050"))
+ r["cross parity"] = (_set_hash(dsn, "SELECT id, v FROM n WHERE id BETWEEN 3000 AND 9000"),
+ _set_hash(dsn, "SELECT id, v FROM h WHERE id BETWEEN 3000 AND 9000"))
+ r["edge parity"] = (_set_hash(dsn, "SELECT id, v FROM n WHERE id BETWEEN 1025 AND 2048"),
+ _set_hash(dsn, "SELECT id, v FROM h WHERE id BETWEEN 1025 AND 2048"))
+ r["has nulls"] = _one(dsn, "SELECT (count(*) - count(w) > 0)::text FROM h WHERE id BETWEEN 2000 AND 2050")
+ r["null parity"] = (_set_hash(dsn, "SELECT id, w FROM n WHERE id BETWEEN 3000 AND 9000"),
+ _set_hash(dsn, "SELECT id, w FROM h WHERE id BETWEEN 3000 AND 9000"))
+ r["agg"] = (_one(dsn, "SELECT count(*), sum(v) FROM n WHERE id BETWEEN 2000 AND 2050"),
+ _one(dsn, "SELECT count(*), sum(v) FROM h WHERE id BETWEEN 2000 AND 2050"))
+ r["agg many"] = (_one(dsn, "SELECT count(*), sum(v), min(v), max(v) FROM n WHERE id BETWEEN 5000 AND 5200"),
+ _one(dsn, "SELECT count(*), sum(v), min(v), max(v) FROM h WHERE id BETWEEN 5000 AND 5200"))
+ r["none"] = _one(dsn, "SELECT count(*) FROM n WHERE id BETWEEN 100000 AND 200000")
+ r["fold reach"] = _fold_flag(_rows(dsn, EXPLAIN + FQ, FOLD))
+ r["fold"] = (_one(dsn, FQ, FOLD),
+ _one(dsn, "SELECT count(*), sum(v) FROM h WHERE id BETWEEN 2000 AND 2050"))
+ r["null fold reach"] = _fold_flag(_rows(dsn, EXPLAIN + FQN, FOLD))
+ r["null fold"] = (_one(dsn, FQN, FOLD),
+ _one(dsn, "SELECT count(w), sum(w) FROM h WHERE id BETWEEN 2000 AND 2050"))
+ r["deep reach"] = _fold_flag(_rows(dsn, EXPLAIN + FQ2, FOLD))
+ r["deep"] = (_one(dsn, FQ2, FOLD),
+ _one(dsn, "SELECT count(*), sum(v) FROM h WHERE id BETWEEN 20000 AND 20050"))
+ r["poison reach"] = _fold_flag(_rows(dsn, EXPLAIN + POISONQ, FOLD))
+ r["fold decoded"] = _counter(_rows(dsn, EXPLAIN + POISONQ, FOLD), "Columnar Vectors Decoded")
+ r["fold all"] = _counter(_rows(dsn, EXPLAIN + "SELECT count(*), sum(v) FROM n WHERE id >= 0", FOLD),
+ "Columnar Vectors Decoded")
+ r["poison"] = (_one(dsn, POISONQ, FOLD),
+ _one(dsn, "SELECT count(*), sum(v) FROM h WHERE id BETWEEN -2000000000 AND 3000"))
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def _n(x):
+ return int(x) if _num(x) else x
+
+
+def test_vecdecode_narrow_plan_is_the_custom_scan(vd, expect):
+ expect.text(vd["narrow custom"], "yes", "the narrow plan is a columnar custom scan")
+
+
+def test_vecdecode_wide_plan_is_the_custom_scan(vd, expect):
+ expect.text(vd["wide custom"], "yes", "the wide plan is a columnar custom scan")
+
+
+def test_vecdecode_one_row_group(vd, expect):
+ expect.text(vd["groups"], "1", "one row group, so every difference is per-vector")
+
+
+def test_vecdecode_no_whole_group_pruned(vd, expect):
+ expect.text(vd["pruned"], "0", "no whole group is pruned on the narrow query")
+
+
+def test_vecdecode_narrow_range_skips_vectors(vd, expect):
+ expect.text(vd["narrow skips"], "yes", "the narrow range skips vectors")
+
+
+def test_vecdecode_wide_range_skips_none(vd, expect):
+ expect.text(vd["wide skips"], "0", "the wide range skips none, so the arms really differ")
+
+
+def test_vecdecode_explain_reports_vectors_decoded(vd, expect):
+ expect.text(vd["has decoded"], "yes", "EXPLAIN reports Columnar Vectors Decoded")
+
+
+def test_vecdecode_full_scan_decodes_something(vd, expect):
+ expect.text(vd["wide decodes"], "yes", "and it is not stuck at zero when a full scan decodes everything")
+
+
+def test_vecdecode_ruled_out_vector_not_decoded(vd, expect):
+ expect.text(vd["fewer"], "yes", "a ruled-out vector is not decoded")
+
+
+def test_vecdecode_decoded_plus_skipped_is_the_groups_vectors(vd, expect):
+ expect.text(vd["sum"], vd["sum want"],
+ "and EVERY ruled-out vector is: decoded plus skipped is the group's vectors")
+
+
+def test_vecdecode_narrow_range_parity(vd, expect):
+ expect.text(vd["narrow parity"][0], vd["narrow parity"][1], "narrow range parity with heap")
+
+
+def test_vecdecode_cross_vector_parity(vd, expect):
+ expect.text(vd["cross parity"][0], vd["cross parity"][1], "cross-vector range parity")
+
+
+def test_vecdecode_boundary_range_parity(vd, expect):
+ expect.text(vd["edge parity"][0], vd["edge parity"][1],
+ "boundary range parity, a range starting exactly on a vector edge")
+
+
+def test_vecdecode_nullable_column_has_nulls(vd, expect):
+ expect.text(vd["has nulls"], "true", "premise: the nullable column has nulls inside the ranges read below")
+
+
+def test_vecdecode_nullable_cross_vector_parity(vd, expect):
+ expect.text(vd["null parity"][0], vd["null parity"][1], "cross-vector parity of a nullable column, nulls included")
+
+
+def test_vecdecode_aggregate_over_skipping_range(vd, expect):
+ expect.text(vd["agg"][0], vd["agg"][1], "aggregate over a skipping range matches heap")
+
+
+def test_vecdecode_aggregate_over_many_skipped_vectors(vd, expect):
+ expect.text(vd["agg many"][0], vd["agg many"][1],
+ "aggregate over a range spanning many skipped vectors matches heap")
+
+
+def test_vecdecode_range_matching_no_row(vd, expect):
+ expect.num(_n(vd["none"]), 0, "a range matching no row at all is still correct")
+
+
+def test_vecdecode_aggregate_reaches_the_fold(vd, expect):
+ expect.text(vd["fold reach"], "Columnar Batch Fold: yes", "the aggregate really reaches the batch fold")
+
+
+def test_vecdecode_fold_answers_over_skipping_range(vd, expect):
+ expect.text(vd["fold"][0], vd["fold"][1], "the fold answers correctly over a range that skips vectors")
+
+
+def test_vecdecode_nullable_aggregate_reaches_the_fold(vd, expect):
+ expect.text(vd["null fold reach"], "Columnar Batch Fold: yes", "a nullable aggregate reaches the fold")
+
+
+def test_vecdecode_fold_nullable_count_and_sum(vd, expect):
+ expect.text(vd["null fold"][0], vd["null fold"][1],
+ "the fold answers count and sum of a nullable column over a skipping range")
+
+
+def test_vecdecode_deep_range_reaches_the_fold(vd, expect):
+ expect.text(vd["deep reach"], "Columnar Batch Fold: yes", "the deep range reaches the fold too")
+
+
+def test_vecdecode_deep_range_agrees_with_heap(vd, expect):
+ expect.text(vd["deep"][0], vd["deep"][1], "and it agrees with heap after many leading skipped vectors")
+
+
+def test_vecdecode_poison_range_reaches_the_fold(vd, expect):
+ expect.text(vd["poison reach"], "Columnar Batch Fold: yes", "the poison-accepting range reaches the fold")
+
+
+def test_vecdecode_fold_arm_decodes_only_what_it_needs(vd, expect):
+ expect.num(_n(vd["fold decoded"]), 3, "the FOLD ARM really decodes only the vectors it needs")
+
+
+def test_vecdecode_fold_counter_reads_every_vector_when_none_skipped(vd, expect):
+ expect.text(vd["fold all"], "32", "control: and the same counter reads every vector when nothing is skipped")
+
+
+def test_vecdecode_fold_does_not_count_undecoded_rows(vd, expect):
+ expect.text(vd["poison"][0], vd["poison"][1], "the fold does not count rows from a vector it never decoded")
diff --git a/test/pytest/test_native_zonemap.py b/test/pytest/test_native_zonemap.py
new file mode 100644
index 000000000..f47c08c63
--- /dev/null
+++ b/test/pytest/test_native_zonemap.py
@@ -0,0 +1,276 @@
+"""Native zone maps, storage side (Phase D5a/D5b). The pytest twin of
+`test/native_zonemap.sh`, every check by the shell's name (#1341).
+
+A native-format table records a Small Materialized Aggregate per 1024-value vector and
+per column chunk in pgcolumnar.zone_map: min, max, value_count, null_count, and a sum
+for int2/int4 columns. The suite validates the catalog rows directly against a heap
+mirror written from the same generator: their shape, the exact counts, the bounds'
+presence, width AND VALUES, the sums, that the read path is unchanged, and that the
+rows go with the table.
+
+THE BOUNDS ARE COMPARED BY VALUE (#1341). The suite used to check only that a bound
+was present and had the right width, and every column ascended, so a vector's first
+value was always its minimum. A writer that never raised a max past a vector's first
+value, never lowered a min, or never folded a chunk max past its first vector stored
+bounds of the right width, and main passed all 18 checks under each. `v` is now a
+permutation (its values, nulls and counts unchanged), and each bound is decoded and
+compared with the heap's own for its group and vector.
+
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order; each test
+asserts one reading. Every reading opens its own connection and returns a failure as a
+`query_error()`, so two failed reads never compare equal and a count that could not be
+read is never defaulted to 0.
+"""
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_zonemap"
+GEN = """SELECT g,
+ (g * 10)::bigint,
+ 'lbl-' || (g % 50),
+ CASE WHEN g % 7 = 0 THEN NULL ELSE ((g::bigint * 7919) % 6151)::int END
+ FROM generate_series(1, 6144) g"""
+HASH = ("SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), $e$EMPTY$e$) "
+ "FROM (SELECT _row::text AS t FROM ({}) _row) _s")
+ZM_LE = """CREATE FUNCTION zm_le(b bytea) RETURNS bigint LANGUAGE sql IMMUTABLE AS
+ $$SELECT sum((get_byte(b, i)::bigint) << (8 * i))::bigint
+ FROM generate_series(0, length(b) - 1) i$$"""
+
+
+def _failed(exc):
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _one(dsn, sql):
+ """-> the first column of the first row as psql -At prints it, or the failure."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return _failed(exc)
+ if row is None:
+ return query_error(f"no row from {sql}")
+ v = row[0]
+ if v is None:
+ return ""
+ if isinstance(v, bool):
+ return "t" if v else "f"
+ return str(v)
+
+
+def _int(dsn, sql):
+ """-> the value as an int, or why there is none."""
+ v = _one(dsn, sql)
+ if v.lstrip("-").isdigit():
+ return int(v)
+ return v if v.startswith("QUERY_ERROR") else query_error(f"not a number from {sql}: [{v}]")
+
+
+def _zq(sid, expr):
+ return f"SELECT {expr} FROM pgcolumnar.zone_map WHERE storage_id = {sid}"
+
+
+def _zint(dsn, sid, expr):
+ """-> a zone-map reading for storage `sid`, or sid's own failure when it could not be
+ read. A query over `storage_id = NULL` matches no row, and a count of bad rows over no
+ rows is the 0 nine checks want (#1697 review); the shell's empty $SID is a syntax
+ error there, so each of its checks fails."""
+ return _int(dsn, _zq(sid, expr)) if isinstance(sid, int) else sid
+
+
+def _bounds_match(dsn, sid, kind, side):
+ """-> how many zone rows of id (0), k (1) and v (3) hold the heap's own bound for
+ their group and vector; kind "vector" (18 rows) or "chunk" (9 rows), as the shell's
+ bounds_match."""
+ if not isinstance(sid, int):
+ return sid
+ vfilter = "vector_index = -1" if kind == "chunk" else "vector_index >= 0"
+ p = side[:3]
+ return _int(dsn, f"""
+ WITH z AS (
+ SELECT dense_rank() OVER (ORDER BY group_number) - 1 AS g, column_index AS c,
+ vector_index AS vi, zm_le({side}) AS got
+ FROM pgcolumnar.zone_map
+ WHERE storage_id = {sid} AND column_index IN (0, 1, 3) AND {vfilter}),
+ hv AS (
+ SELECT (id - 1) / 2048 AS g, ((id - 1) % 2048) / 1024 AS vi,
+ min(id) AS min0, max(id) AS max0, min(k) AS min1, max(k) AS max1,
+ min(v) AS min3, max(v) AS max3
+ FROM h GROUP BY 1, 2
+ UNION ALL
+ SELECT (id - 1) / 2048, -1, min(id), max(id), min(k), max(k), min(v), max(v)
+ FROM h GROUP BY 1)
+ SELECT count(*) FROM z JOIN hv USING (g, vi)
+ WHERE z.got = CASE z.c WHEN 0 THEN hv.{p}0 WHEN 1 THEN hv.{p}1 ELSE hv.{p}3 END""")
+
+
+@pytest.fixture(scope="module")
+def zm(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ conn.execute("CREATE TABLE h (id int, k bigint, label text, v int)")
+ conn.execute("CREATE TABLE n (id int, k bigint, label text, v int) USING pgcolumnar")
+ conn.execute("SELECT pgcolumnar.set_options('n', stripe_row_limit => 2048, "
+ "chunk_group_row_limit => 1024)")
+ conn.execute(f"INSERT INTO h {GEN}")
+ conn.execute(f"INSERT INTO n {GEN}")
+ conn.execute(ZM_LE)
+ sid = _int(dsn, "SELECT pgcolumnar.get_storage_id('n')")
+ r = {
+ "rows": _int(dsn, "SELECT count(*) FROM n"),
+ "hash n": _one(dsn, HASH.format("SELECT id, k, label, v FROM n")),
+ "hash h": _one(dsn, HASH.format("SELECT id, k, label, v FROM h")),
+ "chunk rows": _zint(dsn, sid, "count(*) FILTER (WHERE vector_index = -1)"),
+ "vector rows": _zint(dsn, sid, "count(*) FILTER (WHERE vector_index >= 0)"),
+ "chunk cover": _zint(dsn, sid, "count(*) FILTER (WHERE vector_index = -1 "
+ "AND value_count + null_count <> 2048)"),
+ "vector cover": _zint(dsn, sid, "count(*) FILTER (WHERE vector_index >= 0 "
+ "AND value_count + null_count <> 1024)"),
+ "v values": _zint(dsn, sid, "sum(value_count) FILTER (WHERE vector_index = -1 "
+ "AND column_index = 3)"),
+ "h v values": _int(dsn, "SELECT count(v) FROM h"),
+ "v nulls": _zint(dsn, sid, "sum(null_count) FILTER (WHERE vector_index = -1 "
+ "AND column_index = 3)"),
+ "h v nulls": _int(dsn, "SELECT count(*) - count(v) FROM h"),
+ "id values": _zint(dsn, sid, "sum(value_count) FILTER (WHERE vector_index = -1 "
+ "AND column_index = 0)"),
+ "id nulls": _zint(dsn, sid, "sum(null_count) FILTER (WHERE vector_index = -1 "
+ "AND column_index = 0)"),
+ "k folds": _zint(dsn, sid, """count(*) FILTER (WHERE column_index = 1 AND vector_index = -1
+ AND value_count <> (SELECT sum(z2.value_count)
+ FROM pgcolumnar.zone_map z2
+ WHERE z2.storage_id = pgcolumnar.zone_map.storage_id
+ AND z2.group_number = pgcolumnar.zone_map.group_number
+ AND z2.column_index = 1 AND z2.vector_index >= 0))"""),
+ "minmax missing": _zint(dsn, sid, "count(*) FILTER (WHERE minimum IS NULL "
+ "OR maximum IS NULL)"),
+ "id width": _zint(dsn, sid, "count(*) FILTER (WHERE column_index = 0 "
+ "AND octet_length(minimum) <> 4)"),
+ "k width": _zint(dsn, sid, "count(*) FILTER (WHERE column_index = 1 "
+ "AND octet_length(maximum) <> 8)"),
+ "v not ascending": _one(dsn, """SELECT count(*) > 0 FROM (
+ SELECT (id - 1) / 1024 AS vec, min(v) AS mn,
+ (array_agg(v ORDER BY id) FILTER (WHERE v IS NOT NULL))[1] AS first
+ FROM h GROUP BY 1) s WHERE first <> mn"""),
+ "vector min": _bounds_match(dsn, sid, "vector", "minimum"),
+ "vector max": _bounds_match(dsn, sid, "vector", "maximum"),
+ "chunk min": _bounds_match(dsn, sid, "chunk", "minimum"),
+ "chunk max": _bounds_match(dsn, sid, "chunk", "maximum"),
+ "int4 sums": _zint(dsn, sid, "count(*) FILTER (WHERE column_index IN (0,3) "
+ "AND value_count > 0 AND sum IS NULL)"),
+ "other sums": _zint(dsn, sid, "count(*) FILTER (WHERE column_index IN (1,2) "
+ "AND sum IS NOT NULL)"),
+ "id sum": _zint(dsn, sid, "sum(sum) FILTER (WHERE column_index = 0 "
+ "AND vector_index = -1)"),
+ "h id sum": _int(dsn, "SELECT sum(id) FROM h"),
+ }
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute("DROP TABLE n")
+ r["after drop"] = _zint(dsn, sid, "count(*)")
+ except psycopg.Error as exc:
+ r["after drop"] = _failed(exc)
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_zonemap_row_count(zm, expect):
+ expect.num(zm["rows"], 6144, "row count")
+
+
+def test_zonemap_read_parity(zm, expect):
+ expect.text(zm["hash n"], zm["hash h"], "read parity (no regression)")
+
+
+def test_zonemap_whole_chunk_zone_rows(zm, expect):
+ expect.num(zm["chunk rows"], 12, "whole-chunk zone rows")
+
+
+def test_zonemap_per_vector_zone_rows(zm, expect):
+ expect.num(zm["vector rows"], 24, "per-vector zone rows")
+
+
+def test_zonemap_whole_chunk_rows_cover_their_group(zm, expect):
+ expect.num(zm["chunk cover"], 0, "whole-chunk value+null = group rows")
+
+
+def test_zonemap_each_vector_covers_1024(zm, expect):
+ expect.num(zm["vector cover"], 0, "per-vector value+null = 1024")
+
+
+def test_zonemap_value_count_sums_for_nullable_v(zm, expect):
+ expect.num(zm["v values"], zm["h v values"], "value_count sums (v, nullable)")
+
+
+def test_zonemap_null_count_sums_for_nullable_v(zm, expect):
+ expect.num(zm["v nulls"], zm["h v nulls"], "null_count sums (v, nullable)")
+
+
+def test_zonemap_value_count_sums_for_id(zm, expect):
+ expect.num(zm["id values"], 6144, "value_count sums (id, no nulls)")
+
+
+def test_zonemap_null_count_zero_for_id(zm, expect):
+ expect.num(zm["id nulls"], 0, "null_count zero (id, no nulls)")
+
+
+def test_zonemap_vector_value_counts_fold_into_the_chunk(zm, expect):
+ expect.num(zm["k folds"], 0, "per-vector value_count folds into chunk (k)")
+
+
+def test_zonemap_min_max_present_for_all_columns(zm, expect):
+ expect.num(zm["minmax missing"], 0, "min/max present for all columns")
+
+
+def test_zonemap_int4_min_width(zm, expect):
+ expect.num(zm["id width"], 0, "id (int4) min width = 4 bytes")
+
+
+def test_zonemap_int8_max_width(zm, expect):
+ expect.num(zm["k width"], 0, "k (int8) max width = 8 bytes")
+
+
+def test_zonemap_v_is_not_ascending_within_a_vector(zm, expect):
+ expect.text(zm["v not ascending"], "t",
+ "premise: v is not ascending within a vector, so a stale minimum would show")
+
+
+def test_zonemap_every_vector_minimum_is_the_heaps(zm, expect):
+ expect.num(zm["vector min"], 18, "every vector's minimum of id, k and v is the heap's")
+
+
+def test_zonemap_every_vector_maximum_is_the_heaps(zm, expect):
+ expect.num(zm["vector max"], 18, "every vector's maximum of id, k and v is the heap's")
+
+
+def test_zonemap_every_chunk_minimum_is_the_heaps(zm, expect):
+ expect.num(zm["chunk min"], 9, "every whole-chunk minimum of id, k and v is the heap's")
+
+
+def test_zonemap_every_chunk_maximum_is_the_heaps(zm, expect):
+ expect.num(zm["chunk max"], 9, "every whole-chunk maximum of id, k and v is the heap's")
+
+
+def test_zonemap_sum_present_for_int4_columns(zm, expect):
+ expect.num(zm["int4 sums"], 0, "sum present for int4 columns")
+
+
+def test_zonemap_sum_null_for_other_columns(zm, expect):
+ expect.num(zm["other sums"], 0, "sum null for non-int4 columns")
+
+
+def test_zonemap_whole_chunk_sum_of_id_matches_heap(zm, expect):
+ expect.num(zm["id sum"], zm["h id sum"], "whole-chunk sum(id) matches heap")
+
+
+def test_zonemap_zone_maps_go_with_the_table(zm, expect):
+ expect.num(zm["after drop"], 0, "zone maps cleaned on drop")
diff --git a/test/pytest/test_native_zonemap_narrow.py b/test/pytest/test_native_zonemap_narrow.py
new file mode 100644
index 000000000..c53405e5a
--- /dev/null
+++ b/test/pytest/test_native_zonemap_narrow.py
@@ -0,0 +1,137 @@
+"""Group skipping reads only the predicate columns' zone maps (#314). The pytest twin of
+`test/native_zonemap_narrow.sh`, every check by the shell's name (#1341).
+
+pgcolumnar_native_group_can_match fetches the zone map of each column that carries a
+predicate, not the whole group's. A one-predicate scan therefore reads about the same
+number of pgcolumnar.zone_map tuples on a 30-column table as on a 2-column one; the
+instrument is pg_stat_all_tables.idx_tup_fetch for zone_map, flushed with
+pg_stat_force_next_flush, read exactly as the shell reads it (one session per step).
+
+PRUNING IS READ, NOT INFERRED (#1341). The answer to `c1 > 19000` alone could not show
+a group was skipped: a scan that read the zone maps but never excluded a group, or never
+consulted them, returned the same 1000 rows, and main passed all four checks under
+each. The scan's own "Columnar Chunk Groups Removed by Filter" must be 9 of the ten
+groups, on both tables.
+
+ON A PRIVATE DATABASE. Every reading opens its own connection and returns a failure as
+a `query_error()`, so two failed reads never compare equal and an unread count is never
+defaulted to 0.
+"""
+import re
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_zonemap_narrow"
+WCOLS = ", ".join(f"c{i} int" for i in range(1, 31))
+WVALS = ", ".join("g" for _ in range(1, 31))
+
+
+def _failed(exc):
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _rows(dsn, sql):
+ """-> every row's first column as text, or the failure."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ cur = conn.execute(sql)
+ return [str(r[0]) for r in cur.fetchall()] if cur.description else []
+ except psycopg.Error as exc:
+ return _failed(exc)
+
+
+def _int(dsn, sql):
+ """-> the single value as an int, or why there is none."""
+ rows = _rows(dsn, sql)
+ if isinstance(rows, str):
+ return rows
+ if len(rows) == 1 and rows[0].isdigit():
+ return int(rows[0])
+ return query_error(f"not a number from {sql}: {rows}")
+
+
+def _zm_fetch(dsn, table):
+ """-> zone_map tuples fetched by a one-predicate scan of TABLE, as the shell's zm_fetch."""
+ for sql in ("SELECT pg_stat_reset()",
+ f"SELECT count(*) FROM {table} WHERE c1 >= 0",
+ "SELECT pg_stat_force_next_flush()"):
+ r = _rows(dsn, sql)
+ if isinstance(r, str):
+ return r
+ return _int(dsn, "SELECT coalesce(idx_tup_fetch, 0) FROM pg_stat_all_tables "
+ "WHERE relname = 'zone_map' AND schemaname = 'pgcolumnar'")
+
+
+def _removed(dsn, table, pred):
+ """-> the scan's "Columnar Chunk Groups Removed by Filter", or why there is none."""
+ plan = _rows(dsn, f"EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) "
+ f"SELECT * FROM {table} WHERE {pred}")
+ if isinstance(plan, str):
+ return plan
+ m = re.search(r"Columnar Chunk Groups Removed by Filter: (\d+)", "\n".join(plan))
+ return int(m.group(1)) if m else query_error(f"no removed-groups line for {table}: {plan[:1]}")
+
+
+def _scales(wf, nf):
+ """-> "yes" when the wide read is within twice the narrow, as the shell's arithmetic."""
+ if not (isinstance(wf, int) and isinstance(nf, int)):
+ return query_error(f"no reading (wide [{wf}], narrow [{nf}])")
+ return "yes" if wf <= nf * 2 else "no"
+
+
+@pytest.fixture(scope="module")
+def zn(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ setup = _rows(dsn, f"""CREATE EXTENSION IF NOT EXISTS pgcolumnar;
+ SET pgcolumnar.stripe_row_limit = 2000;
+ CREATE TABLE w ({WCOLS}) USING pgcolumnar;
+ INSERT INTO w SELECT {WVALS} FROM generate_series(1, 20000) g;
+ CREATE TABLE n (c1 int, c2 int) USING pgcolumnar;
+ INSERT INTO n SELECT g, g FROM generate_series(1, 20000) g""")
+ wf = _zm_fetch(dsn, "w")
+ nf = _zm_fetch(dsn, "n")
+ yield {
+ "setup": setup,
+ "scales": _scales(wf, nf),
+ "wide": _int(dsn, "SELECT count(*) FROM w WHERE c1 >= 0"),
+ "narrow": _int(dsn, "SELECT count(*) FROM n WHERE c1 >= 0"),
+ "prunes": _int(dsn, "SELECT count(*) FROM w WHERE c1 > 19000"),
+ "removed w": _removed(dsn, "w", "c1 > 19000"),
+ "removed n": _removed(dsn, "n", "c1 > 19000"),
+ }
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_zone_map_reads_scale_with_predicates_not_table_width(zn, expect):
+ expect.text(zn["scales"], "yes",
+ "zone_map reads scale with predicates, not table width (wide ~= narrow)")
+
+
+def test_the_wide_scan_result_is_correct(zn, expect):
+ expect.num(zn["wide"], 20000, "wide scan result is correct")
+
+
+def test_the_narrow_scan_result_is_correct(zn, expect):
+ expect.num(zn["narrow"], 20000, "narrow scan result is correct")
+
+
+def test_a_pruning_predicate_still_prunes_on_the_wide_table(zn, expect):
+ expect.num(zn["prunes"], 1000, "a pruning predicate still prunes on the wide table")
+
+
+def test_the_zone_map_removes_nine_groups_on_the_wide_table(zn, expect):
+ expect.num(zn["removed w"], 9,
+ "the zone map removes the nine groups c1 > 19000 cannot match, on the wide table")
+
+
+def test_the_zone_map_removes_nine_groups_on_the_narrow_table(zn, expect):
+ expect.num(zn["removed n"], 9, "and on the narrow table")
diff --git a/test/pytest/test_native_zonemap_session.py b/test/pytest/test_native_zonemap_session.py
new file mode 100644
index 000000000..318d55c34
--- /dev/null
+++ b/test/pytest/test_native_zonemap_session.py
@@ -0,0 +1,249 @@
+"""The skip loop resolves pgcolumnar.zone_map ONCE per scan, not once per chunk group per
+predicate column (#744). The pytest twin of `test/native_zonemap_session.sh`, every check
+by the shell's name (#1341).
+
+What is asserted is the work done, not the answer: a scan returns the same rows either
+way. The witness is the DEBUG1 line each scan's read session emits when it closes,
+"pgcolumnar zone map read: probes=N opens=M". Probes must scale with the group count and
+opens must stay at one.
+
+Each scan owns its session, so two scans open at once, the two sides of a join, report
+separately and each reports its OWN groups. A session shared between them, the static the
+first implementation held, folds one scan's probes into the other's and leaves the second
+report empty, while the shared handle still reads correctly; only the witness sees it. The
+same holds around a scan that errors inside a subtransaction: the scans before, after the
+ROLLBACK TO and after the COMMIT each open for themselves and probe only their own groups.
+
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order; each test
+asserts one reading, and a failed query is a `query_error()`.
+"""
+import re
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_native_zonemap_session"
+N = 40
+ROWS = N * 10000
+WITNESS = re.compile(r"probes=(\d+) opens=(\d+)")
+SCAN_SETTINGS = ("SET client_min_messages = debug1", "SET max_parallel_workers_per_gather = 0",
+ "SET enable_indexscan = off", "SET enable_bitmapscan = off")
+
+
+def _failed(exc):
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+
+
+def _run(dsn, sql):
+ """Run one setup statement and swallow its failure, as the shell's psql_run does: a
+ failed step shows up in the readings after it."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.execute(sql)
+ except psycopg.Error:
+ pass
+
+
+def _one(dsn, sql):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return _failed(exc)
+ if row is None or row[0] is None:
+ return query_error(f"no value from {sql}")
+ return str(row[0])
+
+
+def _witness(dsn, where):
+ """-> (probes, opens) from the LAST 'probes=N opens=M' line the scan raised, as the
+ shell's witness() reads it; or a failure."""
+ messages = []
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.add_notice_handler(lambda d: messages.append(d.message_primary or ""))
+ for s in SCAN_SETTINGS:
+ conn.execute(s)
+ conn.execute(f"SELECT count(*) FROM zs WHERE {where}").fetchone()
+ except psycopg.Error as exc:
+ failed = _failed(exc)
+ return failed, failed
+ found = [m.groups() for line in messages for m in [WITNESS.search(line)] if m]
+ if not found:
+ failed = query_error(f"no 'probes= opens=' line from the scan on {where}")
+ return failed, failed
+ return found[-1]
+
+
+def _nested(dsn):
+ """-> (the join's answer, the scan reports it raised); either may be a failure."""
+ messages = []
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.add_notice_handler(lambda d: messages.append(d.message_primary or ""))
+ conn.execute("SET client_min_messages = debug1")
+ conn.execute("SET enable_indexscan = off")
+ conn.execute("SET max_parallel_workers_per_gather = 0")
+ row = conn.execute("SELECT count(*) FROM zs2 a JOIN zs2 b ON a.k = b.k "
+ "WHERE a.k <= 1000 AND b.k <= 1000").fetchone()
+ except psycopg.Error as exc:
+ failed = _failed(exc)
+ return failed, failed
+ return str(row[0]), messages
+
+
+def _around_abort(dsn):
+ """Run the shell's zsub.sql statement by statement, continuing past the error as psql
+ does with ON_ERROR_STOP=0. -> (the counts the three scans answered, the error messages,
+ the scan reports raised); or a failure if the session itself could not run."""
+ messages, answers, errors = [], [], []
+ statements = [
+ "SET client_min_messages = debug1",
+ "SET enable_indexscan = off",
+ "SELECT count(*) FROM zs2 WHERE k <= 1000",
+ "BEGIN",
+ "SAVEPOINT s1",
+ "SELECT count(*) FROM zs2 WHERE k <= 5000 AND 1/(v-4000) > 0",
+ "ROLLBACK TO s1",
+ "SELECT count(*) FROM zs2 WHERE k <= 1000",
+ "COMMIT",
+ "SELECT count(*) FROM zs2 WHERE k <= 1000",
+ ]
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ conn.add_notice_handler(lambda d: messages.append(d.message_primary or ""))
+ for s in statements:
+ try:
+ cur = conn.execute(s)
+ if cur.description:
+ answers.append(str(cur.fetchone()[0]))
+ except psycopg.errors.QueryCanceled:
+ raise
+ except psycopg.OperationalError:
+ raise
+ except psycopg.Error as exc:
+ errors.append(str(exc))
+ except psycopg.Error as exc:
+ return _failed(exc)
+ return answers, errors, messages
+
+
+@pytest.fixture(scope="module")
+def zs(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ _run(dsn, "CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ r = {}
+ _run(dsn, "CREATE TABLE zs (k bigint, w bigint, v bigint) USING pgcolumnar")
+ _run(dsn, "SELECT pgcolumnar.set_options('zs', stripe_row_limit => 10000)")
+ _run(dsn, f"INSERT INTO zs SELECT g, g, g FROM generate_series(1, {ROWS}) g")
+ r["groups"] = _one(dsn, "SELECT count(*) FROM pgcolumnar.row_group "
+ "WHERE storage_id = pgcolumnar.get_storage_id('zs')")
+ r["probes"], r["opens"] = _witness(dsn, "k <= 10000")
+ r["probes2"], r["opens2"] = _witness(dsn, "k <= 10000 AND w <= 10000")
+ _run(dsn, "CREATE TABLE zs2 (k bigint, v bigint) USING pgcolumnar")
+ _run(dsn, "SELECT pgcolumnar.set_options('zs2', stripe_row_limit => 1000)")
+ _run(dsn, "INSERT INTO zs2 SELECT g, g FROM generate_series(1, 20000) g")
+ r["groups2"] = _one(dsn, "SELECT count(*) FROM pgcolumnar.row_group "
+ "WHERE storage_id = pgcolumnar.get_storage_id('zs2')")
+ r["nested"], r["nested reports"] = _nested(dsn)
+ r["abort"] = _around_abort(dsn)
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def _num(v):
+ return int(v) if isinstance(v, str) and v.isdigit() else v
+
+
+def _more_than_one(v):
+ return "yes" if isinstance(v, str) and v.isdigit() and int(v) > 1 else f"no ({v})"
+
+
+def _reports_matching(messages, pattern):
+ """-> how many scan reports match pattern, or the failure that left none to count."""
+ if not isinstance(messages, list):
+ return messages
+ return sum(1 for line in messages if re.search(pattern, line))
+
+
+def _own_groups(r):
+ g = r["groups2"]
+ return rf"zone map read: probes={g} opens=1$" if isinstance(g, str) and g.isdigit() else None
+
+
+def test_native_zonemap_session_fixture_has_several_groups(zs, expect):
+ expect.text(_more_than_one(zs["groups"]), "yes", "the fixture has more than one chunk group")
+
+
+def test_native_zonemap_session_scan_probes_once_per_group(zs, expect):
+ expect.num(_num(zs["probes"]), _num(zs["groups"]), "the scan really probed the zone maps, once per group")
+
+
+def test_native_zonemap_session_scan_resolves_zone_map_once(zs, expect):
+ expect.num(_num(zs["opens"]), 1, "the scan resolves zone_map ONCE, not once per group (#744)")
+
+
+def test_native_zonemap_session_second_column_still_opens_once(zs, expect):
+ expect.num(_num(zs["opens2"]), 1, "a second predicate column still resolves zone_map once")
+
+
+def test_native_zonemap_session_short_circuit_adds_one_probe(zs, expect):
+ g = _num(zs["groups"])
+ expect.num(_num(zs["probes2"]), g + 1 if isinstance(g, int) else g,
+ "and the short circuit means it adds one probe, not one per group")
+
+
+def test_native_zonemap_session_zs2_has_several_groups(zs, expect):
+ expect.text(_more_than_one(zs["groups2"]), "yes", "zs2 has more than one chunk group")
+
+
+def test_native_zonemap_session_nested_scans_answer(zs, expect):
+ expect.num(_num(zs["nested"]), 1000, "nested columnar scans return the right answer")
+
+
+def test_native_zonemap_session_nested_scans_hold_their_own_handle(zs, expect):
+ pattern = _own_groups(zs)
+ got = _reports_matching(zs["nested reports"], pattern) if pattern else zs["groups2"]
+ expect.num(got, 2, "nested columnar scans each hold their own handle")
+
+
+def _abort_part(zs, i):
+ a = zs["abort"]
+ return a[i] if isinstance(a, tuple) else a
+
+
+def test_native_zonemap_session_middle_scan_errored(zs, expect):
+ errors = _abort_part(zs, 1)
+ got = sum(1 for e in errors if "division by zero" in e) if isinstance(errors, list) else errors
+ expect.num(got, 1, "the middle scan really did error inside the subtransaction")
+
+
+def test_native_zonemap_session_every_scan_reports_its_own_session(zs, expect):
+ expect.num(_reports_matching(_abort_part(zs, 2), r"zone map read: probes="), 3,
+ "every scan around an aborted one reports its own session (#744)")
+
+
+def test_native_zonemap_session_each_scan_opened_once(zs, expect):
+ expect.num(_reports_matching(_abort_part(zs, 2), r"zone map read: probes=\d* opens=1"), 3,
+ "and each of them opened exactly once")
+
+
+def test_native_zonemap_session_each_scan_probed_its_own_groups(zs, expect):
+ pattern = _own_groups(zs)
+ got = _reports_matching(_abort_part(zs, 2), pattern) if pattern else zs["groups2"]
+ expect.num(got, 3, "and each of them probed exactly its own groups")
+
+
+def test_native_zonemap_session_scan_after_abort_answers(zs, expect):
+ answers = _abort_part(zs, 0)
+ got = sum(1 for a in answers if a == "1000") if isinstance(answers, list) else answers
+ expect.num(got, 3, "the scan after the subtransaction abort returns the right answer")
diff --git a/test/pytest/test_objstore_addressing.py b/test/pytest/test_objstore_addressing.py
new file mode 100644
index 000000000..f7588a30e
--- /dev/null
+++ b/test/pytest/test_objstore_addressing.py
@@ -0,0 +1,340 @@
+"""The gs:// scheme and S3 virtual-host addressing (#621): the pytest twin of
+`test/objstore_addressing.sh`, every check by the shell's name (#1341).
+
+Reads go through the local SigV4 fixture server (test/objstore_http_server.py, the
+shell's own) started with --virtual-host-base s3.local, so a read that returns data
+proves the signer signed the bytes the endpoint expects. The answer alone does not
+prove the addressing: the fixture serves the same object either way, so every
+addressing check also reads the request path the server LOGGED -- the bucket in the
+path under path-style, the key alone under virtual-host -- and a key with a space,
+'+' and '=' must arrive URI-encoded exactly once.
+
+A REFUSAL IS NOT ANY ERROR: with no endpoint, s3:// must be refused 28000 naming
+AWS_ENDPOINT_URL, and gs:// must reach its default host, which this cluster's
+allow-list refuses 42501 naming storage.googleapis.com:443 -- not a mistyped default,
+not some other error, not a crash with no message.
+
+The parquet objects are written by a python3 child, as the shell's heredoc writes them.
+
+A PRIVATE CLUSTER, because the postmaster is restarted with AWS_* in its environment,
+which the shared cluster cannot do. It starts with every AWS_* variable of this process
+removed. Virtual-host needs pgc-bucket.s3.local to resolve to this host, as the shell
+does; where it does not, the first test declines under the shell's pgc_skip message and
+the rest decline, as they do without pyarrow.
+"""
+import os
+import pathlib
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+TEST = pathlib.Path(__file__).resolve().parents[1]
+AKID, SECRET, REGION, BUCKET = "PGCTESTKEYID", "pgctest-secret", "pgc-test-1", "pgc-bucket"
+ENCKEY = "dir sp/a+b=c.parquet"
+RD = "SELECT count(*), sum(id) FROM pgcolumnar.read_parquet"
+VIRTUAL = "SET pgcolumnar.objstore_s3_addressing='virtual'"
+PATH = "SET pgcolumnar.objstore_s3_addressing='path'"
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _row(dsn, *sqls):
+ """Run `sqls` on one connection -> the last one's first row, columns joined by a
+ space, or the failure as a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for sql in sqls[:-1]:
+ conn.execute(sql)
+ row = conn.execute(sqls[-1]).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None or any(v is None for v in row):
+ return query_error(f"no value from {sqls[-1]}")
+ return " ".join(str(v) for v in row)
+
+
+def _refusal(dsn, sql, phrase, label):
+ """-> "" when `sql` raised with `phrase` in its message, the bare
+ SQLSTATE when the message lacks it, or what happened instead."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return query_error(_message(exc))
+ return exc.sqlstate + (label if phrase in str(exc) else "")
+ return "no error"
+
+
+def _gets(log):
+ """-> the request paths of the GETs the fixture logged, or a query_error()."""
+ try:
+ lines = pathlib.Path(log).read_text(errors="replace").splitlines()
+ except OSError as exc:
+ return query_error(f"request log unreadable: {exc}")
+ return [f[1] for f in (l.split() for l in lines) if len(f) > 1 and f[0] == "GET"]
+
+
+def _sent(log, want, unwanted=None):
+ """-> "yes" when the server received a GET for `want` (and none for `unwanted`),
+ else the paths it did receive."""
+ paths = _gets(log)
+ if not isinstance(paths, list):
+ return paths
+ if want in paths and (unwanted is None or unwanted not in paths):
+ return "yes"
+ return f"no: the server received {paths}"
+
+
+def _resolves(host):
+ try:
+ return bool(socket.getaddrinfo(host, None))
+ except OSError:
+ return False
+
+
+def _write_parquet(path, ids):
+ """Write one int8 column `id` with a python3 child, as the shell's heredoc does; the
+ first test names the shell's pyarrow skip through require_module. -> None, or the
+ decline when pyarrow is absent. Any other failure raises."""
+ w = subprocess.run([sys.executable, "-c",
+ "import sys, pyarrow as pa, pyarrow.parquet as pq\n"
+ "pq.write_table(pa.table({'id': pa.array([int(i) for i in sys.argv[2:]], pa.int64())}),"
+ " sys.argv[1])", str(path)] + [str(i) for i in ids], capture_output=True, text=True)
+ if w.returncode == 0:
+ return None
+ if "No module named 'pyarrow" in w.stderr:
+ return ("MISSING_DEPENDENCY", "pyarrow is not installed")
+ raise RuntimeError(f"writing {path} failed: {w.stderr.strip()}")
+
+
+def _pg_ctl(cluster, args, extra_env):
+ """pg_ctl with an explicit environment: every AWS_* of this process removed, then
+ `extra_env` added. Dropped to postgres when running as root, as the harness does."""
+ unset = [a for k in os.environ if k.startswith("AWS_") for a in ("-u", k)]
+ cmd = ["env"] + unset + [f"{k}={v}" for k, v in extra_env.items()] + \
+ [os.path.join(cluster.bindir, "pg_ctl")] + args
+ if os.geteuid() == 0:
+ cmd = ["runuser", "-u", "postgres", "--"] + cmd
+ return subprocess.run(cmd, capture_output=True, text=True)
+
+
+def _restart(cluster, datadir, log, env):
+ """Restart the private cluster with env, and wait for it as the shell's
+ pg_restart_env does: up to 15 s for SELECT 1."""
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "fast", "-w", "stop"], {})
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], env)
+ for _ in range(30):
+ if started.returncode == 0 and _row(cluster.dsn(), "SELECT 1") == "1":
+ return
+ time.sleep(0.5)
+ raise RuntimeError(f"cluster did not come back after env restart: {started.stderr.strip()}")
+
+
+@pytest.fixture(scope="module")
+def osa(pgc_cluster):
+ if not _resolves(f"{BUCKET}.s3.local"):
+ yield {"absent": ("UNAVAILABLE_ENDPOINT", f"{BUCKET}.s3.local does not resolve")}
+ return
+ from pgc_cluster import Cluster, pick_port
+
+ work = pathlib.Path(tempfile.mkdtemp(prefix="pgc-osa-"))
+ os.chmod(work, 0o777)
+ (work / BUCKET / "dir sp").mkdir(parents=True)
+ wrote = _write_parquet(work / BUCKET / "vh.parquet", [1, 2, 3, 4]) or \
+ _write_parquet(work / BUCKET / ENCKEY, [5, 6, 7])
+ if wrote is not None:
+ shutil.rmtree(work, ignore_errors=True)
+ yield {"absent": wrote}
+ return
+
+ s3_port = pick_port(29)
+ out, s3log = work / "s3_server.out", str(work / "s3.log")
+ srv = subprocess.Popen([sys.executable, str(TEST / "objstore_http_server.py"), "--dir", str(work),
+ "--port", str(s3_port), "--log", s3log,
+ "--sigv4-key", AKID, "--sigv4-secret", SECRET, "--sigv4-region", REGION,
+ "--virtual-host-base", "s3.local"],
+ stdout=open(out, "w"), stderr=subprocess.STDOUT)
+ for _ in range(50):
+ if out.is_file() and "READY" in out.read_text(errors="replace"):
+ break
+ time.sleep(0.1)
+
+ datadir = work / "data"
+ datadir.mkdir()
+ log = str(work / "server.log")
+ cluster = Cluster(pgc_cluster.pg_config, "osa", datadir, pick_port(28))
+ r = {"absent": None, "server up": out.read_text(errors="replace").count("READY") if out.is_file() else 0}
+
+ def logged(key, *sqls):
+ pathlib.Path(s3log).write_text("")
+ r[key] = _row(dsn, *sqls)
+
+ try:
+ cluster.initdb()
+ with open(datadir / "postgresql.conf", "a") as fh:
+ fh.write("\npgcolumnar.objstore_allowed_endpoints='s3.local'\n")
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], {})
+ if started.returncode != 0:
+ raise RuntimeError(f"the private cluster did not start: {started.stderr}")
+ dsn = cluster.dsn()
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+
+ creds = {"AWS_ACCESS_KEY_ID": AKID, "AWS_SECRET_ACCESS_KEY": SECRET, "AWS_REGION": REGION}
+ _restart(cluster, datadir, log, dict(creds, AWS_ENDPOINT_URL=f"http://s3.local:{s3_port}"))
+ vh, enc = f"('s3://{BUCKET}/vh.parquet') AS t(id int8)", f"('s3://{BUCKET}/{ENCKEY}') AS t(id int8)"
+ logged("path read", RD + vh)
+ r["path sent"] = _sent(s3log, f"/{BUCKET}/vh.parquet")
+ logged("path enc read", RD + enc)
+ r["path enc sent"] = _sent(s3log, f"/{BUCKET}/dir%20sp/a%2Bb%3Dc.parquet")
+
+ logged("gs read", RD + f"('gs://{BUCKET}/vh.parquet') AS t(id int8)")
+ _row(dsn, "CREATE TABLE gct (id int8) USING pgcolumnar",
+ "INSERT INTO gct VALUES (5),(6),(7) RETURNING 1")
+ r["gs write"] = _row(dsn, f"SELECT (pgcolumnar.export_parquet('gct', 'gs://{BUCKET}/out.parquet') > 0)::text")
+ r["gs readback"] = _row(dsn, RD + f"('gs://{BUCKET}/out.parquet') AS t(id int8)")
+
+ logged("vhost read", VIRTUAL, RD + vh)
+ r["vhost sent"] = _sent(s3log, "/vh.parquet", unwanted=f"/{BUCKET}/vh.parquet")
+ logged("vhost enc read", VIRTUAL, RD + enc)
+ r["vhost enc sent"] = _sent(s3log, "/dir%20sp/a%2Bb%3Dc.parquet")
+ logged("path again read", PATH, RD + vh)
+ r["path again sent"] = _sent(s3log, f"/{BUCKET}/vh.parquet")
+
+ _restart(cluster, datadir, log, creds)
+ r["s3 no endpoint"] = _refusal(dsn, RD + vh, "AWS_ENDPOINT_URL", " naming AWS_ENDPOINT_URL")
+ r["gs no endpoint"] = _refusal(
+ dsn, RD + f"('gs://{BUCKET}/vh.parquet') AS t(id int8)",
+ 'endpoint "storage.googleapis.com:443" is not in pgcolumnar.objstore_allowed_endpoints',
+ " by the allow-list naming storage.googleapis.com:443")
+ yield r
+ finally:
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "immediate", "-w", "stop"], {})
+ srv.kill()
+ srv.wait()
+ shutil.rmtree(work, ignore_errors=True)
+
+
+def _declined(osa, expect):
+ if osa["absent"] is None:
+ return False
+ expect.cannot_run(*osa["absent"])
+ return True
+
+
+def test_the_fixture_server_is_up(osa, expect):
+ """The shell skips the whole suite without pyarrow, or where the virtual-host name
+ does not resolve, under these names."""
+ if expect.require_module("pyarrow", name="pyarrow is required to write the fixture") is None:
+ return
+ if osa["absent"] is not None:
+ expect.cannot_run(*osa["absent"],
+ name="pgc-bucket.s3.local does not resolve; add it to /etc/hosts (-> 127.0.0.1)")
+ return
+ expect.text(str(osa["server up"]), "1", "premise: fixture up")
+
+
+def test_an_s3_path_style_read(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["path read"], "4 10", "s3:// path-style read")
+
+
+def test_path_style_puts_the_bucket_in_the_request_path(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["path sent"], "yes", "path-style put the bucket in the request path")
+
+
+def test_a_key_needing_encoding_reads_back_path_style(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["path enc read"], "3 18", "a key with a space, '+' and '=' reads back (path-style)")
+
+
+def test_the_path_style_request_path_is_the_key_encoded_once(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["path enc sent"], "yes", "and its path-style request path is the key URI-encoded once")
+
+
+def test_a_gs_read_round_trips(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["gs read"], "4 10", "gs:// read round-trips (scheme handled, HMAC creds reused)")
+
+
+def test_a_gs_write_round_trips(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["gs write"], "true", "gs:// write round-trips")
+
+
+def test_the_gs_written_object_reads_back(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["gs readback"], "3 18", "gs:// read-back of the written object")
+
+
+def test_a_virtual_host_read_equals_the_path_style_read(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["vhost read"], "4 10",
+ "virtual-host read == path-style read (same object, different addressing)")
+
+
+def test_virtual_host_puts_the_key_alone_in_the_path(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["vhost sent"], "yes", "virtual-host put the KEY ALONE in the path (no bucket segment)")
+
+
+def test_a_key_needing_encoding_reads_back_virtual_host(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["vhost enc read"], "3 18", "a key with a space, '+' and '=' reads back (virtual-host)")
+
+
+def test_the_virtual_host_request_path_is_the_key_alone_encoded_once(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["vhost enc sent"], "yes",
+ "and its virtual-host request path is the key alone, URI-encoded once")
+
+
+def test_addressing_path_puts_the_bucket_back_in_the_path(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["path again read"], "4 10",
+ "removal proof: with addressing=path the bucket is back in the path")
+
+
+def test_the_request_path_carries_the_bucket_again(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["path again sent"], "yes", "and the request path carried the bucket again")
+
+
+def test_s3_with_no_endpoint_demands_one(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["s3 no endpoint"], "28000 naming AWS_ENDPOINT_URL",
+ "s3:// with no endpoint demands one (names AWS_ENDPOINT_URL)")
+
+
+def test_gs_with_no_endpoint_goes_to_storage_googleapis_com(osa, expect):
+ if _declined(osa, expect):
+ return
+ expect.text(osa["gs no endpoint"], "42501 by the allow-list naming storage.googleapis.com:443",
+ "gs:// with no endpoint goes to storage.googleapis.com (the allow-list refuses that host by name)")
diff --git a/test/pytest/test_objstore_http_read.py b/test/pytest/test_objstore_http_read.py
new file mode 100644
index 000000000..40c6cdcbf
--- /dev/null
+++ b/test/pytest/test_objstore_http_read.py
@@ -0,0 +1,433 @@
+"""The HTTP ranged byte source (#393 M1): the pytest twin of
+`test/objstore_http_read.sh`, every check by the shell's name (#1341).
+
+Reads go through the shell's own fixture server (test/objstore_http_server.py), which
+logs one line per request: `METHOD path query range`. That log is the seam.
+
+A a foreign table over http://127.0.0.1 returns what the byte-identical local file
+ returns.
+B the request count, ASSERTED: buffered == 4 + G * width (1 HEAD and 3 ranged GETs to
+ open, one GET per needed chunk), unbuffered >= 10x that, and every GET carried a
+ Range header.
+C A REFUSAL IS NOT ANY ERROR. Many guards share 08006 and 08P01, so each refusal must
+ carry its own guard's SQLSTATE AND message: an unreachable port, a 404, a server
+ that ignores Range, a short 206 body, EOF mid-body, and a fresh connection the
+ server hangs up on (asked once, never retried). A keep-alive connection the server
+ dropped is the one failure that is retried, and the read equals the local one.
+D statement_timeout cancels a transfer the server wedged mid-body, within 10 s.
+Survival: the server answers and the server log's "terminated by signal" count has
+not moved -- waited for first, since a crashed server comes back.
+
+Every statement runs on its own connection, as the shell's psql calls do, so each
+read opens the object afresh and the request counts are the shell's.
+
+The parquet fixture is written by a python3 child, as the shell's heredoc writes it.
+"""
+import pathlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+TEST = pathlib.Path(__file__).resolve().parents[1]
+G, NCOLS, ROWS_PER_GROUP = 3, 8, 8000
+COLDEFS = ", ".join(f"c{i} int8" for i in range(NCOLS))
+SUM_ALL = " + ".join(f"sum(c{i})" for i in range(NCOLS))
+K_FULL, K_PROJ = 4 + G * NCOLS, 4 + G * 2
+ALLOW = "SET pgcolumnar.objstore_allowed_endpoints = '127.0.0.1'"
+SCHEMA = "pgc_objstore_http_read"
+
+WRITER = """
+import sys
+import pyarrow as pa, pyarrow.parquet as pq
+G, NCOLS, RPG = %d, %d, %d
+n = G * RPG
+cols = {("c%%d" %% i): pa.array([(i + 1) * v for v in range(n)], pa.int64())
+ for i in range(NCOLS)}
+pq.write_table(pa.table(cols), sys.argv[1], row_group_size=RPG, data_page_size=1024,
+ compression="NONE", use_dictionary=False)
+assert pq.ParquetFile(sys.argv[1]).metadata.num_row_groups == G
+""" % (G, NCOLS, ROWS_PER_GROUP)
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _connect(dsn):
+ conn = psycopg.connect(dsn, autocommit=True, connect_timeout=10)
+ conn.execute(ALLOW)
+ conn.execute(f"SET search_path = {SCHEMA}, public")
+ return conn
+
+
+def _value(dsn, sql):
+ """-> the first row's columns joined by a space, or a query_error()."""
+ try:
+ with _connect(dsn) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None or any(v is None for v in row):
+ return query_error(f"no value from {sql}")
+ return " ".join(str(v) for v in row)
+
+
+def _set_hash(dsn, sql):
+ """The shell's pgc_set_hash: md5 of the rows' text, sorted; EMPTY for no rows."""
+ return _value(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), $e$EMPTY$e$) "
+ f"FROM (SELECT _row::text AS t FROM ( {sql} ) _row) _s")
+
+
+def _await_server(dsn):
+ """The shell's await_server: wait up to 30 s for SELECT 1 -> what it last returned.
+ A crash restarts the server, so every reading after one waits for it: otherwise
+ the next reading fails on recovery instead of measuring its own guard."""
+ up = None
+ for _ in range(60):
+ up = _value(dsn, "SELECT 1")
+ if up == "1":
+ break
+ time.sleep(0.5)
+ return up
+
+
+def _refusal(dsn, sql, phrase):
+ """The shell's refusal_of: -> "SQLSTATE: phrase" when `sql` failed with `phrase` in
+ its primary message, else the SQLSTATE and the message it did give, or what came
+ back instead of an error."""
+ _await_server(dsn)
+ try:
+ with _connect(dsn) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return query_error(_message(exc))
+ primary = exc.diag.message_primary or ""
+ return f"{exc.sqlstate}: " + (phrase if phrase in primary else primary)
+ return "no error"
+
+
+def _requests(log):
+ """-> the request log's lines split into fields, or a query_error()."""
+ try:
+ return [l.split() for l in pathlib.Path(log).read_text(errors="replace").splitlines()]
+ except OSError as exc:
+ return query_error(f"request log unreadable: {exc}")
+
+
+def _count(log, keep=lambda f: True):
+ lines = _requests(log)
+ return lines if not isinstance(lines, list) else sum(1 for f in lines if keep(f))
+
+
+def _crashes(log):
+ try:
+ return pathlib.Path(log).read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _write_fixture(path):
+ """-> None, or the decline when pyarrow is absent. Any other failure raises."""
+ w = subprocess.run([sys.executable, "-c", WRITER, str(path)], capture_output=True, text=True)
+ if w.returncode == 0:
+ return None
+ if "No module named 'pyarrow" in w.stderr:
+ return ("MISSING_DEPENDENCY", "pyarrow is not installed")
+ raise RuntimeError(f"writing {path} failed: {w.stderr.strip()}")
+
+
+@pytest.fixture(scope="module")
+def ohr(pgc_cluster):
+ from pgc_cluster import pick_port
+
+ work = pathlib.Path(tempfile.mkdtemp(prefix="pgc-ohr-"))
+ work.chmod(0o755)
+ fixture = work / "fixture.parquet"
+ wrote = _write_fixture(fixture)
+ if wrote is not None:
+ shutil.rmtree(work, ignore_errors=True)
+ yield {"absent": wrote}
+ return
+ fixture.chmod(0o644)
+
+ port = pick_port(31)
+ out, hlog = work / "http_server.out", str(work / "http.log")
+ srv = subprocess.Popen([sys.executable, str(TEST / "objstore_http_server.py"), "--dir", str(work),
+ "--port", str(port), "--log", hlog],
+ stdout=open(out, "w"), stderr=subprocess.STDOUT)
+ for _ in range(50):
+ if out.is_file() and "READY" in out.read_text(errors="replace"):
+ break
+ time.sleep(0.1)
+
+ dsn, plog = pgc_cluster.dsn(), pgc_cluster.datadir / "server.log"
+ base = f"http://127.0.0.1:{port}"
+ r = {"absent": None, "fixture": "yes" if fixture.stat().st_size > 0 else "no",
+ "server up": out.read_text(errors="replace").count("READY") if out.is_file() else 0}
+
+ def logged(sql):
+ """Truncate the request log, run `sql`, -> how many requests it logged."""
+ pathlib.Path(hlog).write_text("")
+ _value(dsn, sql)
+ return _count(hlog)
+
+ try:
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"DROP SCHEMA IF EXISTS {SCHEMA} CASCADE")
+ c.execute("DROP SERVER IF EXISTS ohr_srv CASCADE")
+ c.execute(f"CREATE SCHEMA {SCHEMA}")
+ c.execute("CREATE SERVER ohr_srv FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ dead = pick_port(33)
+ for name, path in (("flocal", str(fixture)), ("fhttp", f"{base}/fixture.parquet"),
+ ("fdead", f"http://127.0.0.1:{dead}/fixture.parquet"),
+ ("f404", f"{base}/no_such.parquet"),
+ ("fnorange", f"{base}/norange/fixture.parquet"),
+ ("fshort", f"{base}/short/fixture.parquet"),
+ ("ftrunc", f"{base}/trunc/fixture.parquet"),
+ ("fdropka", f"{base}/dropka/fixture.parquet"),
+ ("fhangup", f"{base}/hangup/fixture.parquet"),
+ ("fstall", f"{base}/stall/fixture.parquet")):
+ c.execute(f"CREATE FOREIGN TABLE {SCHEMA}.{name} ({COLDEFS}) SERVER ohr_srv "
+ f"OPTIONS (path '{path}')")
+ r["crashes0"] = _crashes(plog)
+
+ r["A full"] = (_set_hash(dsn, "SELECT * FROM fhttp"), _set_hash(dsn, "SELECT * FROM flocal"))
+ r["A proj"] = (_set_hash(dsn, "SELECT c1, c6 FROM fhttp"),
+ _set_hash(dsn, "SELECT c1, c6 FROM flocal"))
+ r["A count"] = _value(dsn, "SELECT count(*) FROM fhttp")
+
+ r["full buf"] = logged(f"SELECT {SUM_ALL} FROM fhttp")
+ r["proj buf"] = logged("SELECT sum(c1) + sum(c6) FROM fhttp")
+ pathlib.Path(hlog).write_text("")
+ try:
+ with _connect(dsn) as conn:
+ conn.execute("SET pgcolumnar.objstore_buffered = off")
+ conn.execute(f"SELECT {SUM_ALL} FROM fhttp").fetchall()
+ except psycopg.Error:
+ pass # the count below is the reading, as in the shell
+ r["full unbuf"] = _count(hlog)
+
+ pathlib.Path(hlog).write_text("")
+ _value(dsn, "SELECT sum(c0) FROM fhttp")
+ r["gets"] = _count(hlog, lambda f: f[0] == "GET")
+ r["norange"] = _count(hlog, lambda f: f[0] == "GET" and len(f) > 3 and f[3] == "-")
+
+ r["C dead"] = _refusal(dsn, "SELECT count(*) FROM fdead", "could not connect to")
+ r["C 404"] = _refusal(dsn, "SELECT count(*) FROM f404", "does not exist (HTTP 404)")
+ r["C norange"] = _refusal(dsn, "SELECT count(*) FROM fnorange", "ignored a Range request")
+ r["C short"] = _refusal(dsn, "SELECT count(*) FROM fshort", "requested bytes")
+ r["C trunc"] = _refusal(dsn, "SELECT count(*) FROM ftrunc", "closed mid-body")
+ _await_server(dsn)
+ r["C dropka"] = (_set_hash(dsn, "SELECT c2, c5 FROM fdropka"),
+ _set_hash(dsn, "SELECT c2, c5 FROM flocal"))
+ pathlib.Path(hlog).write_text("")
+ r["C hangup"] = _refusal(dsn, "SELECT count(*) FROM fhangup", "failed before a response")
+ r["C hangup asked"] = _count(hlog)
+
+ _await_server(dsn)
+ t0 = time.monotonic()
+ try:
+ with _connect(dsn) as conn:
+ conn.execute("SET statement_timeout = '2s'")
+ conn.execute("SELECT count(*) FROM fstall").fetchall()
+ r["D state"] = "no error"
+ except psycopg.Error as exc:
+ r["D state"] = exc.sqlstate or query_error(_message(exc))
+ r["D elapsed"] = time.monotonic() - t0
+
+ r["up"] = _await_server(dsn)
+ r["crashes1"] = _crashes(plog)
+ yield r
+ finally:
+ try:
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"DROP SCHEMA IF EXISTS {SCHEMA} CASCADE")
+ c.execute("DROP SERVER IF EXISTS ohr_srv CASCADE")
+ except psycopg.Error:
+ pass
+ srv.kill()
+ srv.wait()
+ shutil.rmtree(work, ignore_errors=True)
+
+
+def _declined(ohr, expect):
+ if ohr["absent"] is None:
+ return False
+ expect.cannot_run(*ohr["absent"])
+ return True
+
+
+def _positive(n):
+ return "yes" if isinstance(n, int) and n > 0 else f"no ({n})"
+
+
+def test_the_http_read_fixture_is_written(ohr, expect):
+ """The shell skips the whole suite without pyarrow, under this name."""
+ if expect.require_module("pyarrow", name="pyarrow is required to write the fixture") is None:
+ return
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["fixture"], "yes", "premise: fixture written")
+
+
+def test_the_http_read_fixture_server_is_up(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.num(ohr["server up"], 1, "premise: fixture server is up")
+
+
+def test_a_full_scan_over_http_equals_the_local_file(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.hash(*ohr["A full"], "A: full scan http == local")
+
+
+def test_a_projection_over_http_equals_the_local_file(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.hash(*ohr["A proj"], "A: projection http == local")
+
+
+def test_count_over_http(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["A count"], str(G * ROWS_PER_GROUP), "A: count(*) over http")
+
+
+def test_the_buffered_run_logged_requests(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(_positive(ohr["full buf"]), "yes", "premise: buffered run logged requests")
+
+
+def test_the_unbuffered_run_logged_requests(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(_positive(ohr["full unbuf"]), "yes", "premise: unbuffered run logged requests")
+
+
+def test_buffered_full_width_requests(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.num(ohr["full buf"], K_FULL, "B: buffered full-width requests (K = 4 + G*NCOLS)")
+
+
+def test_buffered_two_column_requests(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.num(ohr["proj buf"], K_PROJ, "B: buffered 2-col requests (K = 4 + G*2)")
+
+
+def test_unbuffered_is_at_least_ten_times_k(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.at_least(ohr["full unbuf"], 10 * K_FULL, "B: unbuffered >= 10x K")
+
+
+def test_the_buffered_to_unbuffered_request_ratio(ohr, expect):
+ """The shell's check_ratio: a / b rounded to two places, within 0.1x; a zero side
+ measured nothing."""
+ if _declined(ohr, expect):
+ return
+ a, b = ohr["full buf"], ohr["full unbuf"]
+ if not isinstance(a, int) or not isinstance(b, int) or a == 0 or b == 0:
+ got = f"nothing measured: a=[{a}] b=[{b}]"
+ else:
+ ratio = float(f"{a / b:.2f}")
+ got = "within the 0.1x bound" if ratio <= 0.1 else f"{ratio}x (a={a} b={b})"
+ expect.text(got, "within the 0.1x bound", "B: buffered/unbuffered request ratio")
+
+
+def test_the_range_audit_phase_logged_gets(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(_positive(ohr["gets"]), "yes", "premise: the range-audit phase logged GETs")
+
+
+def test_every_get_carried_a_range_header(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.num(ohr["norange"], 0, "B: every GET carried a Range header")
+
+
+def test_an_unreachable_endpoint_is_refused(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["C dead"], "08006: could not connect to", "C: unreachable endpoint SQLSTATE")
+
+
+def test_a_404_is_refused(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["C 404"], "58P01: does not exist (HTTP 404)", "C: http 404 SQLSTATE")
+
+
+def test_a_range_ignoring_server_is_refused(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["C norange"], "08P01: ignored a Range request", "C: range-ignoring server SQLSTATE")
+
+
+def test_a_short_206_body_is_refused(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["C short"], "08P01: requested bytes", "C: a short 206 body is refused")
+
+
+def test_eof_mid_body_is_refused(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["C trunc"], "08006: closed mid-body", "C: EOF mid-body is refused")
+
+
+def test_a_dropped_keep_alive_connection_is_retried(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.hash(*ohr["C dropka"], "C: a dropped keep-alive connection is retried (http == local)")
+
+
+def test_a_server_that_hangs_up_is_refused(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["C hangup"], "08006: failed before a response",
+ "C: a server that hangs up on a fresh connection is refused")
+
+
+def test_a_fresh_connection_is_not_retried(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.num(ohr["C hangup asked"], 1, "C: and that fresh connection is not retried (one request logged)")
+
+
+def test_a_stalled_transfer_is_cancelled(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ expect.text(ohr["D state"], "57014", "D: stalled transfer cancelled with query_canceled")
+
+
+def test_the_http_read_cancel_latency_is_bounded(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ s = ohr["D elapsed"]
+ expect.text("within 10s" if s <= 10 else f"{s:.1f}s", "within 10s", "D: cancel latency bounded (<=10s)")
+
+
+def test_the_server_survives(ohr, expect):
+ if _declined(ohr, expect):
+ return
+ c0, c1, up = ohr["crashes0"], ohr["crashes1"], ohr["up"]
+ if not isinstance(c0, int) or not isinstance(c1, int):
+ got = "no (crash count unreadable)"
+ elif up == "1" and c1 == c0:
+ got = "1"
+ else:
+ got = f"no ([{up}], {c1 - c0} backend crashes)"
+ expect.text(got, "1", "survival: the server answers and no backend crashed")
diff --git a/test/pytest/test_objstore_listing.py b/test/pytest/test_objstore_listing.py
new file mode 100644
index 000000000..ac4ad51df
--- /dev/null
+++ b/test/pytest/test_objstore_listing.py
@@ -0,0 +1,380 @@
+"""ListObjectsV2 discovery over remote paths (#619): the pytest twin of
+`test/objstore_listing.sh`, every check by the shell's name (#1341).
+
+read_parquet and the FDW expand a remote prefix (trailing slash) or a remote glob into
+the objects under it, through a paged ListObjectsV2 loop behind the object-store ABI;
+Hive partition columns work over a remote prefix; an exact key is one GET and no LIST.
+The local SigV4 fixture server (test/objstore_http_server.py, the shell's own) answers
+the listing two keys a page, so the continuation-token loop runs.
+
+EACH REFUSAL IS ITS OWN GUARD'S. The two non-progress refusals share 08P01, so each
+check requires its guard's own message: with the no-keys guard removed, the replayed
+page reaches the token guard and still raises 08P01. A LIST that fails (HTTP 500) must
+be refused by the status check, not read as an empty prefix.
+
+A BACKEND THAT DIED DOES NOT PASS AS ONE THAT SURVIVED: survival reads the private
+cluster's server log for killed backends, and every reading waits for the server, so a
+crash fails only its own check and survival.
+
+A PRIVATE CLUSTER, restarted with AWS_* in its environment, which the shared cluster
+cannot do. The parquet fixtures are written by a subprocess (this module imports no
+pyarrow); without pyarrow every test declines under the shell's skip message.
+"""
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+TEST = pathlib.Path(__file__).resolve().parents[1]
+AKID, SECRET, REGION, BUCKET = "PGCTESTKEYID", "pgctest-secret-shhh", "pgc-test-1", "pgc-bucket"
+
+WRITE_FIXTURES = r'''
+import sys, os
+import pyarrow as pa, pyarrow.parquet as pq
+B = sys.argv[1]
+def one(path, ids):
+ pq.write_table(pa.table({"id": pa.array(ids, pa.int64())}), path)
+one(os.path.join(B, "lst/part-0000.parquet"), [0, 1, 2])
+one(os.path.join(B, "lst/part-0001.parquet"), [10, 11, 12])
+one(os.path.join(B, "lst/part-0002.parquet"), [20, 21, 22])
+one(os.path.join(B, "lst/sub/part-0003.parquet"), [30, 31, 32])
+open(os.path.join(B, "lst/_SUCCESS"), "w").close()
+open(os.path.join(B, "lst/notes.txt"), "w").write("not parquet\n")
+one(os.path.join(B, "hive/dt=2026-01-01/part.parquet"), [1, 2, 3])
+one(os.path.join(B, "hive/dt=2026-01-02/part.parquet"), [4, 5, 6])
+'''
+
+STUCK = ('true '
+ 'STUCK '
+ 'lst/part-0000.parquet \n')
+EMPTY = ('true '
+ 'next-1 \n')
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _wait_up(dsn):
+ """Wait up to 15 s for the server, as the shell's readings do after a refusal."""
+ for _ in range(30):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _row(dsn, sql):
+ """-> the first row's columns joined by a space, or the failure as a query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None:
+ return query_error(f"no row from {sql}")
+ return " ".join("" if v is None else str(v) for v in row)
+
+
+def _rows(dsn, sql):
+ """-> every row as text, sorted and joined by newlines, or the failure as a query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ rows = conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ return "\n".join(sorted(" ".join(str(v) for v in r) for r in rows)) or "EMPTY"
+
+
+def _refusal(dsn, sql, phrase, label):
+ """-> "" when `sql`, under a 10 s statement_timeout, raised with `phrase` in its message, the bare
+ SQLSTATE when the message lacks it, or what happened instead."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute("SET statement_timeout = '10s'")
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return query_error(_message(exc))
+ return exc.sqlstate + (label if phrase in str(exc) else "")
+ return "no error"
+
+
+def _list_requests(log):
+ try:
+ return sum(1 for line in pathlib.Path(log).read_text(errors="replace").splitlines()
+ if "list-type=2" in line)
+ except OSError as exc:
+ return query_error(f"request log unreadable: {exc}")
+
+
+def _key_fetched(log, path):
+ """-> "logged" when the request log records a GET or HEAD of `path`, else "not logged"."""
+ try:
+ lines = pathlib.Path(log).read_text(errors="replace").splitlines()
+ except OSError as exc:
+ return query_error(f"request log unreadable: {exc}")
+ hit = any(line.split(" ")[:2] in (["GET", path], ["HEAD", path]) for line in lines)
+ return "logged" if hit else "not logged"
+
+
+def _crashes(log):
+ try:
+ return pathlib.Path(log).read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, log, before):
+ _wait_up(dsn)
+ up, now = _row(dsn, "SELECT 1"), _crashes(log)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ return "1" if up == "1" and now == before else f"no ([{up}], {now - before} backend crashes)"
+
+
+def _pg_ctl(cluster, args, extra_env):
+ """pg_ctl with an explicit environment: every AWS_* of this process removed, then
+ `extra_env` added. Dropped to postgres when running as root, as the harness does."""
+ unset = [a for k in os.environ if k.startswith("AWS_") for a in ("-u", k)]
+ cmd = ["env"] + unset + [f"{k}={v}" for k, v in extra_env.items()] + \
+ [os.path.join(cluster.bindir, "pg_ctl")] + args
+ if os.geteuid() == 0:
+ cmd = ["runuser", "-u", "postgres", "--"] + cmd
+ return subprocess.run(cmd, capture_output=True, text=True)
+
+
+def _restart(cluster, datadir, log, env):
+ """Restart the private cluster with env and wait for it, as pg_restart_env does."""
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "fast", "-w", "stop"], {})
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], env)
+ for _ in range(30):
+ if started.returncode == 0 and _row(cluster.dsn(), "SELECT 1") == "1":
+ return
+ time.sleep(0.5)
+ raise RuntimeError(f"cluster did not come back after env restart: {started.stderr.strip()}")
+
+
+@pytest.fixture(scope="module")
+def osl(pgc_cluster):
+ from pgc_cluster import Cluster, pick_port
+
+ work = pathlib.Path(tempfile.mkdtemp(prefix="pgc-objlist-"))
+ os.chmod(work, 0o777)
+ bucket = work / BUCKET
+ for d in ("lst/sub", "hive/dt=2026-01-01", "hive/dt=2026-01-02"):
+ (bucket / d).mkdir(parents=True)
+ r = {}
+ srv = None
+ cluster = None
+ datadir = work / "data"
+ try:
+ made = subprocess.run([sys.executable, "-c", WRITE_FIXTURES, str(bucket)],
+ capture_output=True, text=True)
+ if made.returncode != 0:
+ raise RuntimeError(f"the parquet fixtures were not written: {made.stderr.strip()}")
+ for p in [bucket, *bucket.rglob("*")]:
+ p.chmod(0o777 if p.is_dir() else 0o666)
+
+ s3_port = pick_port(29)
+ out = work / "s3_server.out"
+ s3log = str(work / "s3.log")
+ # page size 2 forces the continuation-token loop for the 4-object prefix
+ srv = subprocess.Popen([sys.executable, str(TEST / "objstore_http_server.py"), "--dir", str(work),
+ "--port", str(s3_port), "--log", s3log,
+ "--sigv4-key", AKID, "--sigv4-secret", SECRET, "--sigv4-region", REGION,
+ "--list-page-size", "2"],
+ stdout=open(out, "w"), stderr=subprocess.STDOUT)
+ for _ in range(50):
+ if out.is_file() and "READY" in out.read_text(errors="replace"):
+ break
+ time.sleep(0.1)
+ r["fixture up"] = str(out.read_text(errors="replace").count("READY") if out.is_file() else 0)
+
+ datadir.mkdir()
+ log = str(work / "server.log")
+ cluster = Cluster(pgc_cluster.pg_config, "objlist", datadir, pick_port(28))
+ cluster.initdb()
+ with open(datadir / "postgresql.conf", "a") as fh:
+ fh.write("\npgcolumnar.objstore_allowed_endpoints='127.0.0.1'\n")
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], {})
+ if started.returncode != 0:
+ raise RuntimeError(f"the private cluster did not start: {started.stderr}")
+ dsn = cluster.dsn()
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ _restart(cluster, datadir, log, {"AWS_ENDPOINT_URL": f"http://127.0.0.1:{s3_port}",
+ "AWS_ACCESS_KEY_ID": AKID, "AWS_SECRET_ACCESS_KEY": SECRET,
+ "AWS_REGION": REGION})
+ before = _crashes(log)
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE SERVER s3srv FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ rp = f"pgcolumnar.read_parquet('s3://{BUCKET}/lst/') AS t(id int8)"
+
+ open(s3log, "w").close()
+ r["prefix"] = _row(dsn, f"SELECT count(*), sum(id) FROM {rp}")
+ n = _list_requests(s3log)
+ r["paged"] = ("paged" if n >= 2 else f"not paged ({n} list requests)") if isinstance(n, int) else n
+
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"CREATE FOREIGN TABLE fprefix (id int8) SERVER s3srv OPTIONS (path 's3://{BUCKET}/lst/')")
+ r["fdw rows"] = _rows(dsn, "SELECT * FROM fprefix")
+ r["rp rows"] = _rows(dsn, f"SELECT * FROM {rp}")
+ r["excluded"] = _row(dsn, "SELECT count(*) FROM fprefix")
+ r["glob"] = _row(dsn, f"SELECT count(*), sum(id) FROM pgcolumnar.read_parquet("
+ f"'s3://{BUCKET}/lst/*.parquet') AS t(id int8)")
+
+ open(s3log, "w").close()
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"CREATE FOREIGN TABLE fexact (id int8) SERVER s3srv "
+ f"OPTIONS (path 's3://{BUCKET}/lst/part-0000.parquet')")
+ r["exact"] = _row(dsn, "SELECT count(*), sum(id) FROM fexact")
+ n = _list_requests(s3log)
+ r["exact lists"] = str(n) if isinstance(n, int) else n
+ r["exact logged"] = _key_fetched(s3log, f"/{BUCKET}/lst/part-0000.parquet")
+
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"CREATE FOREIGN TABLE fhive (id int8, dt date) SERVER s3srv "
+ f"OPTIONS (path 's3://{BUCKET}/hive/', partition_columns 'dt')")
+ r["hive"] = _rows(dsn, "SELECT dt, count(*) FROM fhive GROUP BY dt ORDER BY dt")
+ plan = _rows(dsn, "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) "
+ "SELECT count(*) FROM fhive WHERE dt = DATE '2026-01-01'")
+ pruned = [ln for ln in plan.splitlines() if "Files Pruned" in ln] if not plan.startswith("QUERY_ERROR") else []
+ r["pruned"] = pruned[0].split(":")[-1].strip() if pruned else (plan if plan.startswith("QUERY_ERROR")
+ else "no Files Pruned line")
+
+ override = work / "__listing_override__"
+ stmt = f"SELECT count(*) FROM {rp}"
+ override.write_text(STUCK)
+ r["stuck"] = _refusal(dsn, stmt, "returned a non-advancing continuation token", " by the token check")
+ override.unlink()
+ override.write_text(EMPTY)
+ r["empty"] = _refusal(dsn, stmt, "returned a truncated page with no keys", " by the progress check")
+ override.unlink()
+ status = work / "__listing_status__"
+ status.write_text("500\n")
+ r["http500"] = _refusal(dsn, stmt, "returned HTTP 500", " by the HTTP status check")
+ status.unlink()
+ r["survived"] = _survived(dsn, log, before)
+ yield r
+ finally:
+ if cluster is not None:
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "immediate", "-w", "stop"], {})
+ if srv is not None:
+ srv.kill()
+ srv.wait()
+ shutil.rmtree(work, ignore_errors=True)
+
+
+def _readings(request, expect):
+ """-> the fixture's readings, or None after declining as the shell skips."""
+ if expect.require_module("pyarrow.parquet", name="pyarrow is required to write the fixture") is None:
+ return None
+ return request.getfixturevalue("osl")
+
+
+def test_the_listing_fixture_server_is_up(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["fixture up"], "1", "premise: fixture up")
+
+
+def test_read_parquet_over_a_prefix_reads_every_object_under_it(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["prefix"], "12 192",
+ "read_parquet over a prefix reads every object under it (4 parts, recursive)")
+
+
+def test_the_prefix_listing_paged(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["paged"], "paged", "the prefix listing paged (more than one list-type=2 request)")
+
+
+def test_the_fdw_over_a_prefix_reads_what_read_parquet_reads(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["fdw rows"], r["rp rows"], "FDW over a prefix == read_parquet over the prefix")
+
+
+def test_hidden_and_non_parquet_objects_are_excluded_from_the_listing(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["excluded"], "12", "hidden (_SUCCESS) and non-parquet (notes.txt) objects are excluded")
+
+
+def test_a_remote_glob_matches_only_the_top_level_parts(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["glob"], "9 99", "glob s3://.../lst/*.parquet matches the 3 top-level parts, not sub/")
+
+
+def test_an_exact_key_read_returns_its_rows(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["exact"], "3 3", "exact-key read returns its rows")
+
+
+def test_an_exact_key_read_issues_no_list(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["exact lists"], "0", "an exact-key read issues no ListObjectsV2")
+
+
+def test_the_exact_key_read_reached_the_request_log(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["exact logged"], "logged", "and the request log recorded the exact-key read")
+
+
+def test_hive_over_a_remote_prefix_stamps_the_partition_column(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["hive"], "2026-01-01 3\n2026-01-02 3",
+ "Hive over a remote prefix stamps the partition column")
+
+
+def test_a_partition_predicate_prunes_a_remote_file(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["pruned"], "1", "a partition predicate prunes a remote file (Files Pruned = 1)")
+
+
+def test_a_non_advancing_continuation_token_is_refused(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["stuck"], "08P01 by the token check",
+ "a non-advancing continuation token is refused, not an infinite paging loop")
+
+
+def test_a_truncated_page_with_no_keys_is_refused(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["empty"], "08P01 by the progress check", "a truncated page with no keys is refused, not looped")
+
+
+def test_a_list_that_fails_is_refused_not_read_as_empty(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["http500"], "08006 by the HTTP status check",
+ "a LIST that returns HTTP 500 is refused, not read as an empty prefix")
+
+
+def test_the_backend_is_still_up_after_the_listing_refusals(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["survived"], "1", "backend still up after the listing refusals")
diff --git a/test/pytest/test_objstore_module.py b/test/pytest/test_objstore_module.py
new file mode 100644
index 000000000..9c605a334
--- /dev/null
+++ b/test/pytest/test_objstore_module.py
@@ -0,0 +1,429 @@
+"""The object-store module's packaging and loader (#393): the pytest twin of
+`test/objstore_module.sh`, every check by the shell's name (#1341).
+
+The module is a separate, non-preloaded library, so its entry point must be absent from
+pgcolumnar.so and present in pgcolumnar_objstore.so (nm, as a pair, so a missing nm
+cannot pass the negative half). A remote URL is refused for what it lacks, never as a
+missing file; a remote glob is listed remotely, never matched against the local
+filesystem; a local path, read through read_parquet, still reads. Then the installed
+module is moved: present, absent and broken must be three different reports, each the
+same twice in one session.
+
+EACH REFUSAL IS PINNED, SQLSTATE AND MESSAGE. The shell took any of seven phrases, one
+of them "is not supported", so a module that stopped handling s3:// or gs:// passed; and
+its local-path check counted a columnar table, which never reaches read_parquet's
+dispatch (#1341). An https:// read reaches the allow-list only when the module links
+OpenSSL; without it the scheme is unhandled, by design, and `ldd` decides which.
+
+THE INSTALLED MODULE IS SHARED, so this file must run alone, as the shell runner keeps
+the shell suite in `runs_alone`. A pristine copy is taken first and put back in the
+fixture's finally. The session's postmaster inherits this process's environment, so an
+ambient AWS_* would turn the remote refusals into connection attempts: those tests then
+decline rather than red.
+"""
+import os
+import pathlib
+import re
+import shutil
+import subprocess
+import tempfile
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+AWS_VARS = ("AWS_ENDPOINT_URL", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY",
+ "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION")
+Q = "SELECT * FROM pgcolumnar.read_parquet('az://bucket/key.parquet') AS (a int)"
+
+
+def _errors(dsn, *sqls):
+ """-> the full text of each statement's error, in one session; a statement that
+ succeeded reads as a query_error naming that, never as text that could match."""
+ out = []
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for sql in sqls:
+ try:
+ conn.execute(sql)
+ out.append(query_error(f"no error from {sql}"))
+ except psycopg.Error as exc:
+ if exc.sqlstate is None:
+ raise
+ out.append(str(exc))
+ except psycopg.Error as exc:
+ return [query_error(f"connection failed: {exc}")] * len(sqls)
+ return out
+
+
+def _refusal(dsn, sql):
+ """-> "SQLSTATE: primary message" and the full error text, or a query_error."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ if exc.sqlstate is None:
+ v = query_error(str(exc))
+ return v, v
+ return f"{exc.sqlstate}: {exc.diag.message_primary}", str(exc)
+ v = query_error(f"no error from {sql}")
+ return v, v
+
+
+def _lines(text, pattern):
+ """grep -c: the lines of text matching pattern."""
+ return sum(1 for l in text.splitlines() if re.search(pattern, l))
+
+
+def _valid(path):
+ """A module is valid when nm finds the entry point: the shell's stash_is_debris."""
+ n = _nm_count(path, " T pgcolumnar_objstore_init") if path.exists() else 0
+ return isinstance(n, int) and n >= 1
+
+
+def _nm_count(path, pattern):
+ try:
+ r = subprocess.run(["nm", "-D", "--defined-only", str(path)], capture_output=True, text=True)
+ except OSError as exc:
+ return query_error(f"nm did not run: {exc}")
+ if r.returncode != 0:
+ return query_error(f"nm failed on {path}: {r.stderr.strip()}")
+ return _lines(r.stdout, pattern)
+
+
+def _https_refusal(mod):
+ try:
+ r = subprocess.run(["ldd", str(mod)], capture_output=True, text=True)
+ except OSError as exc:
+ return query_error(f"ldd did not run: {exc}")
+ if "libssl" in r.stdout:
+ return '42501: columnar: endpoint "host:443" is not in pgcolumnar.objstore_allowed_endpoints'
+ return '0A000: columnar: reading "https://host/key.parquet" is not supported'
+
+
+def _size(path):
+ try:
+ return path.stat().st_size
+ except OSError:
+ return query_error(f"{path} is not there")
+
+
+@pytest.fixture(scope="module")
+def osm(pgc_cluster, server_dir):
+ dsn = pgc_cluster.dsn()
+ lib = pathlib.Path(pgc_cluster.libdir)
+ mod = lib / "pgcolumnar_objstore.so"
+ probe, away = mod.with_name(mod.name + ".probe"), mod.with_name(mod.name + ".away")
+ r = {"ambient": [v for v in AWS_VARS if os.environ.get(v)], "unrecoverable": "none"}
+
+ # The shell's start guard: a stash an interrupted run left is debris beside a valid
+ # module (removed), or the only surviving copy (put back); one that is not a module
+ # either leaves nothing to measure. objstore_stash_recovery drives the shell's own.
+ for stash in (away, probe):
+ if not stash.exists():
+ continue
+ if _valid(mod):
+ stash.unlink()
+ continue
+ mod.unlink(missing_ok=True)
+ stash.rename(mod)
+ if not _valid(mod):
+ r["unrecoverable"] = f"restored {stash} to {mod}, but that is not a module either"
+ r["movable"] = False
+ r["unmovable"] = r["unrecoverable"]
+ return r
+ r["main installed"] = (lib / "pgcolumnar.so").is_file()
+ r["module installed"] = mod.is_file()
+ r["main nm"] = _nm_count(lib / "pgcolumnar.so", "pgcolumnar_objstore_init")
+ r["module nm"] = _nm_count(mod, " T pgcolumnar_objstore_init")
+
+ want = {
+ "s3://bucket/key.parquet":
+ '28000: columnar: reading "s3://bucket/key.parquet" requires AWS_ENDPOINT_URL in the server environment',
+ "gs://bucket/key.parquet":
+ '28000: columnar: reading "gs://bucket/key.parquet" requires AWS_ACCESS_KEY_ID in the server environment',
+ "https://host/key.parquet": _https_refusal(mod),
+ }
+ for url, w in want.items():
+ got, text = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{url}') AS (a int)")
+ r[url] = (got, w, _lines(text, "No such file or directory"))
+ for pat in ("s3://bucket/a*.parquet", "s3://bucket/a?.parquet", "s3://bucket/a[0-9].parquet"):
+ got, text = _refusal(dsn, f"SELECT * FROM pgcolumnar.read_parquet('{pat}') AS (a int)")
+ # the URL the listing's refusal names is the listing's, not the pattern
+ m = re.fullmatch(r'(28000): columnar: reading "s3:[^"]*" (requires AWS_ENDPOINT_URL) .*', got)
+ r[pat] = (" ".join(m.groups()) if m else got,
+ _lines(text, "no files match pattern|matched no regular files|No such file or directory"))
+
+ # a local file written by the server, read back through read_parquet
+ lp = server_dir / "lp.parquet"
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute("DROP TABLE IF EXISTS objstore_module_lp")
+ conn.execute("CREATE TABLE objstore_module_lp (a int) USING pgcolumnar")
+ conn.execute("INSERT INTO objstore_module_lp VALUES (1), (2), (3)")
+ conn.execute(f"SELECT pgcolumnar.export_parquet('objstore_module_lp', '{lp}')")
+ r["local"] = conn.execute(
+ f"SELECT count(*) FROM pgcolumnar.read_parquet('{lp}') AS (a int) WHERE a IN (1, 2, 3)"
+ ).fetchone()[0]
+ conn.execute("DROP TABLE objstore_module_lp")
+ except psycopg.Error as exc:
+ r["local"] = query_error(str(exc))
+ _, text = _refusal(dsn, "SELECT * FROM pgcolumnar.read_parquet('nonexistent-dir/x.parquet') AS (a int)")
+ r["relative"] = _lines(text, "No such file or directory|could not open")
+
+ # ---- everything below moves the installed module ----
+ r["movable"] = False
+ if not mod.is_file():
+ return r
+ fd, safe = tempfile.mkstemp(prefix="pgc-objstore-safe.")
+ os.close(fd)
+ safe = pathlib.Path(safe)
+ shutil.copy2(mod, safe)
+ try:
+ try:
+ mod.rename(probe)
+ probe.rename(mod)
+ r["movable"] = True
+ except OSError as exc:
+ r["unmovable"] = f"cannot move {mod}: {exc}"
+ return r
+ r["present"] = "\n".join(_errors(dsn, Q, Q))
+
+ mod.rename(away)
+ r["absent state"] = "present" if mod.exists() else "absent"
+ r["absent"] = "\n".join(_errors(dsn, Q, Q))
+ away.rename(mod)
+ r["back"] = mod.exists()
+
+ mod.rename(away)
+ mod.write_bytes(b"not a shared object")
+ r["corrupt size"] = _size(mod)
+ r["broken"] = "\n".join(_errors(dsn, Q, Q))
+ mod.unlink()
+ away.rename(mod)
+ r["restored size"] = _size(mod)
+ finally:
+ probe.unlink(missing_ok=True)
+ away.unlink(missing_ok=True)
+ if not (mod.is_file() and mod.read_bytes() == safe.read_bytes()):
+ shutil.copy2(safe, mod)
+ safe.unlink()
+ return r
+
+
+def _remote(osm, expect):
+ """True when the remote refusals cannot be measured here, declared as such."""
+ if osm["ambient"]:
+ expect.cannot_run("UNMET_PRECONDITION",
+ f"the postmaster inherits {', '.join(osm['ambient'])}, so a remote read "
+ "is attempted rather than refused")
+ return True
+ return False
+
+
+def _moved(osm, expect):
+ """True when the module could not be moved, declared under the shell's skip name."""
+ if osm["movable"]:
+ return False
+ expect.cannot_run("UNMET_PRECONDITION", osm.get("unmovable", "no module installed to move"),
+ name="the absent and broken module paths")
+ return True
+
+
+def _yes(b):
+ return "yes" if b else "no"
+
+
+def test_a_restored_stash_is_a_module(osm, expect):
+ """The shell records this only when it fails; here it is read every run."""
+ expect.text(osm["unrecoverable"], "none", "a stash restored over a missing module is a module")
+
+
+def test_the_main_library_is_installed(osm, expect):
+ expect.text(_yes(osm["main installed"]), "yes", "the main library is installed")
+
+
+def test_the_module_is_installed_beside_it(osm, expect):
+ expect.text(_yes(osm["module installed"]), "yes",
+ "the object-store module is installed BESIDE it, not inside it")
+
+
+def test_the_entry_point_is_not_in_the_preloaded_library(osm, expect):
+ expect.num(osm["main nm"], 0, "the module's entry point is NOT inside the preloaded library")
+
+
+def test_the_entry_point_is_defined_in_the_module(osm, expect):
+ expect.num(osm["module nm"], 1, "positive control: it IS defined in the module, so nm really looked")
+
+
+def test_an_s3_url_reports_an_object_storage_error(osm, expect):
+ scheme, url = "s3", "s3://bucket/key.parquet"
+ if _remote(osm, expect):
+ return
+ got, want, _ = osm[url]
+ expect.text(got, want, f"a {scheme} URL reports an object-storage error, not a missing file")
+
+
+def test_an_s3_url_is_not_a_missing_file(osm, expect):
+ scheme, url = "s3", "s3://bucket/key.parquet"
+ expect.num(osm[url][2], 0, f"and the {scheme} URL is NOT reported as a missing file")
+
+
+def test_a_gs_url_reports_an_object_storage_error(osm, expect):
+ scheme, url = "gs", "gs://bucket/key.parquet"
+ if _remote(osm, expect):
+ return
+ got, want, _ = osm[url]
+ expect.text(got, want, f"a {scheme} URL reports an object-storage error, not a missing file")
+
+
+def test_a_gs_url_is_not_a_missing_file(osm, expect):
+ scheme, url = "gs", "gs://bucket/key.parquet"
+ expect.num(osm[url][2], 0, f"and the {scheme} URL is NOT reported as a missing file")
+
+
+def test_an_https_url_reports_an_object_storage_error(osm, expect):
+ scheme, url = "https", "https://host/key.parquet"
+ got, want, _ = osm[url]
+ expect.text(got, want, f"a {scheme} URL reports an object-storage error, not a missing file")
+
+
+def test_an_https_url_is_not_a_missing_file(osm, expect):
+ scheme, url = "https", "https://host/key.parquet"
+ expect.num(osm[url][2], 0, f"and the {scheme} URL is NOT reported as a missing file")
+
+
+def test_a_star_glob_is_handled_remotely(osm, expect):
+ meta = "*"
+ if _remote(osm, expect):
+ return
+ expect.text(osm[f"s3://bucket/a{meta}.parquet"][0], "28000 requires AWS_ENDPOINT_URL",
+ f"a remote glob ({meta}) is handled remotely (an object-storage error, not a local one)")
+
+
+def test_a_star_glob_is_not_a_local_miss(osm, expect):
+ meta = "*"
+ expect.num(osm[f"s3://bucket/a{meta}.parquet"][1], 0,
+ f"and the {meta} glob is NOT reported as a local filesystem miss")
+
+
+def test_a_question_glob_is_handled_remotely(osm, expect):
+ meta = "?"
+ if _remote(osm, expect):
+ return
+ expect.text(osm[f"s3://bucket/a{meta}.parquet"][0], "28000 requires AWS_ENDPOINT_URL",
+ f"a remote glob ({meta}) is handled remotely (an object-storage error, not a local one)")
+
+
+def test_a_question_glob_is_not_a_local_miss(osm, expect):
+ meta = "?"
+ expect.num(osm[f"s3://bucket/a{meta}.parquet"][1], 0,
+ f"and the {meta} glob is NOT reported as a local filesystem miss")
+
+
+def test_a_bracket_glob_is_handled_remotely(osm, expect):
+ meta = "[0-9]"
+ if _remote(osm, expect):
+ return
+ expect.text(osm[f"s3://bucket/a{meta}.parquet"][0], "28000 requires AWS_ENDPOINT_URL",
+ f"a remote glob ({meta}) is handled remotely (an object-storage error, not a local one)")
+
+
+def test_a_bracket_glob_is_not_a_local_miss(osm, expect):
+ meta = "[0-9]"
+ expect.num(osm[f"s3://bucket/a{meta}.parquet"][1], 0,
+ f"and the {meta} glob is NOT reported as a local filesystem miss")
+
+
+def test_a_local_path_still_works(osm, expect):
+ expect.num(osm["local"], 3, "a local path still works")
+
+
+def test_a_relative_path_is_not_a_url(osm, expect):
+ expect.num(osm["relative"], 1, "a relative path is not mistaken for a URL")
+
+
+# ---- present, absent and broken: three different reports ----
+
+def test_present_both_reads_report_an_unhandled_scheme(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.num(_lines(osm["present"], "is not supported"), 2,
+ "with the module present, both reads report an unhandled scheme")
+
+
+def test_present_neither_claims_the_module_is_missing(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.num(_lines(osm["present"], "requires the object-store module"), 0,
+ "and neither claims the module is missing")
+
+
+def test_premise_the_module_was_absent(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.text(osm["absent state"], "absent", "premise: the module really was absent while those two reads ran")
+
+
+def test_premise_the_module_is_back(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.text(_yes(osm["back"]), "yes", "premise: and the real module is back afterwards")
+
+
+def test_absent_no_read_leaks_the_loader_error(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.num(_lines(osm["absent"], "could not access file"), 0,
+ "with the module absent, neither read leaks the loader's own error")
+
+
+def test_absent_both_reads_name_the_missing_module(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.num(_lines(osm["absent"], "requires the object-store module"), 2,
+ "both reads name the missing module, identically")
+
+
+def test_absent_neither_downgrades_to_an_unhandled_scheme(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.num(_lines(osm["absent"], "is not supported"), 0,
+ "and neither downgrades it to an unhandled scheme")
+
+
+def test_premise_the_module_is_corrupt(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.num(osm["corrupt size"], 19,
+ "premise: the module really is corrupt for this run, not merely intended to be")
+
+
+def test_premise_the_module_was_restored(osm, expect):
+ if _moved(osm, expect):
+ return
+ size = osm["restored size"]
+ expect.text("yes" if isinstance(size, int) and size > 1000 else f"no ({size})", "yes",
+ "premise: the real module was restored afterwards")
+
+
+def test_broken_is_not_an_unhandled_scheme(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.num(_lines(osm["broken"], "is not supported"), 0,
+ "a broken module does NOT masquerade as an unhandled scheme")
+
+
+def test_broken_is_not_a_module_never_installed(osm, expect):
+ if _moved(osm, expect):
+ return
+ expect.num(_lines(osm["broken"], "requires the object-store module"), 0,
+ "nor as a module that was never installed")
+
+
+def test_broken_keeps_the_loader_reason(osm, expect):
+ if _moved(osm, expect):
+ return
+ n = _lines(osm["broken"], "(?i)could not load library")
+ expect.text("yes" if n >= 1 else f"no ({n} lines)", "yes", "and the loader's own reason survives")
diff --git a/test/pytest/test_objstore_s3_read.py b/test/pytest/test_objstore_s3_read.py
new file mode 100644
index 000000000..2b1d8d024
--- /dev/null
+++ b/test/pytest/test_objstore_s3_read.py
@@ -0,0 +1,444 @@
+"""SigV4-signed s3:// reads (#393 M2): the pytest twin of `test/objstore_s3_read.sh`,
+every check by the shell's name (#1341).
+
+Reads go through the local fixture server (test/objstore_http_server.py, the shell's
+own) in SigV4 mode: it recomputes every request's signature with python's stdlib and
+refuses 403 on any mismatch, requires the session token inside the signed set, and
+verifies one tamper bucket against a different secret. So every green read proves the
+C signer and an independent implementation agree on every byte, with the key, region
+and token the postmaster's environment supplied. Reads are compared with the
+byte-identical local file, and the request count with the unsigned arithmetic.
+
+A REFUSAL IS NOT ANY ERROR: five guards share 28000 (no endpoint, no region, no
+secret, a 403, a missing token), so each refusal must name its own cause: the
+tamper bucket's must name the HTTP 403. AWS_REGION must
+outrank AWS_DEFAULT_REGION, which must be the fallback.
+
+A BACKEND THAT DIED DOES NOT PASS AS ONE THAT SURVIVED: a refusal's ERROR reaches the
+client before abort cleanup runs, so survival also reads the private cluster's server
+log for killed backends, and every reading first waits for the server, so a crash
+fails its own reading and survival rather than every reading after it.
+
+A PRIVATE CLUSTER, because the postmaster is restarted with AWS_* in its environment,
+which the shared cluster cannot do. It starts with every AWS_* variable of this process
+removed. The parquet fixture is written by a python3 child, as the shell's heredoc
+writes it (this module imports no pyarrow); without pyarrow every test declines under
+the shell's skip message. The shell's optional real-S3 arm runs only when
+PGC_S3_INTEGRATION_ENDPOINT is set, and declines otherwise.
+"""
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+TEST = pathlib.Path(__file__).resolve().parents[1]
+AKID, SECRET, REGION, BUCKET = "PGCTESTKEYID", "pgctest-secret-shhh", "pgc-test-1", "pgc-bucket"
+TOKEN = "pgctest-session-token"
+G, NCOLS, ROWS_PER_GROUP = 3, 8, 8000
+COLDEFS = ", ".join(f"c{i} int8" for i in range(NCOLS))
+SUM_ALL = " + ".join(f"sum(c{i})" for i in range(NCOLS))
+
+WRITE_FIXTURE = r'''
+import sys, shutil
+import pyarrow as pa, pyarrow.parquet as pq
+G, NCOLS, RPG = (int(a) for a in sys.argv[2:5])
+n = G * RPG
+cols = {("c%d" % i): pa.array([(i + 1) * v for v in range(n)], pa.int64())
+ for i in range(NCOLS)}
+pq.write_table(pa.table(cols), sys.argv[1], row_group_size=RPG,
+ data_page_size=1024, compression="NONE", use_dictionary=False)
+shutil.copyfile(sys.argv[1],
+ sys.argv[1].rsplit("/", 1)[0] + "/dir with space/f 1.parquet")
+'''
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _wait_up(dsn):
+ """Wait up to 15 s for the server, as the shell's readings do after a refusal."""
+ for _ in range(30):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _value(dsn, sql):
+ """-> the first column of the first row as text, or the failure as a query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None or row[0] is None:
+ return query_error(f"no value from {sql}")
+ return str(row[0])
+
+
+def _set_hash(dsn, sql):
+ """The shell's pgc_set_hash: an order-independent md5 of the result's rows."""
+ return _value(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ({sql}) _row) _s")
+
+
+def _sqlstate(dsn, sql):
+ """-> the SQLSTATE `sql` raised, or what happened instead."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ return exc.sqlstate or query_error(_message(exc))
+ return "no error"
+
+
+def _refusal(dsn, sql, phrase, label):
+ """-> "" when `sql` raised with `phrase` in its message, the bare
+ SQLSTATE when the message lacks it, or what happened instead."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return query_error(_message(exc))
+ return exc.sqlstate + (label if phrase in str(exc) else "")
+ return "no error"
+
+
+def _requests(log):
+ try:
+ return len(pathlib.Path(log).read_text(errors="replace").splitlines())
+ except OSError as exc:
+ return query_error(f"request log unreadable: {exc}")
+
+
+def _crashes(log):
+ try:
+ return pathlib.Path(log).read_text(errors="replace").count("terminated by signal")
+ except OSError as exc:
+ return query_error(f"crash count unreadable: {exc}")
+
+
+def _survived(dsn, log, before):
+ _wait_up(dsn)
+ up, now = _value(dsn, "SELECT 1"), _crashes(log)
+ if not isinstance(before, int) or not isinstance(now, int):
+ return query_error(f"crash count unreadable: {before} / {now}")
+ return "1" if up == "1" and now == before else f"no ([{up}], {now - before} backend crashes)"
+
+
+def _https_want(pg_config):
+ """The shell's HTTPS_WANT: 08006 when the object-store module links libssl (the
+ handshake fails against this plain-HTTP fixture), else 0A000 (refused outright)."""
+ try:
+ libdir = subprocess.run([pg_config, "--pkglibdir"], capture_output=True, text=True,
+ check=True).stdout.strip()
+ ldd = subprocess.run(["ldd", os.path.join(libdir, "pgcolumnar_objstore.so")],
+ capture_output=True, text=True).stdout
+ except (OSError, subprocess.CalledProcessError) as exc:
+ return query_error(f"the module's link set is unreadable: {exc}")
+ return "08006" if "libssl" in ldd else "0A000"
+
+
+def _pg_ctl(cluster, args, extra_env):
+ """pg_ctl with an explicit environment: every AWS_* of this process removed, then
+ `extra_env` added. Dropped to postgres when running as root, as the harness does."""
+ unset = [a for k in os.environ if k.startswith("AWS_") for a in ("-u", k)]
+ cmd = ["env"] + unset + [f"{k}={v}" for k, v in extra_env.items()] + \
+ [os.path.join(cluster.bindir, "pg_ctl")] + args
+ if os.geteuid() == 0:
+ cmd = ["runuser", "-u", "postgres", "--"] + cmd
+ return subprocess.run(cmd, capture_output=True, text=True)
+
+
+def _restart(cluster, datadir, log, env):
+ """Restart the private cluster with env and wait for it, as pg_restart_env does:
+ up to 15 s for SELECT 1. A cluster that does not come back is one error here."""
+ _wait_up(cluster.dsn()) # a backend that crashed in the last reading: let recovery finish
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "fast", "-w", "stop"], {})
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], env)
+ for _ in range(30):
+ if started.returncode == 0 and _value(cluster.dsn(), "SELECT 1") == "1":
+ return
+ time.sleep(0.5)
+ raise RuntimeError(f"cluster did not come back after env restart: {started.stderr.strip()}")
+
+
+def _integration(cluster, datadir, log, dsn, local):
+ """The shell's phase 4, against a real S3 implementation. -> None when
+ PGC_S3_INTEGRATION_ENDPOINT is unset or the upload helper failed (the shell then
+ records nothing), else (s3 hash, local hash)."""
+ ep = os.environ.get("PGC_S3_INTEGRATION_ENDPOINT", "")
+ if not ep:
+ return None
+ key, secret = os.environ.get("PGC_S3_INTEGRATION_KEY", ""), os.environ.get("PGC_S3_INTEGRATION_SECRET", "")
+ region = os.environ.get("PGC_S3_INTEGRATION_REGION", "garage")
+ ib, ikey = os.environ.get("PGC_S3_INTEGRATION_BUCKET", "pgcolumnar-test"), f"m2/integration-{os.getpid()}.parquet"
+ _restart(cluster, datadir, log, {"AWS_ENDPOINT_URL": ep, "AWS_ACCESS_KEY_ID": key,
+ "AWS_SECRET_ACCESS_KEY": secret, "AWS_REGION": region})
+ try:
+ up = subprocess.run(["aws", "--endpoint-url", ep, "s3", "cp", str(local), f"s3://{ib}/{ikey}"],
+ env=dict(os.environ, AWS_ACCESS_KEY_ID=key, AWS_SECRET_ACCESS_KEY=secret,
+ AWS_DEFAULT_REGION=region), capture_output=True)
+ except OSError:
+ return None
+ if up.returncode != 0:
+ return None
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"CREATE FOREIGN TABLE fs3real ({COLDEFS}) SERVER s3srv OPTIONS (path 's3://{ib}/{ikey}')")
+ return _set_hash(dsn, "SELECT * FROM fs3real"), _set_hash(dsn, "SELECT * FROM flocal")
+
+
+@pytest.fixture(scope="module")
+def osr(pgc_cluster):
+ from pgc_cluster import Cluster, pick_port
+
+ work = pathlib.Path(tempfile.mkdtemp(prefix="pgc-s3read-"))
+ os.chmod(work, 0o777)
+ bucket = work / BUCKET
+ (bucket / "dir with space").mkdir(parents=True)
+ fixture = bucket / "fixture.parquet"
+ r = {}
+ srv = None
+ cluster = None
+ datadir = work / "data"
+ try:
+ made = subprocess.run([sys.executable, "-c", WRITE_FIXTURE, str(fixture), str(G), str(NCOLS),
+ str(ROWS_PER_GROUP)], capture_output=True, text=True)
+ if made.returncode != 0:
+ raise RuntimeError(f"the parquet fixture was not written: {made.stderr.strip()}")
+ for p in [bucket, *bucket.rglob("*")]:
+ p.chmod(0o777 if p.is_dir() else 0o666)
+ r["fixture written"] = "yes" if fixture.is_file() and fixture.stat().st_size > 0 else "no"
+
+ s3_port = pick_port(31)
+ out = work / "s3_server.out"
+ s3log = str(work / "s3.log")
+ srv = subprocess.Popen([sys.executable, str(TEST / "objstore_http_server.py"), "--dir", str(work),
+ "--port", str(s3_port), "--log", s3log,
+ "--sigv4-key", AKID, "--sigv4-secret", SECRET, "--sigv4-region", REGION,
+ "--sigv4-token", TOKEN, "--tamper-bucket", "tamperbkt"],
+ stdout=open(out, "w"), stderr=subprocess.STDOUT)
+ for _ in range(50):
+ if out.is_file() and "READY" in out.read_text(errors="replace"):
+ break
+ time.sleep(0.1)
+ r["server up"] = str(out.read_text(errors="replace").count("READY") if out.is_file() else 0)
+
+ datadir.mkdir()
+ log = str(work / "server.log")
+ cluster = Cluster(pgc_cluster.pg_config, "s3read", datadir, pick_port(30))
+ cluster.initdb()
+ with open(datadir / "postgresql.conf", "a") as fh:
+ fh.write("\npgcolumnar.objstore_allowed_endpoints='127.0.0.1'\n")
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], {})
+ if started.returncode != 0:
+ raise RuntimeError(f"the private cluster did not start: {started.stderr}")
+ dsn = cluster.dsn()
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ c.execute("CREATE SERVER s3srv FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ for name, path in (("flocal", str(fixture)), ("fs3", f"s3://{BUCKET}/fixture.parquet"),
+ ("fs3enc", f"s3://{BUCKET}/dir with space/f 1.parquet"),
+ ("fs3tamper", "s3://tamperbkt/fixture.parquet"),
+ ("fs3miss", f"s3://{BUCKET}/no_such.parquet")):
+ c.execute(f"CREATE FOREIGN TABLE {name} ({COLDEFS}) SERVER s3srv OPTIONS (path '{path}')")
+
+ # phase 1: no AWS environment at all. The baseline comes first: a backend that
+ # dies in this read must move the count.
+ before = _crashes(log)
+ r["no env"] = _refusal(dsn, "SELECT count(*) FROM fs3",
+ "requires AWS_ENDPOINT_URL in the server environment", " naming AWS_ENDPOINT_URL")
+
+ # phase 2: the full ambient environment; AWS_DEFAULT_REGION names a region the
+ # fixture refuses, so AWS_REGION must win
+ endpoint = f"http://127.0.0.1:{s3_port}"
+ _restart(cluster, datadir, log, {"AWS_ENDPOINT_URL": endpoint, "AWS_ACCESS_KEY_ID": AKID,
+ "AWS_SECRET_ACCESS_KEY": SECRET, "AWS_REGION": REGION,
+ "AWS_DEFAULT_REGION": "pgc-wrong-1", "AWS_SESSION_TOKEN": TOKEN})
+ r["outranks"] = _value(dsn, "SELECT count(*) FROM fs3")
+ r["full"] = (_set_hash(dsn, "SELECT * FROM fs3"), _set_hash(dsn, "SELECT * FROM flocal"))
+ r["projection"] = (_set_hash(dsn, "SELECT c1, c6 FROM fs3"), _set_hash(dsn, "SELECT c1, c6 FROM flocal"))
+ r["encoded"] = (_set_hash(dsn, "SELECT * FROM fs3enc"), _set_hash(dsn, "SELECT * FROM flocal"))
+ r["count"] = _value(dsn, "SELECT count(*) FROM fs3")
+ _wait_up(dsn)
+ open(s3log, "w").close()
+ _value(dsn, f"SELECT {SUM_ALL} FROM fs3")
+ r["requests"] = _requests(s3log)
+ r["tamper"] = _refusal(dsn, "SELECT count(*) FROM fs3tamper",
+ "was denied (HTTP 403", " as a 403")
+ r["miss"] = _refusal(dsn, "SELECT count(*) FROM fs3miss", "does not exist (HTTP 404)", " as a 404")
+
+ # phase 2b: AWS_DEFAULT_REGION alone is the fallback
+ _restart(cluster, datadir, log, {"AWS_ENDPOINT_URL": endpoint, "AWS_ACCESS_KEY_ID": AKID,
+ "AWS_SECRET_ACCESS_KEY": SECRET, "AWS_DEFAULT_REGION": REGION,
+ "AWS_SESSION_TOKEN": TOKEN})
+ r["fallback"] = _value(dsn, "SELECT count(*) FROM fs3")
+
+ # phase 2c: no region at all, then no secret
+ _restart(cluster, datadir, log, {"AWS_ENDPOINT_URL": endpoint, "AWS_ACCESS_KEY_ID": AKID,
+ "AWS_SECRET_ACCESS_KEY": SECRET, "AWS_SESSION_TOKEN": TOKEN})
+ r["no region"] = _refusal(dsn, "SELECT count(*) FROM fs3", "requires a region option", " naming the region")
+ _restart(cluster, datadir, log, {"AWS_ENDPOINT_URL": endpoint, "AWS_ACCESS_KEY_ID": AKID,
+ "AWS_REGION": REGION, "AWS_SESSION_TOKEN": TOKEN})
+ r["no secret"] = _refusal(dsn, "SELECT count(*) FROM fs3",
+ "requires AWS_SECRET_ACCESS_KEY in the server environment",
+ " naming AWS_SECRET_ACCESS_KEY")
+
+ # phase 3: an https endpoint is never silently downgraded
+ _restart(cluster, datadir, log, {"AWS_ENDPOINT_URL": f"https://127.0.0.1:{s3_port}",
+ "AWS_ACCESS_KEY_ID": AKID, "AWS_SECRET_ACCESS_KEY": SECRET,
+ "AWS_REGION": REGION})
+ want = _https_want(pgc_cluster.pg_config)
+ r["https"] = (f"{_sqlstate(dsn, 'SELECT count(*) FROM fs3')} for this build", f"{want} for this build")
+ r["survived"] = _survived(dsn, log, before)
+
+ r["integration"] = _integration(cluster, datadir, log, dsn, fixture)
+ yield r
+ finally:
+ if cluster is not None:
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "immediate", "-w", "stop"], {})
+ if srv is not None:
+ srv.kill()
+ srv.wait()
+ shutil.rmtree(work, ignore_errors=True)
+
+
+def _readings(request, expect):
+ """-> the fixture's readings, or None after declining as the shell skips."""
+ if expect.require_module("pyarrow.parquet", name="pyarrow is required to write the fixture") is None:
+ return None
+ return request.getfixturevalue("osr")
+
+
+def test_the_fixture_is_written(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["fixture written"], "yes", "premise: fixture written")
+
+
+def test_the_sigv4_fixture_server_is_up(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["server up"], "1", "premise: sigv4 fixture server is up")
+
+
+def test_an_s3_read_with_no_environment_is_refused_naming_the_endpoint(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["no env"], "28000 naming AWS_ENDPOINT_URL", "config: s3 read with no environment is 28000")
+
+
+def test_aws_region_outranks_aws_default_region(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["outranks"], str(G * ROWS_PER_GROUP), "region: AWS_REGION outranks AWS_DEFAULT_REGION")
+
+
+def test_a_full_scan_over_s3_reads_what_the_local_file_reads(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(*r["full"], "A: full scan s3 == local")
+
+
+def test_a_projection_over_s3_reads_what_the_local_file_reads(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(*r["projection"], "A: projection s3 == local")
+
+
+def test_a_percent_encoded_key_over_s3_reads_what_the_local_file_reads(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(*r["encoded"], "A: percent-encoded key s3 == local")
+
+
+def test_count_over_s3(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["count"], str(G * ROWS_PER_GROUP), "A: count(*) over s3")
+
+
+def test_the_signed_run_logged_requests(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ n = r["requests"]
+ expect.text("yes" if isinstance(n, int) and n > 0 else f"no ({n})", "yes",
+ "premise: signed run logged requests")
+
+
+def test_signing_left_the_request_arithmetic_unchanged(request, expect):
+ """open = HEAD + 3 GETs, data = one GET per column chunk."""
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(str(r["requests"]), str(4 + G * NCOLS), "B: signing left the request arithmetic unchanged")
+
+
+def test_the_tamper_bucket_surfaces_its_403_as_28000(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["tamper"], "28000 as a 403", "403: tamper bucket surfaces 28000")
+
+
+def test_a_verified_request_for_a_missing_key_still_reaches_404(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["miss"], "58P01 as a 404", "403 ordering premise: verified requests still reach 404 = 58P01")
+
+
+def test_aws_default_region_is_the_fallback(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["fallback"], str(G * ROWS_PER_GROUP), "region: AWS_DEFAULT_REGION is the fallback")
+
+
+def test_an_s3_read_with_no_region_is_refused_naming_it(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["no region"], "28000 naming the region", "config: s3 read with no region is refused naming it")
+
+
+def test_an_s3_read_with_no_secret_is_refused_naming_it(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["no secret"], "28000 naming AWS_SECRET_ACCESS_KEY",
+ "config: s3 read with no AWS_SECRET_ACCESS_KEY is refused naming it")
+
+
+def test_an_https_endpoint_is_not_downgraded(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(*r["https"], "https endpoint is not downgraded")
+
+
+def test_the_backend_is_still_up_after_the_s3_refusals(request, expect):
+ r = _readings(request, expect)
+ if r is not None:
+ expect.text(r["survived"], "1", "backend still up after the s3 refusals")
+
+
+def test_a_real_s3_implementation_reads_what_the_local_file_reads(request, expect):
+ r = _readings(request, expect)
+ if r is None:
+ return
+ if r["integration"] is None:
+ expect.cannot_run("UNMET_PRECONDITION",
+ "PGC_S3_INTEGRATION_ENDPOINT names no real S3 implementation (or its upload "
+ "failed), so the shell records nothing either",
+ name="integration: real S3 implementation == local")
+ return
+ expect.text(*r["integration"], "integration: real S3 implementation == local")
diff --git a/test/pytest/test_objstore_sink_write.py b/test/pytest/test_objstore_sink_write.py
new file mode 100644
index 000000000..3f50d10e7
--- /dev/null
+++ b/test/pytest/test_objstore_sink_write.py
@@ -0,0 +1,575 @@
+"""The remote sink (#394 steps 3-4): the pytest twin of `test/objstore_sink_write.sh`,
+every check by the shell's name (#1341).
+
+Exports to s3:// go as one plain PUT below the part size and as a multipart upload above
+it (create, parts, complete), and a failed export aborts its upload so nothing is ever
+visible at the final key. parallel_export_parquet writes one object per worker under an
+s3:// prefix, a _SUCCESS marker last, drops a smaller re-run's stale tail, and on cancel
+removes what it wrote. The local SigV4 fixture server (test/objstore_http_server.py, the
+shell's own) emulates multipart as S3 does: the object is the parts the complete LISTS,
+each checked against its ETag, and it keeps the keys of the uploads still open in
+`__open_uploads__`, the billable orphans.
+
+EVERY OBJECT IS READ BACK and compared with the GENERATOR's rows, not with the columnar
+table the export itself scanned, which would agree with a scan that bent rows. The Arrow
+object is imported back from the bucket, so a count > 0 no longer passes a short object.
+
+EACH REFUSAL IS ITS OWN GUARD'S: a part the server refuses (`__part_status__`) and a
+complete that fails share 58030, so the part check is read by its own message, as are the
+injected fault and the allow-list refusal. Abort is read from the server's open uploads,
+not from the DELETE count alone, which passed an abort that named the wrong upload.
+
+A PRIVATE CLUSTER, restarted with AWS_* in its environment, which the shared cluster
+cannot do. The fixtures are generated by SQL; this module needs no pyarrow. The shell's
+optional real-S3 arms (PGC_S3_INTEGRATION_ENDPOINT, never set in CI) are not mirrored.
+"""
+import os
+import pathlib
+import re
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+TEST = pathlib.Path(__file__).resolve().parents[1]
+AKID, SECRET, REGION, BUCKET = "PGCTESTKEYID", "pgctest-secret-shhh", "pgc-test-1", "pgc-bucket"
+PART = "SET pgcolumnar.objstore_part_size = 262144"
+
+GEN_SMALL = "SELECT g, 'row-'||g FROM generate_series(1,1000) g"
+GEN_BIG = "SELECT g::int8, md5(g::text)||md5((g+1)::text) FROM generate_series(1,400000) g"
+GEN_PAR = "SELECT g::int8, (g % 100)::int FROM generate_series(1,80000) g"
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _wait_up(dsn):
+ """Wait up to 15 s for the server, as the shell's readings do after a refusal."""
+ for _ in range(30):
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=5) as conn:
+ conn.execute("SELECT 1")
+ return
+ except psycopg.Error:
+ time.sleep(0.5)
+
+
+def _row(dsn, sql, setup=()):
+ """-> the first row's columns joined by a space, or the failure as a query_error()."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for s in setup:
+ conn.execute(s)
+ row = conn.execute(sql).fetchone()
+ except psycopg.Error as exc:
+ return query_error(_message(exc))
+ if row is None:
+ return query_error(f"no row from {sql}")
+ return " ".join("" if v is None else str(v) for v in row)
+
+
+def _set_hash(dsn, sql):
+ """The port of pgc_set_hash: md5 of the rows' text, sorted."""
+ return _row(dsn, "SELECT coalesce(md5(string_agg(t, chr(10) ORDER BY t)), 'EMPTY') "
+ f"FROM (SELECT _row::text AS t FROM ( {sql} ) _row) _s")
+
+
+def _refusal(dsn, sql, phrase, label, setup=()):
+ """-> "" when `sql` raised with `phrase` in its message, the bare
+ SQLSTATE when the message lacks it, or what happened instead."""
+ _wait_up(dsn)
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for s in setup:
+ conn.execute(s)
+ conn.execute(sql).fetchall()
+ except psycopg.Error as exc:
+ if not exc.sqlstate:
+ return query_error(_message(exc))
+ return exc.sqlstate + (label if phrase in str(exc) else "")
+ return "no error"
+
+
+def _logn(log, pattern):
+ """-> how many request-log lines match `pattern` (grep -cE), or a query_error()."""
+ try:
+ lines = pathlib.Path(log).read_text(errors="replace").splitlines()
+ except OSError as exc:
+ return query_error(f"request log unreadable: {exc}")
+ return sum(1 for line in lines if re.search(pattern, line))
+
+
+def _open_uploads(work, pattern):
+ """-> how many uploads the fixture server still holds open for keys matching `pattern`."""
+ f = work / "__open_uploads__"
+ if not f.is_file():
+ return "0"
+ return str(sum(1 for line in f.read_text(errors="replace").splitlines() if re.search(pattern, line)))
+
+
+def _parts(d):
+ """-> how many part-*.parquet objects are under `d` (0 when it does not exist)."""
+ return sum(1 for p in d.iterdir() if re.search(r"part-.*\.parquet$", p.name)) if d.is_dir() else 0
+
+
+def _pg_ctl(cluster, args, extra_env):
+ """pg_ctl with an explicit environment: every AWS_* of this process removed, then
+ `extra_env` added. Dropped to postgres when running as root, as the harness does."""
+ unset = [a for k in os.environ if k.startswith("AWS_") for a in ("-u", k)]
+ cmd = ["env"] + unset + [f"{k}={v}" for k, v in extra_env.items()] + \
+ [os.path.join(cluster.bindir, "pg_ctl")] + args
+ if os.geteuid() == 0:
+ cmd = ["runuser", "-u", "postgres", "--"] + cmd
+ return subprocess.run(cmd, capture_output=True, text=True)
+
+
+def _restart(cluster, datadir, log, env):
+ """Restart the private cluster with env and wait for it, as pg_restart_env does."""
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "fast", "-w", "stop"], {})
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], env)
+ for _ in range(30):
+ if started.returncode == 0 and _row(cluster.dsn(), "SELECT 1") == "1":
+ return
+ time.sleep(0.5)
+ raise RuntimeError(f"cluster did not come back after env restart: {started.stderr.strip()}")
+
+
+def _cancel_run(dsn, s3log, bucket, r):
+ """The shell's cancel arm: a 4-worker export of es_parbig in the background, cancelled
+ once a worker has PUT a part."""
+ out = {}
+
+ def run():
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(PART)
+ conn.execute("SELECT pgcolumnar.parallel_export_parquet("
+ f"'es_parbig'::regclass, 's3://{BUCKET}/cx', 4)").fetchall()
+ out["result"] = "completed"
+ except psycopg.Error as exc:
+ out["result"] = str(exc)
+
+ shutil.rmtree(bucket / "cx", ignore_errors=True)
+ open(s3log, "w").close()
+ bg = threading.Thread(target=run)
+ bg.start()
+ wrote = "no"
+ for _ in range(300):
+ n = _logn(s3log, r"^PUT /pgc-bucket/cx/part-")
+ if isinstance(n, int) and n >= 1:
+ wrote = "yes"
+ break
+ time.sleep(0.1)
+ r["cx wrote"] = wrote
+ # cancel only once all four workers' uploads are registered on the server; an upload
+ # whose POST ?uploads is still in flight is #1743's window, not this check's
+ cx_open = "0"
+ for _ in range(300):
+ cx_open = _open_uploads(bucket.parent, r"^pgc-bucket/cx/")
+ if int(cx_open) >= 4:
+ break
+ time.sleep(0.1)
+ r["cx all open"] = "yes" if int(cx_open) >= 4 else f"no ({cx_open} open)"
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("SELECT pg_cancel_backend(pid) FROM pg_stat_activity "
+ "WHERE query LIKE '%parallel_export_parquet%' AND state = 'active' "
+ "AND pid <> pg_backend_pid()")
+ bg.join(600)
+ # let requests still in flight from the cancelled workers land (the log stops growing
+ # for a second, at most 10 s), so a late write to a final key is read, not raced
+ for _ in range(10):
+ size = os.path.getsize(s3log)
+ time.sleep(1)
+ if os.path.getsize(s3log) == size:
+ break
+ res = out.get("result", "still running")
+ r["cx cancelled"] = ("cancelled" if re.search(r"canceling statement|canceled on user request", res, re.I)
+ else "completed")
+ n = _logn(s3log, r"^DELETE /pgc-bucket/cx/part-")
+ r["cx delete"] = ("yes" if n >= 1 else "no") if isinstance(n, int) else n
+ r["cx parts"] = str(_parts(bucket / "cx"))
+ r["cx marker"] = "present" if (bucket / "cx" / "_SUCCESS").exists() else "absent"
+ r["cx open"] = _open_uploads(bucket.parent, r"^pgc-bucket/cx/")
+
+
+WORKERS = "backend_type = 'pgcolumnar parallel_export worker'"
+
+
+def _silent_run(dsn, s3log, bucket, srv, r):
+ """The shell's silent-store arm (#1727 review): a 4-worker export, the fixture server
+ stopped once a worker has PUT a part, the workers terminated, and their exit timed.
+ Each wait slice times out, so the 60 s quiet limit ends a worker's abort; without
+ WL_TIMEOUT the review saw all four alive at +92 s. No upload seen is a red too."""
+ def run():
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ conn.execute(PART)
+ conn.execute("SELECT pgcolumnar.parallel_export_parquet("
+ f"'es_parbig'::regclass, 's3://{BUCKET}/hs', 4)").fetchall()
+ except psycopg.Error:
+ pass
+
+ shutil.rmtree(bucket / "hs", ignore_errors=True)
+ open(s3log, "w").close()
+ bg = threading.Thread(target=run)
+ bg.start()
+ wrote = "no"
+ for _ in range(300):
+ n = _logn(s3log, r"^PUT /pgc-bucket/hs/part-")
+ if isinstance(n, int) and n >= 1:
+ wrote = "yes"
+ break
+ time.sleep(0.1)
+ srv.send_signal(signal.SIGSTOP)
+ try:
+ n_workers = _row(dsn, f"SELECT count(*) FROM pg_stat_activity WHERE {WORKERS}")
+ _row(dsn, f"SELECT count(*) FROM (SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
+ f"WHERE {WORKERS}) t")
+ t0 = time.monotonic()
+ left = n_workers
+ for _ in range(90):
+ left = _row(dsn, f"SELECT count(*) FROM pg_stat_activity WHERE {WORKERS}")
+ if left == "0":
+ break
+ time.sleep(1)
+ dt = int(time.monotonic() - t0)
+ finally:
+ srv.send_signal(signal.SIGCONT)
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("SELECT pg_cancel_backend(pid) FROM pg_stat_activity "
+ "WHERE query LIKE '%parallel_export_parquet%' AND state = 'active' "
+ "AND pid <> pg_backend_pid()")
+ bg.join(600)
+ print(f"-- store silent: {n_workers} workers terminated, {left} left after {dt}s")
+ if wrote != "yes":
+ r["hs exit"] = "no upload seen"
+ elif n_workers.isdigit() and int(n_workers) >= 1 and left == "0" and dt <= 75:
+ r["hs exit"] = "bounded"
+ else:
+ r["hs exit"] = f"slow ({n_workers} workers, {left} left after {dt}s)"
+
+
+@pytest.fixture(scope="module")
+def osw(pgc_cluster):
+ from pgc_cluster import Cluster, pick_port
+
+ work = pathlib.Path(tempfile.mkdtemp(prefix="pgc-objsink-"))
+ os.chmod(work, 0o777)
+ bucket = work / BUCKET
+ bucket.mkdir()
+ bucket.chmod(0o777)
+ r = {}
+ srv = None
+ cluster = None
+ datadir = work / "data"
+ try:
+ s3_port = pick_port(30)
+ out = work / "sink_server.out"
+ s3log = str(work / "sink.log")
+ srv = subprocess.Popen([sys.executable, str(TEST / "objstore_http_server.py"), "--dir", str(work),
+ "--port", str(s3_port), "--log", s3log,
+ "--sigv4-key", AKID, "--sigv4-secret", SECRET, "--sigv4-region", REGION],
+ stdout=open(out, "w"), stderr=subprocess.STDOUT)
+ for _ in range(50):
+ if out.is_file() and "READY" in out.read_text(errors="replace"):
+ break
+ time.sleep(0.1)
+ r["fixture up"] = str(out.read_text(errors="replace").count("READY") if out.is_file() else 0)
+
+ datadir.mkdir()
+ log = str(work / "server.log")
+ cluster = Cluster(pgc_cluster.pg_config, "objsink", datadir, pick_port(31))
+ cluster.initdb()
+ with open(datadir / "postgresql.conf", "a") as fh:
+ fh.write("\npgcolumnar.objstore_allowed_endpoints='127.0.0.1'\nmax_worker_processes=16\n")
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], {})
+ if started.returncode != 0:
+ raise RuntimeError(f"the private cluster did not start: {started.stderr}")
+ dsn = cluster.dsn()
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ _restart(cluster, datadir, log, {"AWS_ENDPOINT_URL": f"http://127.0.0.1:{s3_port}",
+ "AWS_ACCESS_KEY_ID": AKID, "AWS_SECRET_ACCESS_KEY": SECRET,
+ "AWS_REGION": REGION})
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE TABLE es_small (id int, t text) USING pgcolumnar")
+ c.execute(f"INSERT INTO es_small {GEN_SMALL}")
+ c.execute("CREATE TABLE es_big (id int8, pad text) USING pgcolumnar")
+ c.execute(f"INSERT INTO es_big {GEN_BIG}")
+ c.execute("CREATE SERVER wsrv FOREIGN DATA WRAPPER pgcolumnar_parquet")
+
+ # step 3: single-object round trips
+ open(s3log, "w").close()
+ r["small export"] = _row(dsn, f"SELECT pgcolumnar.export_parquet('es_small', 's3://{BUCKET}/small.parquet')")
+ n = _logn(s3log, r"^PUT /pgc-bucket/small.parquet -")
+ r["small put"] = str(n) if isinstance(n, int) else n
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"CREATE FOREIGN TABLE f_small (id int, t text) SERVER wsrv "
+ f"OPTIONS (path 's3://{BUCKET}/small.parquet')")
+ r["small back"], r["small gen"] = _set_hash(dsn, "SELECT * FROM f_small"), _set_hash(dsn, GEN_SMALL)
+
+ open(s3log, "w").close()
+ r["big export"] = _row(dsn, f"SELECT pgcolumnar.export_parquet('es_big', 's3://{BUCKET}/big.parquet')",
+ setup=[PART])
+ starts = _logn(s3log, r"^POST /pgc-bucket/big.parquet uploads=")
+ parts = _logn(s3log, r"^PUT /pgc-bucket/big.parquet partNumber=")
+ r["big multipart"] = ("yes" if starts >= 1 and parts >= 2 else "no") \
+ if isinstance(starts, int) and isinstance(parts, int) else query_error(f"{starts} / {parts}")
+ n = _logn(s3log, r"^POST /pgc-bucket/big.parquet uploadId=")
+ r["big completes"] = str(n) if isinstance(n, int) else n
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute(f"CREATE FOREIGN TABLE f_big (id int8, pad text) SERVER wsrv "
+ f"OPTIONS (path 's3://{BUCKET}/big.parquet')")
+ r["big back"], r["big gen"] = _set_hash(dsn, "SELECT * FROM f_big"), _set_hash(dsn, GEN_BIG)
+
+ # injection: abort mid-part leaves nothing
+ open(s3log, "w").close()
+ r["inject"] = _refusal(dsn, f"SELECT pgcolumnar.export_parquet('es_big', 's3://{BUCKET}/fail.parquet')",
+ "export write failed (injected fault)", " by the injected fault",
+ setup=[PART, "SET pgcolumnar.sink_fail_after = 900000"])
+ r["inject key"] = "clean" if not (bucket / "fail.parquet").exists() else "present"
+ started_n = _logn(s3log, r"^POST /pgc-bucket/fail.parquet uploads=")
+ aborted_n = _logn(s3log, r"^DELETE /pgc-bucket/fail.parquet uploadId=")
+ r["inject started"] = ("yes" if started_n >= 1 else "no") if isinstance(started_n, int) else started_n
+ r["inject aborted"] = (str(aborted_n), str(started_n)) \
+ if isinstance(started_n, int) and isinstance(aborted_n, int) else (query_error(), query_error())
+ r["inject open"] = _open_uploads(work, r"^pgc-bucket/fail\.parquet$")
+
+ # a part the server refuses fails the export
+ open(s3log, "w").close()
+ (work / "__part_status__").write_text("500 2\n")
+ r["part"] = _refusal(dsn, f"SELECT pgcolumnar.export_parquet('es_big', 's3://{BUCKET}/partfail.parquet')",
+ "uploading a part of", " by the part check", setup=[PART])
+ (work / "__part_status__").unlink()
+ r["part key"] = "clean" if not (bucket / "partfail.parquet").exists() else "present"
+ n = _logn(s3log, r"^POST /pgc-bucket/partfail.parquet uploads=")
+ r["part started"] = ("yes" if n >= 1 else "no") if isinstance(n, int) else n
+ r["part open"] = _open_uploads(work, r"^pgc-bucket/partfail\.parquet$")
+
+ # taxonomy
+ r["allow"] = _refusal(dsn, "SELECT pgcolumnar.export_parquet('es_small', 'http://127.0.0.2:1/x.parquet')",
+ "is not in pgcolumnar.objstore_allowed_endpoints", " by the allow-list")
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE TABLE es_arrow_back (id int, t text) USING pgcolumnar")
+ _row(dsn, f"SELECT pgcolumnar.export_arrow('es_small', 's3://{BUCKET}/small.arrow')")
+ _row(dsn, f"SELECT pgcolumnar.import_arrow('es_arrow_back', '{bucket}/small.arrow')")
+ r["arrow back"] = _set_hash(dsn, "SELECT * FROM es_arrow_back")
+
+ # step 4: parallel export to an s3:// prefix
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE TABLE es_par (id int8, v int) USING pgcolumnar")
+ c.execute("SELECT pgcolumnar.set_options('es_par', stripe_row_limit => 5000)")
+ c.execute(f"INSERT INTO es_par {GEN_PAR}")
+ open(s3log, "w").close()
+ pexp = f"SELECT pgcolumnar.parallel_export_parquet('es_par'::regclass, 's3://{BUCKET}/par', 4)"
+ r["par export"] = _row(dsn, pexp)
+ r["par objects"] = str(_parts(bucket / "par"))
+ n = _logn(s3log, r"^PUT /pgc-bucket/par/part-")
+ r["par puts"] = ("yes" if n >= 4 else "no") if isinstance(n, int) else n
+ r["par marker"] = "yes" if (bucket / "par" / "_SUCCESS").is_file() else "no"
+ n = _logn(s3log, r"^PUT /pgc-bucket/par/_SUCCESS")
+ r["par marker put"] = ("yes" if n >= 1 else "no") if isinstance(n, int) else n
+ with psycopg.connect(dsn, autocommit=True) as c:
+ for i in range(4):
+ c.execute(f"CREATE FOREIGN TABLE fpar_{i} (id int8, v int) SERVER wsrv "
+ f"OPTIONS (path 's3://{BUCKET}/par/part-000{i}.parquet')")
+ r["par back"] = _set_hash(dsn, " UNION ALL ".join(f"SELECT * FROM fpar_{i}" for i in range(4)))
+ r["par gen"] = _set_hash(dsn, GEN_PAR)
+ open(s3log, "w").close()
+ r["par rerun"] = _row(dsn, pexp)
+
+ # shrink: a smaller re-run drops the stale tail
+ _row(dsn, f"SELECT pgcolumnar.parallel_export_parquet('es_par'::regclass, 's3://{BUCKET}/sr', 4)")
+ sr = bucket / "sr"
+ r["sr four"] = "ok" if _parts(sr) == 4 and (sr / "_SUCCESS").is_file() else \
+ f"{_parts(sr)} parts, marker {(sr / '_SUCCESS').is_file()}"
+ _row(dsn, f"SELECT pgcolumnar.parallel_export_parquet('es_par'::regclass, 's3://{BUCKET}/sr', 2)")
+ r["sr two"] = str(_parts(sr))
+ r["sr gone"] = "gone" if not (sr / "part-0002.parquet").exists() and \
+ not (sr / "part-0003.parquet").exists() else "present"
+ r["sr marker"] = "yes" if (sr / "_SUCCESS").is_file() else "no"
+
+ # cancel mid-run
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("DROP TABLE IF EXISTS es_parbig")
+ c.execute("CREATE TABLE es_parbig (id int8, pad text) USING pgcolumnar")
+ c.execute("SELECT pgcolumnar.set_options('es_parbig', stripe_row_limit => 4000)")
+ c.execute("INSERT INTO es_parbig SELECT g, md5(g::text)||md5((g+1)::text) "
+ "FROM generate_series(1,3000000) g")
+ _cancel_run(dsn, s3log, bucket, r)
+ _silent_run(dsn, s3log, bucket, srv, r)
+ yield r
+ finally:
+ if cluster is not None:
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "immediate", "-w", "stop"], {})
+ if srv is not None:
+ srv.kill()
+ srv.wait()
+ shutil.rmtree(work, ignore_errors=True)
+
+
+def test_the_sink_write_sigv4_fixture_server_is_up(osw, expect):
+ expect.text(osw["fixture up"], "1", "premise: sigv4 fixture server is up")
+
+
+def test_a_small_export_to_s3_succeeds(osw, expect):
+ expect.text(osw["small export"], "1000", "small export to s3:// succeeds")
+
+
+def test_a_small_object_goes_as_one_plain_put(osw, expect):
+ expect.text(osw["small put"], "1", "small object went as ONE plain PUT (no multipart)")
+
+
+def test_the_small_object_reads_back_as_the_generator(osw, expect):
+ expect.text(osw["small back"], osw["small gen"], "small round trip: s3 read-back == source")
+
+
+def test_a_large_export_to_s3_succeeds(osw, expect):
+ expect.text(osw["big export"], "400000", "large export to s3:// succeeds")
+
+
+def test_the_large_export_took_the_multipart_protocol(osw, expect):
+ expect.text(osw["big multipart"], "yes", "premise: the large export took the multipart protocol")
+
+
+def test_the_large_export_completed_its_upload_once(osw, expect):
+ expect.text(osw["big completes"], "1", "and completed it exactly once (POST with uploadId, no partNumber)")
+
+
+def test_the_large_object_reads_back_as_the_generator(osw, expect):
+ expect.text(osw["big back"], osw["big gen"], "large round trip: s3 read-back == source")
+
+
+def test_an_injected_mid_upload_failure_surfaces_as_disk_full(osw, expect):
+ expect.text(osw["inject"], "53100 by the injected fault", "inject: mid-upload failure surfaces as disk-full (53100)")
+
+
+def test_the_injected_failure_leaves_no_key(osw, expect):
+ expect.text(osw["inject key"], "clean", "inject: the key does not exist")
+
+
+def test_the_injected_fault_landed_mid_multipart(osw, expect):
+ expect.text(osw["inject started"], "yes", "premise: the fault landed mid-multipart (an upload was started)")
+
+
+def test_every_upload_the_injected_export_started_was_aborted(osw, expect):
+ aborted, started = osw["inject aborted"]
+ expect.text(aborted, started, "inject: every upload started was aborted (no billable orphan)")
+
+
+def test_the_injected_failure_leaves_no_open_upload(osw, expect):
+ expect.text(osw["inject open"], "0", "inject: the server holds no open upload for the key")
+
+
+def test_a_refused_part_fails_the_export(osw, expect):
+ expect.text(osw["part"], "58030 by the part check", "part failure: a refused part fails the export (58030)")
+
+
+def test_a_refused_part_leaves_no_key(osw, expect):
+ expect.text(osw["part key"], "clean", "part failure: the key does not exist")
+
+
+def test_the_part_failure_landed_mid_multipart(osw, expect):
+ expect.text(osw["part started"], "yes",
+ "premise: the part failure landed mid-multipart (an upload was started)")
+
+
+def test_a_refused_part_leaves_no_open_upload(osw, expect):
+ expect.text(osw["part open"], "0", "part failure: the server holds no open upload for the key")
+
+
+def test_an_unlisted_endpoint_is_refused_by_the_allow_list(osw, expect):
+ expect.text(osw["allow"], "42501 by the allow-list", "allow-list: an unlisted endpoint refuses 42501 before any write")
+
+
+def test_an_arrow_export_to_s3_imports_back_as_the_generator(osw, expect):
+ expect.text(osw["arrow back"], osw["small gen"], "arrow export to s3:// also round-trips")
+
+
+def test_a_parallel_export_to_an_s3_prefix_succeeds(osw, expect):
+ expect.text(osw["par export"], "80000", "parallel export to an s3:// prefix succeeds")
+
+
+def test_the_parallel_export_wrote_one_object_per_worker(osw, expect):
+ expect.text(osw["par objects"], "4", "parallel: one object per worker was written (4)")
+
+
+def test_every_worker_put_went_to_the_prefix(osw, expect):
+ expect.text(osw["par puts"], "yes", "parallel: every worker PUT went to the prefix")
+
+
+def test_a_success_marker_exists_at_the_prefix(osw, expect):
+ expect.text(osw["par marker"], "yes", "parallel remote: a _SUCCESS marker object exists at the prefix")
+
+
+def test_the_marker_went_to_the_prefix_as_a_put(osw, expect):
+ expect.text(osw["par marker put"], "yes", "parallel remote: the marker went to the prefix as a PUT")
+
+
+def test_the_per_worker_objects_read_back_as_the_generator(osw, expect):
+ expect.text(osw["par back"], osw["par gen"], "parallel: the union of the per-worker objects == source")
+
+
+def test_a_rerun_into_the_same_prefix_succeeds(osw, expect):
+ expect.text(osw["par rerun"], "80000", "parallel: a re-run into the same prefix succeeds (overwrite)")
+
+
+def test_the_four_worker_run_wrote_four_parts_and_a_marker(osw, expect):
+ expect.text(osw["sr four"], "ok", "shrink: the 4-worker run wrote 4 parts + marker")
+
+
+def test_a_smaller_rerun_leaves_exactly_its_own_parts(osw, expect):
+ expect.text(osw["sr two"], "2", "shrink: a smaller re-run leaves exactly its own 2 parts (stale tail dropped)")
+
+
+def test_the_larger_runs_stale_parts_are_gone(osw, expect):
+ expect.text(osw["sr gone"], "gone", "shrink: parts 0002/0003 from the larger run are gone")
+
+
+def test_the_marker_certifies_the_reconciled_prefix(osw, expect):
+ expect.text(osw["sr marker"], "yes", "shrink: _SUCCESS certifies the reconciled prefix")
+
+
+def test_a_worker_was_uploading_before_the_cancel(osw, expect):
+ expect.text(osw["cx wrote"], "yes", "premise: a worker was uploading before the cancel")
+
+
+def test_all_four_uploads_were_open_before_the_cancel(osw, expect):
+ expect.text(osw["cx all open"], "yes", "all four workers' uploads were open before the cancel")
+
+
+def test_the_export_was_cancelled_not_completed(osw, expect):
+ expect.text(osw["cx cancelled"], "cancelled", "premise: the export was cancelled, not completed")
+
+
+def test_the_dispatcher_deleted_the_known_keys_on_cancel(osw, expect):
+ expect.text(osw["cx delete"], "yes", "cancel: the dispatcher issued DELETE over the known keys")
+
+
+def test_no_completed_object_remains_after_the_cancel(osw, expect):
+ expect.text(osw["cx parts"], "0", "cancel: no completed object remains at the prefix")
+
+
+def test_no_success_marker_remains_after_the_cancel(osw, expect):
+ expect.text(osw["cx marker"], "absent", "cancel: no _SUCCESS marker at the prefix")
+
+
+def test_no_upload_stays_open_after_the_cancel(osw, expect):
+ # #1722: a worker the cancel terminates aborts its multipart upload on the way out
+ expect.text(osw["cx open"], "0", "cancel: the server holds no open upload under the prefix")
+
+
+def test_terminated_workers_exit_while_the_store_is_silent(osw, expect):
+ # #1727 review: the exit callback's abort waits in slices that time out, so the 60 s
+ # quiet limit ends it; a silent store cannot hold a terminated worker
+ expect.text(osw["hs exit"], "bounded", "terminate: export workers exit within 75 s while the store is silent")
diff --git a/test/pytest/test_objstore_stash_recovery.py b/test/pytest/test_objstore_stash_recovery.py
new file mode 100644
index 000000000..4ec53cb86
--- /dev/null
+++ b/test/pytest/test_objstore_stash_recovery.py
@@ -0,0 +1,282 @@
+"""Recovery from a stash an interrupted objstore_module run left behind: the pytest
+twin of `test/objstore_stash_recovery.sh` (#1341). Every check carries the shell's
+name.
+
+objstore_module.sh moves the installed module aside to reach its absent and broken
+paths, and an interrupt between a move and its restore leaves a
+pgcolumnar_objstore.so.probe or .away in pkglibdir. Its start guard has to tell
+debris beside a good module (remove it, continue) from the only surviving copy
+(restore it, continue), and the discriminator is whether the module BESIDE the
+stash is valid, not whether a stash is present.
+
+THE SUBJECT IS THE SHELL SUITE'S START GUARD, so this file runs
+`test/objstore_module.sh` as a subprocess, exactly as the shell twin does, and
+asserts on its exit status and on what it left in pkglibdir. Nothing here reaches
+inside the guard. Three arrangements, each from the pristine copy:
+
+ - debris: a valid module installed, and a DIFFERENT valid module at .probe;
+ - only copy: nothing installed, the real module at .probe;
+ - stand-in: 19 bytes of text installed, the real module at .away.
+
+THE INSTALLED MODULE IS SHARED, so this file must run alone. The cluster job runs
+its files in one process, and the shell runner keeps the shell twin in
+`runs_alone` for the same reason. A pristine copy is taken first and put back in
+the fixture's finally, so a wrong guard does not leave the box in the state this
+file exists to recover from.
+"""
+import os
+import pathlib
+import shutil
+import subprocess
+import tempfile
+
+import pytest
+
+REPO = pathlib.Path(__file__).resolve().parents[2]
+SUITE = REPO / "test" / "objstore_module.sh"
+
+
+def _valid(path):
+ """A module is valid when nm finds the entry point: the shell's mod_is_valid."""
+ if not path.exists():
+ return False
+ r = subprocess.run(["nm", "-D", "--defined-only", str(path)],
+ capture_output=True, text=True)
+ return any(" T pgcolumnar_objstore_init" in l for l in r.stdout.splitlines())
+
+
+def _same(a, b):
+ return a.exists() and b.exists() and a.read_bytes() == b.read_bytes()
+
+
+def _run_suite(pg_config):
+ env = dict(os.environ, PGC_SKIP_BUILD="1")
+ env.pop("PGC_PORT", None)
+ r = subprocess.run(["bash", str(SUITE), pg_config], capture_output=True,
+ text=True, env=env, timeout=1800)
+ if r.returncode != 0:
+ # SAID, not swallowed: CI's log is the only place a reader sees why the inner
+ # suite failed, and an exit status alone named nothing (#1717 review).
+ tail = (r.stdout + r.stderr).splitlines()[-40:]
+ print(f"-- {SUITE.name} exited {r.returncode}; last {len(tail)} lines:")
+ for line in tail:
+ print(f" {line}")
+ return r.returncode
+
+
+@pytest.fixture(scope="module")
+def osr(pgc_cluster):
+ """-> what each arrangement looked like before the run and what it left after."""
+ mod = pathlib.Path(pgc_cluster.libdir) / "pgcolumnar_objstore.so"
+ probe, away = mod.with_name(mod.name + ".probe"), mod.with_name(mod.name + ".away")
+ if not _valid(mod):
+ return {"valid": False, "mod": str(mod)}
+ fd, safe = tempfile.mkstemp(prefix="pgc-objstore-safe.")
+ os.close(fd)
+ safe = pathlib.Path(safe)
+ shutil.copy2(mod, safe)
+
+ def pristine():
+ probe.unlink(missing_ok=True)
+ away.unlink(missing_ok=True)
+ shutil.copy2(safe, mod)
+
+ o = {"valid": True}
+ try:
+ # debris beside a good installation: a stale build, not a copy
+ pristine()
+ o["d valid before"] = _valid(mod)
+ shutil.copy2(mod, probe)
+ with open(probe, "ab") as f:
+ f.write(b"stale")
+ o["d stash placed"] = probe.exists()
+ o["d stash differs"] = _valid(probe) and not _same(probe, mod)
+ o["d rc"] = _run_suite(pgc_cluster.pg_config)
+ o["d same"] = _same(mod, safe)
+ o["d stash left"] = probe.exists()
+ o["d valid after"] = _valid(mod)
+
+ # the only surviving copy, at .probe
+ pristine()
+ mod.rename(probe)
+ o["o absent"] = not mod.exists()
+ o["o stash real"] = _valid(probe)
+ o["o rc"] = _run_suite(pgc_cluster.pg_config)
+ o["o valid after"] = _valid(mod)
+ o["o stash left"] = probe.exists()
+
+ # a stand-in installed over the real module, which is at .away
+ pristine()
+ mod.rename(away)
+ mod.write_bytes(b"not a shared object")
+ o["s size"] = mod.stat().st_size
+ o["s stand-in valid"] = _valid(mod)
+ o["s stash real"] = _valid(away)
+ o["s rc"] = _run_suite(pgc_cluster.pg_config)
+ o["s valid after"] = _valid(mod)
+ o["s stash left"] = away.exists()
+ finally:
+ probe.unlink(missing_ok=True)
+ away.unlink(missing_ok=True)
+ if not _valid(mod):
+ shutil.copy2(safe, mod)
+ safe.unlink()
+ return o
+
+
+def _unrunnable(osr, expect):
+ if osr["valid"]:
+ return False
+ expect.cannot_run("UNMET_PRECONDITION",
+ f"no valid module installed at {osr['mod']}, so there is no state to arrange",
+ name="stash recovery")
+ return True
+
+
+def _yes(b):
+ return "yes" if b else "no"
+
+
+def _present(b):
+ return "present" if b else "absent"
+
+
+def test_the_session_build_installed_a_valid_module(osr, expect):
+ """The shell builds here unless PGC_SKIP_BUILD is set and records a failed build
+ by this name. The session fixture builds and installs before any test runs and
+ raises if either fails, so what is left to observe here is the artifact: a
+ build that installed no module that nm can read measured nothing below."""
+ expect.text(_yes(osr["valid"]), "yes",
+ "the build or install failed, so nothing below measures the guard")
+
+
+# ---- debris beside a good installation must not stop the suite ----------------
+
+def test_debris_premise_installed_module_valid(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["d valid before"]), "yes",
+ "premise: the installed module is valid before this slice arranges anything")
+
+
+def test_debris_premise_stash_in_place(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["d stash placed"]), "yes",
+ "premise: a debris stash really is in place for the run below")
+
+
+def test_debris_premise_stash_is_a_different_valid_module(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["d stash differs"]), "yes",
+ "premise: and the debris is a valid module that differs from the installed one")
+
+
+def test_debris_suite_completes(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.num(osr["d rc"], 0,
+ "the suite runs to completion with debris beside a valid module")
+
+
+def test_debris_not_restored_over_the_module(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text("same" if osr["d same"] else "replaced", "same",
+ "and the installed module is still the one that was there, not the debris")
+
+
+def test_debris_gone(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_present(osr["d stash left"]), "absent",
+ "the debris is gone afterwards")
+
+
+def test_debris_module_still_valid(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["d valid after"]), "yes",
+ "and the module left installed is still valid")
+
+
+# ---- a stash that is the only surviving copy must be restored, not refused ----
+
+def test_only_copy_premise_nothing_installed(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_present(not osr["o absent"]), "absent",
+ "premise: nothing is installed at the module's path for this slice")
+
+
+def test_only_copy_premise_stash_is_real(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["o stash real"]), "yes",
+ "premise: and the stash beside it is the real module")
+
+
+def test_only_copy_suite_completes(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.num(osr["o rc"], 0,
+ "the suite recovers the only surviving copy and runs to completion")
+
+
+def test_only_copy_reinstalled(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["o valid after"]), "yes",
+ "the module is installed again afterwards, and valid")
+
+
+def test_only_copy_stash_not_left(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_present(osr["o stash left"]), "absent",
+ "and the stash is not left behind for the next run to trip on")
+
+
+# ---- a stand-in installed over the real module is the destructive case --------
+
+def test_stand_in_premise_installed(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.num(osr["s size"], 19,
+ "premise: a 19-byte stand-in really is installed as the module")
+
+
+def test_stand_in_premise_not_a_module(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["s stand-in valid"]), "no",
+ "premise: which is not a module")
+
+
+def test_stand_in_premise_stash_is_real(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["s stash real"]), "yes",
+ "premise: and the real module is the thing parked at the stash")
+
+
+def test_stand_in_suite_completes(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.num(osr["s rc"], 0,
+ "the suite replaces the stand-in with the real module and completes")
+
+
+def test_stand_in_real_module_survived(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_yes(osr["s valid after"]), "yes",
+ "the real module survived, rather than being deleted with the stash")
+
+
+def test_stand_in_stash_cleared(osr, expect):
+ if _unrunnable(osr, expect):
+ return
+ expect.text(_present(osr["s stash left"]), "absent",
+ "and the stash is cleared")
diff --git a/test/pytest/test_objstore_tls_read.py b/test/pytest/test_objstore_tls_read.py
new file mode 100644
index 000000000..a374a2523
--- /dev/null
+++ b/test/pytest/test_objstore_tls_read.py
@@ -0,0 +1,415 @@
+"""TLS for the object-store module (#393 M3): the pytest twin of
+`test/objstore_tls_read.sh`, every check by the shell's name (#1341).
+
+A refusal matrix over the shell's own fixture server (test/objstore_http_server.py)
+and the shell's own PKI, made by the openssl CLI: one server whose certificate is good
+(CA-1, SAN IP:127.0.0.1), one that signs SigV4 over TLS, and four that must each be
+refused for a different reason -- a wrong host, an expired certificate, an untrusted
+CA, and a self-signed leaf. The good arm is the over-restriction proof: a module that
+refuses everything cannot pass it.
+
+A REFUSAL IS NOT ANY ERROR: each must be 08006 with the module's own message naming
+the URL it could not reach, and with OpenSSL's verification reason for THAT
+certificate as the whole DETAIL. A cleartext request to a TLS port also fails 08006,
+and a different verification failure also mentions a certificate; neither passes.
+Each also carries the premise that the handshake was attempted -- the fixture logs
+HANDSHAKE at accept, before the handshake can fail -- and waits for SELECT 1
+afterwards, so a crash fails its own checks, which the server log's "terminated by
+signal" count reports at the end.
+
+Trust comes only from the postmaster environment, which OpenSSL's default verify
+paths honour: SSL_CERT_FILE for every arm but E, which restarts with SSL_CERT_DIR
+alone (CA-1 under its subject hash) and must still read the good server.
+
+The PKI, the parquet fixture and the logs live in `server_dir`, because the backend
+reads the CA and the local fixture. The parquet object is written by a python3 child,
+as the shell's heredoc writes it.
+
+A PRIVATE CLUSTER, because the postmaster is restarted with SSL_CERT_* and AWS_* in
+its environment, which the shared cluster cannot do. It starts with every AWS_* and
+SSL_CERT_* variable of this process removed.
+"""
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+import time
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+TEST = pathlib.Path(__file__).resolve().parents[1]
+AKID, SECRET, REGION, BUCKET = "PGCTESTKEYID", "pgctest-secret-shhh", "pgc-test-1", "pgc-bucket"
+G, NCOLS, ROWS_PER_GROUP = 3, 8, 8000
+COLDEFS = ", ".join(f"c{i} int8" for i in range(NCOLS))
+# label -> (foreign table, server, OpenSSL's verification reason)
+REFUSED = {
+ "wrong-host": ("fwrong", "wronghost", "IP address mismatch"),
+ "expired": ("fexp", "expired", "certificate has expired"),
+ "untrusted-ca": ("funtr", "untrusted", "unable to get local issuer certificate"),
+ "self-signed": ("fself", "selfsigned", "self-signed certificate"),
+}
+CRASH = "terminated by signal"
+
+
+def _message(exc):
+ return str(exc).splitlines()[0] if str(exc) else type(exc).__name__
+
+
+def _rows(dsn, *sqls):
+ """Run `sqls` on one connection -> the last one's rows, or a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for sql in sqls[:-1]:
+ conn.execute(sql)
+ return conn.execute(sqls[-1]).fetchall()
+ except psycopg.Error as exc:
+ failed = _message(exc)
+ return query_error(failed)
+
+
+def _one(dsn, sql):
+ """-> the first column of the first row, or a query_error()."""
+ rows = _rows(dsn, sql)
+ if not isinstance(rows, list):
+ return rows
+ if not rows or rows[0][0] is None:
+ return query_error(f"no value from {sql}")
+ return rows[0][0]
+
+
+def _refusal(dsn, *sqls):
+ """-> {"state", "message", "detail", "rows"} of the last of `sqls`: what its error
+ said, or "no error" and the rows it returned."""
+ err = None
+ try:
+ with psycopg.connect(dsn, autocommit=True, connect_timeout=10) as conn:
+ for sql in sqls[:-1]:
+ conn.execute(sql)
+ rows = conn.execute(sqls[-1]).fetchall()
+ except psycopg.Error as exc:
+ err = exc
+ if err is None:
+ return {"state": "no error", "message": "no error", "detail": "no error", "rows": len(rows)}
+ said = _message(err)
+ return {"state": err.sqlstate or query_error(said),
+ "message": err.diag.message_primary or query_error(said),
+ "detail": err.diag.message_detail or query_error(f"no DETAIL: {said}"),
+ "rows": 0}
+
+
+def _wait_up(dsn):
+ """A crashed backend fails its own check, not the next arm's: wait up to 15 s for
+ SELECT 1, as the shell's wait_up does."""
+ for _ in range(30):
+ if _one(dsn, "SELECT 1") == 1:
+ return
+ time.sleep(0.5)
+
+
+def _count(path, prefix):
+ """-> the lines of `path` that start with (or, for the server log, contain)
+ `prefix`, or a query_error()."""
+ try:
+ lines = pathlib.Path(path).read_text(errors="replace").splitlines()
+ except OSError as exc:
+ return query_error(f"{path} unreadable: {exc}")
+ return sum(1 for l in lines if (prefix in l if prefix == CRASH else l.startswith(prefix)))
+
+
+def _openssl(*args):
+ return subprocess.run(["openssl"] + [str(a) for a in args], capture_output=True, text=True)
+
+
+def _pki(pki):
+ """The shell's PKI, by the same openssl commands. -> None, or what failed."""
+ pki.mkdir()
+
+ def ca(name):
+ _openssl("genrsa", "-out", pki / f"{name}.key", "2048")
+ _openssl("req", "-x509", "-new", "-key", pki / f"{name}.key", "-subj", f"/CN=pgc-test-{name}",
+ "-days", "2", "-out", pki / f"{name}.pem")
+
+ def cert(name, issuer, san, *extra):
+ _openssl("genrsa", "-out", pki / f"{name}.key", "2048")
+ _openssl("req", "-new", "-key", pki / f"{name}.key", "-subj", f"/CN={name}", "-out", pki / f"{name}.csr")
+ (pki / f"{name}.ext").write_text(f"subjectAltName={san}\n")
+ _openssl("x509", "-req", "-in", pki / f"{name}.csr", "-CA", pki / f"{issuer}.pem",
+ "-CAkey", pki / f"{issuer}.key", "-CAcreateserial", "-days", "2",
+ "-extfile", pki / f"{name}.ext", *extra, "-out", pki / f"{name}.pem")
+
+ ca("ca1")
+ ca("ca2")
+ cert("good", "ca1", "IP:127.0.0.1")
+ cert("goodsig", "ca1", "IP:127.0.0.1")
+ cert("wronghost", "ca1", "DNS:other.example")
+ # -not_before/-not_after need OpenSSL 3.4; on an older one, a zero-lifetime
+ # certificate, as the shell falls back to. The premise proves it expired.
+ cert("expired", "ca1", "IP:127.0.0.1", "-not_before", "20200101000000Z", "-not_after", "20200102000000Z")
+ if not (pki / "expired.pem").is_file() or (pki / "expired.pem").stat().st_size == 0:
+ cert("expired", "ca1", "IP:127.0.0.1", "-days", "0")
+ cert("untrusted", "ca2", "IP:127.0.0.1")
+ _openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", pki / "selfsigned.key",
+ "-subj", "/CN=selfsigned", "-addext", "subjectAltName=IP:127.0.0.1",
+ "-days", "2", "-out", pki / "selfsigned.pem")
+ (pki / "cadir").mkdir()
+ h = _openssl("x509", "-hash", "-noout", "-in", pki / "ca1.pem").stdout.strip()
+ shutil.copy(pki / "ca1.pem", pki / "cadir" / f"{h}.0")
+ for f in ("ca1", "ca2", "good", "goodsig", "wronghost", "expired", "untrusted", "selfsigned"):
+ p = pki / f"{f}.pem"
+ if not p.is_file() or p.stat().st_size == 0:
+ return f"PKI generation failed for {f}"
+ return None
+
+
+def _write_parquet(path):
+ """The shell's fixture, written by a python3 child as its heredoc does. -> None, or
+ the decline when pyarrow is absent. Any other failure raises."""
+ w = subprocess.run([sys.executable, "-c",
+ "import sys, pyarrow as pa, pyarrow.parquet as pq\n"
+ "G, NCOLS, RPG = (int(a) for a in sys.argv[2:5])\n"
+ "n = G * RPG\n"
+ "cols = {('c%d' % i): pa.array([(i + 1) * v for v in range(n)], pa.int64())"
+ " for i in range(NCOLS)}\n"
+ "pq.write_table(pa.table(cols), sys.argv[1], row_group_size=RPG,"
+ " data_page_size=1024, compression='NONE', use_dictionary=False)\n",
+ str(path), str(G), str(NCOLS), str(ROWS_PER_GROUP)], capture_output=True, text=True)
+ if w.returncode == 0:
+ return None
+ if "No module named 'pyarrow" in w.stderr:
+ return ("MISSING_DEPENDENCY", "pyarrow is not installed")
+ raise RuntimeError(f"writing {path} failed: {w.stderr.strip()}")
+
+
+def _pg_ctl(cluster, args, extra_env):
+ """pg_ctl with an explicit environment: every AWS_* and SSL_CERT_* of this process
+ removed, then `extra_env` added. Dropped to postgres when running as root, as the
+ harness does."""
+ unset = [a for k in os.environ if k.startswith(("AWS_", "SSL_CERT_")) for a in ("-u", k)]
+ cmd = ["env"] + unset + [f"{k}={v}" for k, v in extra_env.items()] + \
+ [os.path.join(cluster.bindir, "pg_ctl")] + args
+ if os.geteuid() == 0:
+ cmd = ["runuser", "-u", "postgres", "--"] + cmd
+ return subprocess.run(cmd, capture_output=True, text=True)
+
+
+def _restart(cluster, datadir, log, env):
+ """Restart the private cluster with env, and wait up to 15 s for SELECT 1."""
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "fast", "-w", "stop"], {})
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], env)
+ for _ in range(30):
+ if started.returncode == 0 and _one(cluster.dsn(), "SELECT 1") == 1:
+ return
+ time.sleep(0.5)
+ raise RuntimeError(f"cluster did not come back after env restart: {started.stderr.strip()}")
+
+
+@pytest.fixture(scope="module")
+def otr(pgc_cluster, server_dir):
+ if shutil.which("openssl") is None:
+ yield {"absent": ("MISSING_DEPENDENCY", "the openssl CLI is not installed"), "openssl": False}
+ return
+ work = server_dir
+ (work / BUCKET).mkdir()
+ wrote = _write_parquet(work / BUCKET / "fixture.parquet")
+ if wrote is not None:
+ yield {"absent": wrote, "openssl": True}
+ return
+ from pgc_cluster import Cluster, pick_port
+
+ pki, tlslog = work / "pki", str(work / "tls.log")
+ failed = _pki(pki)
+ if failed is not None:
+ raise RuntimeError(failed)
+ r = {"absent": None, "openssl": True}
+ r["expired premise"] = "valid" if _openssl("x509", "-in", pki / "expired.pem", "-checkend", "0").returncode == 0 \
+ else "expired"
+
+ srvs, port = [], {}
+ for slot, (name, extra) in enumerate([
+ ("good", []),
+ ("goodsig", ["--sigv4-key", AKID, "--sigv4-secret", SECRET, "--sigv4-region", REGION]),
+ ("wronghost", []), ("expired", []), ("untrusted", []), ("selfsigned", [])]):
+ port[name] = pick_port(40 + slot)
+ out = work / f"srv_{name}.out"
+ srvs.append(subprocess.Popen(
+ [sys.executable, str(TEST / "objstore_http_server.py"), "--dir", str(work),
+ "--port", str(port[name]), "--log", tlslog,
+ "--tls-cert", str(pki / f"{name}.pem"), "--tls-key", str(pki / f"{name}.key")] + extra,
+ stdout=open(out, "w"), stderr=subprocess.STDOUT))
+ for _ in range(50):
+ if out.is_file() and "READY" in out.read_text(errors="replace"):
+ break
+ time.sleep(0.1)
+ r["servers up"] = sum(1 for n in port if "READY" in (work / f"srv_{n}.out").read_text(errors="replace"))
+
+ datadir = work / "data"
+ datadir.mkdir()
+ log = str(work / "server.log")
+ cluster = Cluster(pgc_cluster.pg_config, "otr", datadir, pick_port(39))
+ try:
+ cluster.initdb()
+ with open(datadir / "postgresql.conf", "a") as fh:
+ fh.write("\npgcolumnar.objstore_allowed_endpoints='127.0.0.1'\n")
+ started = _pg_ctl(cluster, ["-D", str(datadir), "-l", log, "-w", "start"], {})
+ if started.returncode != 0:
+ raise RuntimeError(f"the private cluster did not start: {started.stderr}")
+ dsn = cluster.dsn()
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE EXTENSION IF NOT EXISTS pgcolumnar")
+ _restart(cluster, datadir, log, {
+ "SSL_CERT_FILE": str(pki / "ca1.pem"), "AWS_ENDPOINT_URL": f"https://127.0.0.1:{port['goodsig']}",
+ "AWS_ACCESS_KEY_ID": AKID, "AWS_SECRET_ACCESS_KEY": SECRET, "AWS_REGION": REGION})
+ crashes_before = _count(log, CRASH)
+
+ def url(name, prefix=""):
+ return f"https://127.0.0.1:{port[name]}/{prefix}{BUCKET}/fixture.parquet"
+
+ with psycopg.connect(dsn, autocommit=True) as c:
+ c.execute("CREATE SERVER tlssrv FOREIGN DATA WRAPPER pgcolumnar_parquet")
+ for tbl, path in [("flocal", str(work / BUCKET / "fixture.parquet")), ("fgood", url("good")),
+ ("fs3tls", f"s3://{BUCKET}/fixture.parquet"), ("fwrong", url("wronghost")),
+ ("fexp", url("expired")), ("funtr", url("untrusted")),
+ ("fself", url("selfsigned")), ("fstall", url("good", "stall/"))]:
+ c.execute(f"CREATE FOREIGN TABLE {tbl} ({COLDEFS}) SERVER tlssrv OPTIONS (path '{path}')")
+
+ r["local"] = _rows(dsn, "SELECT * FROM flocal")
+ r["A https"] = _rows(dsn, "SELECT * FROM fgood")
+ r["A count"] = _one(dsn, "SELECT count(*) FROM fgood")
+ r["A2 s3"] = _rows(dsn, "SELECT * FROM fs3tls")
+
+ for label, (tbl, name, _) in REFUSED.items():
+ hs0 = _count(tlslog, "HANDSHAKE")
+ got = _refusal(dsn, f"SELECT count(*) FROM {tbl}")
+ hs1 = _count(tlslog, "HANDSHAKE")
+ _wait_up(dsn)
+ got["attempted"] = "yes" if isinstance(hs0, int) and isinstance(hs1, int) and hs1 > hs0 \
+ else f"no: HANDSHAKE lines {hs0} -> {hs1}"
+ got["url"] = url(name)
+ r[label] = got
+
+ t0 = time.monotonic()
+ r["D state"] = _refusal(dsn, "SET statement_timeout = '2s'", "SELECT count(*) FROM fstall")["state"]
+ r["D elapsed"] = time.monotonic() - t0
+ _wait_up(dsn)
+
+ _restart(cluster, datadir, log, {"SSL_CERT_DIR": str(pki / "cadir")})
+ r["E count"] = _one(dsn, "SELECT count(*) FROM fgood")
+
+ after = _count(log, CRASH)
+ r["crashes"] = after - crashes_before if isinstance(after, int) and isinstance(crashes_before, int) \
+ else query_error(f"crash count unreadable: {crashes_before} -> {after}")
+ yield r
+ finally:
+ _pg_ctl(cluster, ["-D", str(datadir), "-m", "immediate", "-w", "stop"], {})
+ for s in srvs:
+ s.kill()
+ s.wait()
+
+
+def _declined(otr, expect):
+ if otr["absent"] is None:
+ return False
+ expect.cannot_run(*otr["absent"])
+ return True
+
+
+def test_the_expired_certificate_is_genuinely_expired(otr, expect):
+ """The shell skips the whole suite without pyarrow or the openssl CLI, under these
+ names."""
+ if expect.require_module("pyarrow", name="pyarrow is required to write the fixture") is None:
+ return
+ if not otr["openssl"]:
+ expect.cannot_run(*otr["absent"], name="openssl CLI is required to generate the fixture PKI")
+ return
+ expect.text(otr["expired premise"], "expired", "premise: expired certificate is genuinely expired")
+
+
+def test_six_tls_servers_are_up(otr, expect):
+ if _declined(otr, expect):
+ return
+ expect.num(otr["servers up"], 6, "premise: six TLS servers are up")
+
+
+def test_an_https_read_equals_the_local_read(otr, expect):
+ if _declined(otr, expect):
+ return
+ expect.row_set(otr["A https"], otr["local"], "A: https differential == local")
+
+
+def test_count_over_https(otr, expect):
+ if _declined(otr, expect):
+ return
+ expect.num(otr["A count"], G * ROWS_PER_GROUP, "A: count(*) over https")
+
+
+def test_s3_over_https_equals_the_local_read(otr, expect):
+ if _declined(otr, expect):
+ return
+ expect.row_set(otr["A2 s3"], otr["local"], "A2: s3 over https differential == local")
+
+
+@pytest.mark.parametrize("label", sorted(REFUSED))
+def test_the_handshake_was_attempted(otr, expect, label):
+ if _declined(otr, expect):
+ return
+ expect.text(otr[label]["attempted"], "yes", f"premise: {label} handshake was attempted")
+
+
+@pytest.mark.parametrize("label", sorted(REFUSED))
+def test_the_certificate_is_refused_08006(otr, expect, label):
+ if _declined(otr, expect):
+ return
+ expect.text(otr[label]["state"], "08006", f"C: {label} refused with 08006")
+
+
+@pytest.mark.parametrize("label", sorted(REFUSED))
+def test_the_refusal_is_the_tls_one(otr, expect, label):
+ if _declined(otr, expect):
+ return
+ expect.text(otr[label]["message"], f'columnar: could not establish TLS to "{otr[label]["url"]}"',
+ f"C: {label} refusal is the TLS one")
+
+
+@pytest.mark.parametrize("label", sorted(REFUSED))
+def test_the_refusal_names_its_reason(otr, expect, label):
+ """The whole DETAIL: OpenSSL's verification reason for this certificate."""
+ if _declined(otr, expect):
+ return
+ expect.text(otr[label]["detail"], f"Certificate verification failed: {REFUSED[label][2]}.",
+ f"C: {label} names its reason")
+
+
+@pytest.mark.parametrize("label", sorted(REFUSED))
+def test_the_refused_read_returned_no_rows(otr, expect, label):
+ if _declined(otr, expect):
+ return
+ expect.num(otr[label]["rows"], 0, f"C: {label} returned no rows")
+
+
+def test_a_stalled_tls_transfer_is_cancelled(otr, expect):
+ if _declined(otr, expect):
+ return
+ expect.text(otr["D state"], "57014", "D: stalled TLS transfer cancelled with query_canceled")
+
+
+def test_the_cancel_latency_is_bounded(otr, expect):
+ if _declined(otr, expect):
+ return
+ got = "bounded" if otr["D elapsed"] <= 10 else f"slow({otr['D elapsed']:.0f}s)"
+ expect.text(got, "bounded", "D: cancel latency bounded (<=10s)")
+
+
+def test_ssl_cert_dir_alone_trusts_the_ca(otr, expect):
+ if _declined(otr, expect):
+ return
+ expect.num(otr["E count"], G * ROWS_PER_GROUP, "E: SSL_CERT_DIR alone trusts the CA (count(*) over https)")
+
+
+def test_no_backend_was_terminated_by_a_signal(otr, expect):
+ if _declined(otr, expect):
+ return
+ expect.num(otr["crashes"], 0, "survival: no backend was terminated by a signal")
diff --git a/test/pytest/test_phase2.py b/test/pytest/test_phase2.py
new file mode 100644
index 000000000..60a90e20d
--- /dev/null
+++ b/test/pytest/test_phase2.py
@@ -0,0 +1,281 @@
+"""Compression, projection, zone-map min/max and chunk-group filtering, plus the phase 1
+regression checks: the pytest twin of `test/phase2.sh`, every check by the shell's name
+(#1341).
+
+Each codec writes 20000 rows with an identical 100-byte payload, and must round-trip
+AND be the codec stored: the round-trip alone passed with the session's setting ignored
+and with no block codec ever applied. A single value is too small to compress, so it is
+stored with codec 0 whatever was asked. The zone maps of 25000 sorted ints in three
+vectors are decoded from their little-endian bytes, and a range wholly inside the last
+vector must decode one of the three, not merely return the right rows: exact selection
+returns them with the zone maps ruling out nothing. Every expected value comes from the
+generator, never from the columnar table.
+
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order, because the
+fallback and orphan checks read the database's whole catalog. A reading is a value or a
+query_error(), never text that stands in for one.
+"""
+import re
+
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_phase2"
+CODECS = (("none", 0), ("pglz", 1), ("lz4", 2), ("zstd", 3))
+ZM = ("pgcolumnar.zone_map WHERE storage_id = pgcolumnar.get_storage_id('s') "
+ "AND column_index = 0 AND vector_index")
+DEC = ("((get_byte(minimum,3)::bigint<<24)|(get_byte(minimum,2)<<16)"
+ "|(get_byte(minimum,1)<<8)|get_byte(minimum,0))")
+DECX = DEC.replace("minimum", "maximum")
+
+
+def _cell(v):
+ if v is None:
+ return ""
+ if isinstance(v, bool):
+ return "t" if v else "f"
+ return str(v)
+
+
+def _q(dsn, *sql, sep="\n", trail=""):
+ """-> the last statement's rows as psql -At prints them (`sep` between rows, `trail`
+ after each when given), or a query_error()."""
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ for s in sql[:-1]:
+ conn.execute(s)
+ rows = conn.execute(sql[-1]).fetchall()
+ except psycopg.Error as exc:
+ return query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__)
+ if trail:
+ return "".join("|".join(_cell(v) for v in r) + trail for r in rows)
+ return sep.join("|".join(_cell(v) for v in r) for r in rows)
+
+
+def _qq(dsn, sql):
+ return _q(dsn, sql, trail=",")
+
+
+def _decoded(dsn, sql):
+ """-> the plan's Columnar Vectors Decoded, or a query_error()."""
+ plan = _q(dsn, "SET max_parallel_workers_per_gather = 0",
+ f"EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) {sql}")
+ m = re.search(r"Columnar Vectors Decoded: (\d+)", plan)
+ return m.group(1) if m else query_error(f"no Vectors Decoded line in [{plan}]")
+
+
+@pytest.fixture(scope="module")
+def p2(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ q = lambda *s: _q(dsn, *s)
+ q("CREATE EXTENSION pgcolumnar")
+ r = {}
+
+ q("CREATE TABLE t (a int, b text) USING pgcolumnar")
+ q("INSERT INTO t SELECT g, g::text FROM generate_series(1,100000) g")
+ r["count(*)"] = q("SELECT count(*) FROM t")
+ r["count where a<50"] = q("SELECT count(*) FROM t WHERE a < 50")
+ r["order by a limit3"] = _qq(dsn, "SELECT a || CHR(124) || b FROM t ORDER BY a LIMIT 3")
+ r["no nulls t.b"] = q("SELECT count(*) FROM t WHERE b IS NULL")
+ q("DROP TABLE t")
+
+ for codec, _ in CODECS:
+ q("CREATE TABLE c (id int, payload text) USING pgcolumnar")
+ # SET and INSERT in one session so the setting is in force at write time.
+ q(f"SET pgcolumnar.compression = '{codec}'",
+ "INSERT INTO c SELECT g, repeat('x',100) FROM generate_series(1,20000) g")
+ r[f"{codec} count"] = q("SELECT count(*) FROM c")
+ r[f"{codec} sum(id)"] = q("SELECT sum(id) FROM c")
+ r[f"{codec} payload ok"] = q("SELECT payload = repeat('x',100) FROM c WHERE id = 12345")
+ r[f"{codec} distinct pl"] = q("SELECT count(DISTINCT payload) FROM c")
+ r[f"{codec} block codec"] = q(
+ "SELECT string_agg(DISTINCT block_codec::text, ',') FROM pgcolumnar.column_chunk "
+ "WHERE storage_id = pgcolumnar.get_storage_id('c') AND column_index = 1")
+ q("DROP TABLE c")
+
+ q("CREATE TABLE f (id int) USING pgcolumnar")
+ q("SET pgcolumnar.compression = 'zstd'", "INSERT INTO f VALUES (42)")
+ r["fallback value"] = q("SELECT id FROM f")
+ r["fallback codec=0"] = q("SELECT block_codec FROM pgcolumnar.column_chunk WHERE column_index = 0")
+ q("DROP TABLE f")
+
+ q("CREATE TABLE n (a int, b text) USING pgcolumnar")
+ q("SET pgcolumnar.compression = 'lz4'",
+ "INSERT INTO n SELECT g, CASE WHEN g%3=0 THEN NULL ELSE repeat('y',50) END "
+ "FROM generate_series(1,15000) g")
+ r["null rows"] = q("SELECT count(*) FROM n")
+ r["null count"] = q("SELECT count(*) FROM n WHERE b IS NULL")
+ r["null value"] = q("SELECT b IS NULL FROM n WHERE a = 9")
+ r["nonnull val"] = q("SELECT b = repeat('y',50) FROM n WHERE a = 8")
+ q("DROP TABLE n")
+
+ q("CREATE TABLE p (a int, b text, c int) USING pgcolumnar")
+ q("SET pgcolumnar.compression = 'zstd'",
+ "INSERT INTO p SELECT g, 'row'||g, g*2 FROM generate_series(1,30000) g")
+ r["project a"] = q("SELECT a FROM p WHERE a = 100")
+ r["project b"] = q("SELECT b FROM p WHERE a = 100")
+ r["project c"] = q("SELECT c FROM p WHERE a = 100")
+ r["project sum"] = q("SELECT sum(c) FROM p")
+ r["project b,c"] = _qq(dsn, "SELECT b || CHR(124) || c FROM p ORDER BY a LIMIT 2")
+ q("DROP TABLE p")
+
+ q("CREATE TABLE s (id int) USING pgcolumnar")
+ q("SET pgcolumnar.compression = 'zstd'", "INSERT INTO s SELECT g FROM generate_series(1,25000) g")
+ r["vectors"] = q(f"SELECT count(*) FROM {ZM} >= 0")
+ r["minmax present"] = q(f"SELECT count(*) FROM {ZM} >= 0 AND minimum IS NOT NULL AND maximum IS NOT NULL")
+ r["v0 min=1"] = q(f"SELECT {DEC} FROM {ZM} = 0")
+ r["v0 max=10000"] = q(f"SELECT {DECX} FROM {ZM} = 0")
+ r["v2 max=25000"] = q(f"SELECT {DECX} FROM {ZM} = 2")
+ r["between count"] = q("SELECT count(*) FROM s WHERE id BETWEEN 15000 AND 15010")
+ r["range min/max"] = _qq(dsn, "SELECT min(id) || CHR(124) || max(id) FROM s WHERE id > 24990")
+ r["point lookup"] = q("SELECT count(*) FROM s WHERE id = 20005")
+ r["empty range"] = q("SELECT count(*) FROM s WHERE id > 30000")
+ r["range decodes 1 of 3 vectors"] = _decoded(dsn, "SELECT id FROM s WHERE id > 24990")
+ q("DROP TABLE s")
+
+ r["orphan row groups"] = q("SELECT count(*) FROM pgcolumnar.row_group")
+ r["orphan chunks"] = q("SELECT count(*) FROM pgcolumnar.column_chunk")
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_count_star_reads_every_row(p2, expect):
+ expect.text(p2["count(*)"], "100000", "count(*)")
+
+
+def test_a_filtered_count_reads_the_matching_rows(p2, expect):
+ expect.text(p2["count where a<50"], "49", "count where a<50")
+
+
+def test_order_by_limit_returns_the_first_three_rows(p2, expect):
+ expect.text(p2["order by a limit3"], "1|1,2|2,3|3,", "order by a limit3")
+
+
+def test_a_column_with_no_nulls_reads_none(p2, expect):
+ expect.text(p2["no nulls t.b"], "0", "no nulls t.b")
+
+
+# One test per codec per check, each under the shell's name: the shell's loop names
+# them "$codec count" and so on, so the twin's names are the same template.
+@pytest.mark.parametrize("codec", [c for c, _ in CODECS])
+def test_a_codec_round_trips_every_row(p2, expect, codec):
+ expect.text(p2[f"{codec} count"], "20000", f"{codec} count")
+
+
+@pytest.mark.parametrize("codec", [c for c, _ in CODECS])
+def test_a_codec_round_trips_the_ids(p2, expect, codec):
+ expect.text(p2[f"{codec} sum(id)"], "200010000", f"{codec} sum(id)")
+
+
+@pytest.mark.parametrize("codec", [c for c, _ in CODECS])
+def test_a_codec_round_trips_a_payload(p2, expect, codec):
+ expect.text(p2[f"{codec} payload ok"], "t", f"{codec} payload ok")
+
+
+@pytest.mark.parametrize("codec", [c for c, _ in CODECS])
+def test_a_codec_round_trips_one_distinct_payload(p2, expect, codec):
+ expect.text(p2[f"{codec} distinct pl"], "1", f"{codec} distinct pl")
+
+
+@pytest.mark.parametrize("codec, code", CODECS)
+def test_the_codec_asked_for_is_the_block_codec_stored(p2, expect, codec, code):
+ expect.text(p2[f"{codec} block codec"], str(code), f"{codec} block codec")
+
+def test_a_value_too_small_to_compress_round_trips(p2, expect):
+ expect.text(p2["fallback value"], "42", "fallback value")
+
+
+def test_a_value_too_small_to_compress_is_stored_with_codec_0(p2, expect):
+ expect.text(p2["fallback codec=0"], "0", "fallback codec=0")
+
+
+def test_nulls_round_trip_under_lz4(p2, expect):
+ expect.text(p2["null rows"], "15000", "null rows")
+
+
+def test_every_third_value_reads_null(p2, expect):
+ expect.text(p2["null count"], "5000", "null count")
+
+
+def test_a_null_reads_null(p2, expect):
+ expect.text(p2["null value"], "t", "null value")
+
+
+def test_a_value_beside_nulls_reads_its_value(p2, expect):
+ expect.text(p2["nonnull val"], "t", "nonnull val")
+
+
+def test_projecting_a_reads_a(p2, expect):
+ expect.text(p2["project a"], "100", "project a")
+
+
+def test_projecting_b_reads_b(p2, expect):
+ expect.text(p2["project b"], "row100", "project b")
+
+
+def test_projecting_c_reads_c(p2, expect):
+ expect.text(p2["project c"], "200", "project c")
+
+
+def test_a_sum_over_one_projected_column(p2, expect):
+ expect.text(p2["project sum"], "900030000", "project sum")
+
+
+def test_projecting_two_columns_in_order(p2, expect):
+ expect.text(p2["project b,c"], "row1|2,row2|4,", "project b,c")
+
+
+def test_three_vectors_have_zone_maps(p2, expect):
+ expect.text(p2["vectors"], "3", "vectors")
+
+
+def test_every_zone_map_has_a_minimum_and_maximum(p2, expect):
+ expect.text(p2["minmax present"], "3", "minmax present")
+
+
+def test_vector_0_minimum_is_1(p2, expect):
+ expect.text(p2["v0 min=1"], "1", "v0 min=1")
+
+
+def test_vector_0_maximum_is_10000(p2, expect):
+ expect.text(p2["v0 max=10000"], "10000", "v0 max=10000")
+
+
+def test_vector_2_maximum_is_25000(p2, expect):
+ expect.text(p2["v2 max=25000"], "25000", "v2 max=25000")
+
+
+def test_a_between_filter_returns_its_rows(p2, expect):
+ expect.text(p2["between count"], "11", "between count")
+
+
+def test_a_range_filter_returns_its_bounds(p2, expect):
+ expect.text(p2["range min/max"], "24991|25000,", "range min/max")
+
+
+def test_a_point_lookup_returns_one_row(p2, expect):
+ expect.text(p2["point lookup"], "1", "point lookup")
+
+
+def test_a_range_past_the_data_returns_none(p2, expect):
+ expect.text(p2["empty range"], "0", "empty range")
+
+
+def test_a_range_inside_the_last_vector_decodes_one_of_three(p2, expect):
+ expect.text(p2["range decodes 1 of 3 vectors"], "1", "range decodes 1 of 3 vectors")
+
+
+def test_no_row_group_outlives_its_table(p2, expect):
+ expect.text(p2["orphan row groups"], "0", "orphan row groups")
+
+
+def test_no_column_chunk_outlives_its_table(p2, expect):
+ expect.text(p2["orphan chunks"], "0", "orphan chunks")
diff --git a/test/pytest/test_phase3.py b/test/pytest/test_phase3.py
new file mode 100644
index 000000000..8e640cb29
--- /dev/null
+++ b/test/pytest/test_phase3.py
@@ -0,0 +1,357 @@
+"""Read-your-writes, DELETE, UPDATE, ROLLBACK, savepoints and temporary tables on a
+columnar table, plus the phase 1/2 regression checks: the pytest twin of
+`test/phase3.sh`, every check by the shell's name (#1341).
+
+A savepoint's own discard is reached only by a statement that FAILS PARTWAY inside it:
+executor end flushes a completed statement's buffers under its subtransaction, and the
+abort's xmin verdict hides those (#1441). So an INSERT whose 2001st row divides by
+zero, and a DELETE that fails at a = 9, are each rolled back to a savepoint, and both
+must leave nothing behind, inside the transaction and after it. With the discard of
+pending writes or pending deletes removed, every other check stayed green. A DELETE
+whose RETURNING reads the table in a block that commits into one that aborts must see
+the rows deleted so far, as heap does: with the commit not handing the read's written
+marks to its parent, the abort left them attributed to a finished block and no later
+read wrote them again. The
+deleted range must straddle a vector boundary, read from the zone maps, as the shell
+says it does.
+
+ON A PRIVATE DATABASE, built once by a module fixture in the shell's order, because the
+delete_vector and row_group checks read the database's whole catalog. Statements the
+shell runs in one psql session run on one connection here. A reading is a value or a
+query_error(), never text that stands in for one.
+"""
+import psycopg
+import pytest
+
+from pgc_vacuity import query_error
+
+DB = "pgc_phase3"
+DECX = ("((get_byte(maximum,3)::bigint<<24)|(get_byte(maximum,2)<<16)"
+ "|(get_byte(maximum,1)<<8)|get_byte(maximum,0))")
+
+
+def _cell(v):
+ if v is None:
+ return ""
+ if isinstance(v, bool):
+ return "t" if v else "f"
+ return str(v)
+
+
+def _seq(dsn, *steps):
+ """Run `steps` on one session -> each row-returning statement's rows as psql -At
+ prints them. A step (sql, sqlstate) may fail with exactly that SQLSTATE. Any other
+ failure ends the session, and it and every reading after it are query_error()s."""
+ out = []
+ try:
+ with psycopg.connect(dsn, autocommit=True) as conn:
+ for st in steps:
+ if isinstance(st, tuple):
+ sql, state = st
+ try:
+ conn.execute(sql)
+ except psycopg.Error as exc:
+ if exc.sqlstate != state:
+ raise
+ continue
+ cur = conn.execute(st)
+ if cur.description is not None:
+ out.append("\n".join("|".join(_cell(v) for v in r) for r in cur.fetchall()))
+ except psycopg.Error as exc:
+ out.append(query_error(str(exc).splitlines()[0] if str(exc) else type(exc).__name__))
+ return out
+
+
+def _at(out, i):
+ return out[i] if i < len(out) else query_error(f"no reading {i}: the session ended early")
+
+
+def _q(dsn, *steps):
+ return _at(_seq(dsn, *steps), 0)
+
+
+def _qq(dsn, sql):
+ """-> every row followed by a comma, as the shell's qq prints them."""
+ v = _q(dsn, sql)
+ return v if v.startswith("QUERY_ERROR") else "".join(l + "," for l in v.split("\n") if v)
+
+
+@pytest.fixture(scope="module")
+def p3(pgc_cluster):
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+ a.execute(f"CREATE DATABASE {DB}")
+ try:
+ dsn = pgc_cluster.dsn(dbname=DB)
+ q = lambda *s: _q(dsn, *s)
+ q("CREATE EXTENSION pgcolumnar")
+ r = {}
+
+ q("CREATE TABLE reg (a int, b text) USING pgcolumnar")
+ q("INSERT INTO reg SELECT g, g::text FROM generate_series(1,50000) g")
+ r["reg count"] = q("SELECT count(*) FROM reg")
+ r["reg filter a<100"] = q("SELECT count(*) FROM reg WHERE a < 100")
+ q("DROP TABLE reg")
+
+ r["ryw count"] = q("BEGIN", "CREATE TABLE ryw (a int) USING pgcolumnar",
+ "INSERT INTO ryw SELECT g FROM generate_series(1,1000) g",
+ "SELECT count(*) FROM ryw", "COMMIT")
+ r["ryw sum"] = q("BEGIN", "INSERT INTO ryw VALUES (100000)", "SELECT sum(a) FROM ryw", "COMMIT")
+ r["ryw persisted"] = q("SELECT count(*) FROM ryw")
+ q("DROP TABLE ryw")
+
+ q("CREATE TABLE d (id int, v text) USING pgcolumnar")
+ q("INSERT INTO d SELECT g, 'r'||g FROM generate_series(1,25000) g")
+ r["the deleted range straddles vectors 0 and 1"] = q(
+ f"SELECT string_agg({DECX}::text, chr(44) ORDER BY vector_index) FROM pgcolumnar.zone_map "
+ "WHERE storage_id = pgcolumnar.get_storage_id('d') AND column_index = 0 AND vector_index >= 0")
+ q("DELETE FROM d WHERE id BETWEEN 9995 AND 10005")
+ r["delete count"] = q("SELECT count(*) FROM d")
+ r["delete gone"] = q("SELECT count(*) FROM d WHERE id BETWEEN 9995 AND 10005")
+ r["delete neighbors"] = _qq(dsn, "SELECT id FROM d WHERE id IN (9994,10006) ORDER BY id")
+ r["delete boundaries"] = q("SELECT min(id)||chr(47)||max(id) FROM d")
+ q("DELETE FROM d WHERE id > 20000")
+ r["delete tail count"] = q("SELECT count(*) FROM d")
+ r["delete tail max"] = q("SELECT max(id) FROM d")
+ r["delete_vector rows>0"] = q("SELECT (count(*) > 0)::int FROM pgcolumnar.delete_vector")
+ r["delete_vector deleted"] = q("SELECT sum(deleted_count) FROM pgcolumnar.delete_vector")
+ q("DROP TABLE d")
+
+ q("CREATE TABLE u (id int, v text) USING pgcolumnar")
+ q("INSERT INTO u SELECT g, 'r'||g FROM generate_series(1,5000) g")
+ q("UPDATE u SET v='changed' WHERE id = 2500")
+ r["update value"] = q("SELECT v FROM u WHERE id = 2500")
+ r["update one row"] = q("SELECT count(*) FROM u WHERE v = 'changed'")
+ r["update count"] = q("SELECT count(*) FROM u")
+ q("UPDATE u SET id = id + 100000 WHERE id <= 10")
+ r["update key gone"] = q("SELECT count(*) FROM u WHERE id <= 10")
+ r["update key new"] = q("SELECT count(*) FROM u WHERE id > 100000")
+ r["update key total"] = q("SELECT count(*) FROM u")
+ q("UPDATE u SET v = v || 'ZZ'")
+ r["bulk update rows"] = q("SELECT count(*) FROM u")
+ r["bulk update sfx"] = q("SELECT count(*) FROM u WHERE right(v,2) = 'ZZ'")
+ q("DROP TABLE u")
+
+ q("CREATE TABLE rb (a int) USING pgcolumnar")
+ q("INSERT INTO rb SELECT g FROM generate_series(1,100) g")
+ r["rb before"] = q("SELECT count(*) FROM rb")
+ _seq(dsn, "BEGIN", "INSERT INTO rb SELECT g FROM generate_series(101,200) g",
+ "DELETE FROM rb WHERE a <= 50", "ROLLBACK")
+ r["rb after count"] = q("SELECT count(*) FROM rb")
+ r["rb after range"] = q("SELECT min(a)||chr(47)||max(a) FROM rb")
+ q("DROP TABLE rb")
+
+ q("CREATE TABLE sp (a int) USING pgcolumnar")
+ q("INSERT INTO sp SELECT g FROM generate_series(1,10) g")
+ r["savepoint in-txn"] = q("BEGIN", "INSERT INTO sp VALUES (11)", "DELETE FROM sp WHERE a = 1",
+ "SAVEPOINT s1", "INSERT INTO sp VALUES (12)", "DELETE FROM sp WHERE a = 2",
+ "ROLLBACK TO s1", "SELECT string_agg(a::text, ',' ORDER BY a) FROM sp",
+ "COMMIT")
+ r["savepoint final"] = _qq(dsn, "SELECT a FROM sp ORDER BY a")
+ out = _seq(dsn, "BEGIN", "SAVEPOINT s2",
+ ("INSERT INTO sp SELECT CASE WHEN g < 2100 THEN g ELSE 1 / (g - g) END "
+ "FROM generate_series(100, 3000) g", "22012"),
+ "ROLLBACK TO s2", "SELECT count(*) FROM sp WHERE a >= 100", "SAVEPOINT s3",
+ ("DELETE FROM sp WHERE a < 11 AND 1 / (a - 9) IS NOT NULL", "22012"),
+ "ROLLBACK TO s3", "SELECT string_agg(a::text, chr(44) ORDER BY a) FROM sp", "COMMIT")
+ r["savepoint failed insert discarded"] = _at(out, 0)
+ r["savepoint failed delete discarded"] = _at(out, 1)
+ r["savepoint failed final"] = _qq(dsn, "SELECT a FROM sp ORDER BY a")
+ q("DROP TABLE sp")
+
+ q("""CREATE FUNCTION spn_rd(t text) RETURNS int LANGUAGE plpgsql AS $$
+DECLARE n int;
+BEGIN
+ BEGIN
+ BEGIN
+ EXECUTE format('SELECT count(*) FROM %s', t) INTO n;
+ EXCEPTION WHEN no_data_found THEN NULL;
+ END;
+ PERFORM 1 / 0;
+ EXCEPTION WHEN division_by_zero THEN NULL;
+ END;
+ RETURN n;
+END $$""")
+
+ def spn(am):
+ out = _seq(dsn, f"CREATE TABLE spn_{am} (id int) USING {am}",
+ f"INSERT INTO spn_{am} SELECT g FROM generate_series(1, 200) g",
+ f"WITH d AS (DELETE FROM spn_{am} WHERE id % 2 = 0 RETURNING spn_rd('spn_{am}') AS n) "
+ "SELECT sum(n) FROM d",
+ f"SELECT count(*) || chr(124) || sum(id) FROM spn_{am}")
+ return next((v for v in out if v.startswith("QUERY_ERROR")), None) or "|".join(out)
+
+ r["savepoint nested read: pgcolumnar"] = spn("pgcolumnar")
+ r["savepoint nested read: heap"] = spn("heap")
+ q("DROP TABLE spn_pgcolumnar, spn_heap", "DROP FUNCTION spn_rd(text)")
+
+ r["temp survivors"] = q("CREATE TEMP TABLE t_tmp (a int, b text) USING pgcolumnar",
+ "INSERT INTO t_tmp SELECT g, g::text FROM generate_series(1,2000) g",
+ "DELETE FROM t_tmp WHERE a % 5 = 0", "UPDATE t_tmp SET b = 'u' WHERE a = 3",
+ "SELECT count(*) FROM t_tmp")
+ r["temp deleted"] = q("CREATE TEMP TABLE t_tmp2 (a int) USING pgcolumnar",
+ "INSERT INTO t_tmp2 SELECT g FROM generate_series(1,2000) g",
+ "DELETE FROM t_tmp2 WHERE a % 5 = 0",
+ "SELECT count(*) FROM t_tmp2 WHERE a % 5 = 0")
+
+ q("CREATE TABLE dur (a int) USING pgcolumnar")
+ q("INSERT INTO dur SELECT g FROM generate_series(1,1000) g")
+ q("DELETE FROM dur WHERE a % 2 = 0")
+ r["durable survivors"] = q("SELECT count(*) FROM dur")
+ r["durable odd only"] = q("SELECT count(*) FROM dur WHERE a % 2 = 0")
+ q("DROP TABLE dur")
+ r["delete_vector cleaned"] = q("SELECT count(*) FROM pgcolumnar.delete_vector")
+ r["row group cleaned"] = q("SELECT count(*) FROM pgcolumnar.row_group")
+ yield r
+ finally:
+ with psycopg.connect(pgc_cluster.dsn(), autocommit=True) as a:
+ a.execute(f"DROP DATABASE IF EXISTS {DB} WITH (FORCE)")
+
+
+def test_the_regression_table_counts_every_row(p3, expect):
+ expect.text(p3["reg count"], "50000", "reg count")
+
+
+def test_the_regression_table_filters(p3, expect):
+ expect.text(p3["reg filter a<100"], "99", "reg filter a<100")
+
+
+def test_a_table_created_in_the_transaction_reads_its_rows(p3, expect):
+ expect.text(p3["ryw count"], "1000", "ryw count")
+
+
+def test_an_insert_is_read_by_its_own_transaction(p3, expect):
+ expect.text(p3["ryw sum"], "600500", "ryw sum")
+
+
+def test_the_transaction_s_rows_persist(p3, expect):
+ expect.text(p3["ryw persisted"], "1001", "ryw persisted")
+
+
+def test_the_deleted_range_straddles_a_vector_boundary(p3, expect):
+ expect.text(p3["the deleted range straddles vectors 0 and 1"], "10000,20000,25000", "the deleted range straddles vectors 0 and 1")
+
+
+def test_a_delete_leaves_the_survivors(p3, expect):
+ expect.text(p3["delete count"], "24989", "delete count")
+
+
+def test_deleted_rows_are_gone(p3, expect):
+ expect.text(p3["delete gone"], "0", "delete gone")
+
+
+def test_the_deleted_range_s_neighbours_survive(p3, expect):
+ expect.text(p3["delete neighbors"], "9994,10006,", "delete neighbors")
+
+
+def test_the_table_s_bounds_survive(p3, expect):
+ expect.text(p3["delete boundaries"], "1/25000", "delete boundaries")
+
+
+def test_a_tail_delete_leaves_the_survivors(p3, expect):
+ expect.text(p3["delete tail count"], "19989", "delete tail count")
+
+
+def test_a_tail_delete_lowers_the_maximum(p3, expect):
+ expect.text(p3["delete tail max"], "20000", "delete tail max")
+
+
+def test_the_delete_vector_catalog_has_rows(p3, expect):
+ expect.text(p3["delete_vector rows>0"], "1", "delete_vector rows>0")
+
+
+def test_the_delete_vector_catalog_counts_every_deletion(p3, expect):
+ expect.text(p3["delete_vector deleted"], "5011", "delete_vector deleted")
+
+
+def test_an_update_shows_the_new_value(p3, expect):
+ expect.text(p3["update value"], "changed", "update value")
+
+
+def test_an_update_changes_one_row(p3, expect):
+ expect.text(p3["update one row"], "1", "update one row")
+
+
+def test_an_update_keeps_the_row_count(p3, expect):
+ expect.text(p3["update count"], "5000", "update count")
+
+
+def test_a_key_update_removes_the_old_keys(p3, expect):
+ expect.text(p3["update key gone"], "0", "update key gone")
+
+
+def test_a_key_update_writes_the_new_keys(p3, expect):
+ expect.text(p3["update key new"], "10", "update key new")
+
+
+def test_a_key_update_keeps_the_row_count(p3, expect):
+ expect.text(p3["update key total"], "5000", "update key total")
+
+
+def test_a_bulk_update_keeps_the_row_count(p3, expect):
+ expect.text(p3["bulk update rows"], "5000", "bulk update rows")
+
+
+def test_a_bulk_update_rewrites_every_row(p3, expect):
+ expect.text(p3["bulk update sfx"], "5000", "bulk update sfx")
+
+
+def test_the_rollback_table_starts_with_its_rows(p3, expect):
+ expect.text(p3["rb before"], "100", "rb before")
+
+
+def test_a_rolled_back_transaction_leaves_the_count(p3, expect):
+ expect.text(p3["rb after count"], "100", "rb after count")
+
+
+def test_a_rolled_back_transaction_leaves_the_range(p3, expect):
+ expect.text(p3["rb after range"], "1/100", "rb after range")
+
+
+def test_rollback_to_a_savepoint_keeps_the_work_before_it(p3, expect):
+ expect.text(p3["savepoint in-txn"], "2,3,4,5,6,7,8,9,10,11", "savepoint in-txn")
+
+
+def test_rollback_to_a_savepoint_holds_after_commit(p3, expect):
+ expect.text(p3["savepoint final"], "2,3,4,5,6,7,8,9,10,11,", "savepoint final")
+
+
+def test_an_insert_that_failed_inside_a_savepoint_leaves_nothing(p3, expect):
+ expect.text(p3["savepoint failed insert discarded"], "0", "savepoint failed insert discarded")
+
+
+def test_a_delete_that_failed_inside_a_savepoint_deletes_nothing(p3, expect):
+ expect.text(p3["savepoint failed delete discarded"], "2,3,4,5,6,7,8,9,10,11", "savepoint failed delete discarded")
+
+
+def test_the_failed_statements_leave_nothing_after_commit(p3, expect):
+ expect.text(p3["savepoint failed final"], "2,3,4,5,6,7,8,9,10,11,", "savepoint failed final")
+
+
+def test_a_read_in_a_block_that_commits_into_an_aborted_one_sees_the_deletes(p3, expect):
+ expect.text(p3["savepoint nested read: pgcolumnar"], p3["savepoint nested read: heap"],
+ "savepoint nested read in a delete sees its deleted rows, as on heap")
+
+
+def test_a_temporary_table_keeps_its_survivors(p3, expect):
+ expect.text(p3["temp survivors"], "1600", "temp survivors")
+
+
+def test_a_temporary_table_s_deleted_rows_are_gone(p3, expect):
+ expect.text(p3["temp deleted"], "0", "temp deleted")
+
+
+def test_deletes_survive_their_transaction(p3, expect):
+ expect.text(p3["durable survivors"], "500", "durable survivors")
+
+
+def test_only_the_undeleted_rows_survive(p3, expect):
+ expect.text(p3["durable odd only"], "0", "durable odd only")
+
+
+def test_drop_removes_the_delete_vector_rows(p3, expect):
+ expect.text(p3["delete_vector cleaned"], "0", "delete_vector cleaned")
+
+
+def test_drop_removes_the_row_group_rows(p3, expect):
+ expect.text(p3["row group cleaned"], "0", "row group cleaned")
diff --git a/test/pytest/unported_suites.txt b/test/pytest/unported_suites.txt
index bec10813b..58b9eaf70 100644
--- a/test/pytest/unported_suites.txt
+++ b/test/pytest/unported_suites.txt
@@ -11,42 +11,7 @@ decode_interrupts
decode_skip_interrupts
harness_selftest
native_cancel
-native_fetch_interrupt
-native_fold_deferral
-native_late_materialization
-native_lazy_slot
-native_metadata_flush
-native_page_offset_bound
-native_param_pushdown
-native_parquet_codecs
-native_parquet_fdw
-native_parquet_fieldid
-native_parquet_hardening
-native_parquet_partition
-native_parquet_projection
-native_parquet_stack
-native_parquet_streaming
-native_parquet_units
-native_read_parquet
-native_reclaim_reconcile
-native_rewrite
-native_rewrite_conc
-native_sort_by
-native_vecdecode
-native_zonemap
-native_zonemap_narrow
-native_zonemap_session
-objstore_addressing
-objstore_http_read
-objstore_listing
-objstore_module
-objstore_s3_read
-objstore_sink_write
-objstore_stash_recovery
-objstore_tls_read
parquet_export
-phase2
-phase3
phase4
phase6
planner_choice_quality