From d392b80ff954fbad09436a7d2bfca4fab7e27ae4 Mon Sep 17 00:00:00 2001 From: "Joshua (D) Drake" <136637981+ChronicallyJD@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:07:33 -0600 Subject: [PATCH] test/pytest: port the rest of the differential suite (#432) All seven parts of test/differential.sh now have a pytest twin. 85 tests, 232 checks, 17 seconds. Parts 1 and 2 were #1020 and the first commits here; this adds boundary conditions, lightweight encodings, aggregates with deletes, bloom equality skipping, wide projections and the covering count. A FOURTH UNFALSIFIABLE BASH ARM, and the subtlest of the four. `textbloom collate-mismatch` probes `tk = 'k100'`, and tk is 'k' || ((g*2654435761)%50000) over 16,000 rows of a 50,000-wide domain -- 32% coverage, and measured, k100 matches 0 rows. The arm exists to catch a bloom wrongly pushed under a mismatched collation, and a wrongly-pushed bloom skips the chunks holding the match and returns 0. So the one defect it is built to detect produces exactly the answer it expects. Found by a premise arm I added asking whether the probe value exists. The port probes a value derived from the data, asserts it is present exactly once, and adds the direction the arm must fail in -- that the mismatched collation RETURNS the row rather than skipping it. FIXTURE PREMISES ARE ASSERTED WHERE THEY DECIDE WHAT IS UNDER TEST: * the bloom fixture's key spread, at least 90,000 of the domain per chunk. Ordered keys would make every bloom arm pass on zone maps alone and say nothing about blooms, and no arm would report that. * the dictionary fixture's cardinalities, 4 and 6 against an md5 per row, so the per-column verdict is visible rather than assumed. * the delete in part 4 removed exactly 400 rows. A DELETE matching nothing leaves the per-group fallback untested while every arm stays green. * the update in part 7 leaves a count neither the delete nor the update alone would give, which is the arithmetic the metadata path has to get right. Float aggregates use min/max rather than sum, because a float sum has no single right answer -- part 1 measures three from heap alone by row order. SET on the connection rather than ALTER DATABASE in part 7: that form exists because each psql invocation in the bash suite is a new session, and this is not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a --- test/pytest/TESTS.md | 136 +++++++- test/pytest/expected_tests.txt | 5 +- test/pytest/test_differential.py | 557 ++++++++++++++++++++++++++++++- 3 files changed, 690 insertions(+), 8 deletions(-) diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index b708cf62..3e15d33d 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -75,8 +75,8 @@ behaviour, the source of that number is named. - [27. test_skip_loop_arms.py: a skipped arm records under its own name](#27-test_skip_loop_armspy-a-skipped-arm-records-under-its-own-name) - [28. test_docs_join_clustering.py: the runtime filter's layout precondition](#28-test_docs_join_clusteringpy-the-runtime-filters-layout-precondition) - [29. test_join_vector_agg.py: ungrouped fold over a unique-key join](#29-test_join_vector_aggpy-ungrouped-fold-over-a-unique-key-join) -- [30. test_differential.py: the heap oracle, type matrix](#30-test_differentialpy-the-heap-oracle-type-matrix) - [31. test_native_ownership.py: every maintenance function is owner-only](#31-test_native_ownershippy-every-maintenance-function-is-owner-only) +- [30. test_differential.py: the heap oracle, all seven parts](#30-test_differentialpy-the-heap-oracle-all-seven-parts) ## 1. How to read a test in here @@ -2840,17 +2840,20 @@ join. A non-equi join clause is the same kind of extra Join Filter. EXPLAIN has no vectorized agg node. The sum matches a heap twin. -## 30. test_differential.py: the heap oracle, type matrix +## 30. test_differential.py: the heap oracle, all seven parts The governing property of `test/differential.sh`, and the reason it is the largest suite in the tree: load the same data into a heap table and a columnar one, and every query must answer identically. Heap is the oracle, so this catches encode/decode, null-handling and chunk-skipping bugs **generically** rather than one at a time. -This is **part 1** of that port -- the type matrix. Twenty columns covering every type the -suite exercises, 12,000 rows, a **different null modulus per column** so no two columns -share a null pattern, small chunk-group and stripe limits so there is something to skip. The -boundary, encoding, bloom and aggregate parts are separate slices. +This is the **whole suite** ported -- all seven parts, in the order the bash suite runs them: +the type matrix, the boundary conditions, the lightweight encodings, aggregates over nulls and +deletes, bloom equality skipping, a wide projection, and the covering `count(*)`. + +Part 1, the type matrix, is twenty columns covering every type the suite exercises, 12,000 +rows, a **different null modulus per column** so no two columns share a null pattern, and +small chunk-group and stripe limits so there is something to skip. Names are the bash suite's character for character, which is what lets `compare_to_bash.py` diff the two harnesses by property. A port that renames a check asserts the same thing and @@ -2992,3 +2995,124 @@ pytest as an f-string -- so the tool reports `PORT IS INCOMPLETE` for a port tha complete. Measured across the corpus: **81 of 253 suites** carry at least one interpolated check name, 252 of 4345 names overall. The verdict is a false red for a third of the suites, which bounds how much of #432's parity the tool can certify. +## Part 2: boundary conditions + +Part 1 asks whether the two access methods agree about DATA. Part 2 asks whether they agree +at the SIZES where the format's structure changes. Each fixture is built per test rather than +shared, because a different geometry each time is the whole point -- a module fixture would +have to pick one. + +### `test_an_empty_table_agrees_and_the_agreement_is_not_vacuous` + +`empty scan` compares two empty results, which `pgc_set_hash` renders as `EMPTY` on both +sides. That is not nothing -- a scan that invented a row would break it -- but the vacuity +layer refuses it by default, so the reason is stated and a **positive control** is added: the +same query returns a row once one exists. `empty count` and `empty agg` are not vacuous, +because 0 and a row of NULLs are values. + +### `test_a_single_row_agrees` + +One row is the smallest geometry that stores anything: a stripe, a chunk group and a value +stream all of length one. A format that assumes a full vector anywhere breaks here. + +### `test_the_chunk_group_boundary_is_exact_and_the_data_survives_it` + +N-1, N, N+1 around a 100-row limit, for two limits. The GROUP COUNT is asserted as well as +the data, because the data can agree while the geometry is wrong: a writer that never closes +a group produces one group and the right rows, and only the count says so. + +### `test_the_stripe_boundary_is_exact_and_the_data_survives_it` + +The same question one level up, at 1000 -- the floor `set_options` enforces, so the smallest +legal stripe and the most boundaries per row. It is also below one 1024-value vector, which +#1017 measures as a compression cliff; that is a SIZE question and this is a CORRECTNESS one. + +### `test_a_column_that_is_entirely_null_agrees` + +A column with no values has no zone-map minimum or maximum, and a scan treating a missing +range as "matches nothing" loses every row of the TABLE rather than of the column. `minmax` +is the arm that sees it. + +### `test_a_whole_chunk_group_that_is_null_agrees` + +The case a column-wide NULL cannot reach: skip decisions are per group, so a null group +between two non-null ones is where a wrong "cannot match" prunes live rows. The range arm +straddles the boundary deliberately. + +### `test_the_empty_string_stays_distinct_from_null` + +A varlena column stores `''` as a zero-length value and NULL as a bit, so a decoder that +loses the bitmap returns `''` where NULL was. Both counts are asserted, not just the total, +because they move in opposite directions. + +### `test_a_wide_row_of_sixty_one_columns_agrees` + +Sixty-one columns, where a per-column offset error shows and a narrow table hides it. Its +premise arm counts columns **via `regclass`**, not `information_schema.columns` by name: the +unqualified form counts every table called `t_col` in every schema, including the module +fixture's, and reported 81. + +## Parts 3 to 7 + +### `test_the_integer_encodings_round_trip` + +Four shapes in one table, each the input a different encoding is chosen for: constant deltas +for delta-of-delta, four distinct values for a dictionary, one value for a constant column, and +a hash-spread bigint for none of them. One table rather than four, because the verdict is per +column and a writer applying one column's to another would pass a single-shape table. +`compression => 'none'` so the codec cannot compress the damage away. + +### `test_the_float_and_timestamp_encodings_round_trip` + +Gorilla on a random walk and delta-of-delta on a fixed interval. **This fixture is why `_pair` +generates once and copies**: measured on its own generator, 2000 rows, regenerated gives 2000 +rows differing and copied gives 0. min/max rather than sum for the floats, because a float sum +has no single right answer -- part 1 measures three from heap alone by row order. + +### `test_the_dictionary_encoding_round_trips_including_varlena` + +Four values, six values, and an md5 per row in one table, so the per-column verdict is visible. +`GROUP BY` is the arm a whole-row comparison cannot replace: it reads the column through the +grouping path rather than the projection path. + +### `test_an_uncompressed_table_still_round_trips` + +Encoding is independent of the codec. Without this arm every encoding above is only read back +through a codec, and a bug the codec happens to mask would never show. + +### `test_aggregates_agree_with_nulls_and_deletes_present` + +A row group carrying a delete cannot be answered from the value stream, so the scan falls back +per group -- and a fallback that double-counts shows in `count(*)` while every other arm stays +green. The delete is asserted to have removed exactly 400 rows, because a DELETE that matched +nothing would leave the fast path untested. + +### `test_bloom_equality_agrees_on_numeric_and_uuid_keys` + +Hash-spread keys, so min/max cannot prune and only a bloom can. **The spread is asserted**, not +assumed: each chunk's key range must span at least 90,000 of the domain, because a fixture that +quietly became ordered would make every arm here pass on zone maps alone and say nothing about +blooms. The absent-value probe is derived from the data rather than guessed. + +### `test_text_bloom_equality_agrees_including_a_mismatched_collation` + +**The bash arm probes a value that is not there, and it is the subtlest unfalsifiable arm this +port found.** `tk` is `'k' || ((g*2654435761)%50000)` over 16,000 rows of a 50,000-wide domain, +so 32% of values appear and `tk = 'k100'` matches 0 rows -- measured. A bloom wrongly pushed +under a mismatched collation would skip the chunks holding the match and also return 0, so the +one defect the arm exists to detect produces the answer it expects. The port probes a present +value and adds the direction it must fail in. + +### `test_a_selective_filter_with_a_wide_projection_agrees` + +Four selectivities from one row to most. `wide nomatch` is the second arm in the file that +cannot fail alone, and gets the same treatment as `empty scan`: a stated reason and a positive +control at a value that is present. + +### `test_a_covering_count_agrees_with_the_path_on_and_off` + +An UPDATE appends the new row and masks the old, so the stored per-group count and the visible +count diverge -- which is the arithmetic the metadata path has to get right and a plain scan +gets right for free. Run both ways through `enable_vectorization`, which is what distinguishes +"the fast path is correct" from "the fast path was not taken". `SET` on the connection rather +than the bash suite's `ALTER DATABASE`, which exists because each psql there is a new session. diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index ba9d1148..8b5116f2 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -52,4 +52,7 @@ guard_tests 277 # which is the argument for gating it rather than a detail about it. # 166 -> 177 when test_native_ownership.py landed: nine parametrized refusal arms, # the owner control, and the check-ordering arm. -cluster_tests 177 +cluster_tests 202 +# The rate is the point. The ungated half grew by 67 tests in the time #1016 took to review, +# which was the argument for gating it. It is gated now, and 166 -> 182 is the first move made +# with the gate actually watching: this number and the tests land in one commit. diff --git a/test/pytest/test_differential.py b/test/pytest/test_differential.py index 09c5ea8f..bf81d95a 100644 --- a/test/pytest/test_differential.py +++ b/test/pytest/test_differential.py @@ -156,8 +156,16 @@ def matrix(pgc_cluster): # skipping paths are only exercised when there is something to skip. conn.execute("SELECT pgcolumnar.set_options('t_col', " "chunk_group_row_limit => 1000, stripe_row_limit => 5000)") + # GENERATED ONCE INTO HEAP, THEN COPIED, exactly as lib.sh's load_pair does and + # for the reason its comment gives: "both hold byte-identical logical contents + # regardless of any volatile generators". Running the generator twice produces two + # DIFFERENT tables the moment anything in it is volatile, and then the oracle is + # comparing two fixtures rather than two access methods. + # + # Every load in parts 1 and 2 happens to be deterministic, so this was right by + # luck rather than by construction until part 3 needed random(). conn.execute(f"INSERT INTO t_heap {MATRIX_LOAD}") - conn.execute(f"INSERT INTO t_col {MATRIX_LOAD}") + conn.execute("INSERT INTO t_col SELECT * FROM t_heap") yield _Pair(conn) finally: try: @@ -353,3 +361,550 @@ def test_a_compound_predicate_over_several_columns_agrees(matrix, expect): c, h = matrix.both("SELECT id FROM %T WHERE c_int > 0 AND c_bool " "AND c_vc IS NOT NULL AND c_num < 8000") expect.row_set(c, h, "compound") + + +# --------------------------------------------------------------------------- +# Part 2: boundary conditions +# +# Part 1 asks whether the two access methods agree about DATA. This part asks whether +# they agree at the SIZES where the format's structure changes: the row counts that +# land exactly on a chunk-group or stripe limit, a table with no rows, a table with +# one, a column that is entirely NULL, a whole chunk group that is, and the empty +# string against NULL. +# +# Each fixture is built per test rather than shared, because the whole point is a +# DIFFERENT geometry each time -- the options are the subject, so a module fixture +# would have to pick one and the rest would go untested. +# --------------------------------------------------------------------------- + + +def _pair(conn, defs, load=None, options=None): + """Build a heap/columnar pair with the given shape, and return a _Pair over it. + + `load` is a SELECT without INSERT, exactly as `load_pair` takes it in the bash + suite. It is generated ONCE into heap and then copied, which is not a detail: a + volatile generator run twice fills the two tables differently and the oracle then + compares two fixtures instead of two access methods. + """ + conn.execute("DROP TABLE IF EXISTS t_heap") + conn.execute("DROP TABLE IF EXISTS t_col") + conn.execute(f"CREATE TABLE t_heap ({defs})") + conn.execute(f"CREATE TABLE t_col ({defs}) USING pgcolumnar") + if options: + conn.execute(f"SELECT pgcolumnar.set_options('t_col', {options})") + if load: + # ONE generation, then a copy -- see the module fixture above. + conn.execute(f"INSERT INTO t_heap {load}") + conn.execute("INSERT INTO t_col SELECT * FROM t_heap") + return _Pair(conn) + + +def _groups(pair): + """Chunk groups, by lib.sh's own definition in chunk_group_count.""" + return pair.one("SELECT count(*) FROM pgcolumnar.zone_map " + "WHERE storage_id = pgcolumnar.get_storage_id('t_col') " + "AND vector_index >= 0 AND column_index = 0") + + +def _stripes(pair): + """Row groups, by lib.sh's own definition in stripe_count.""" + return pair.one("SELECT count(*) FROM pgcolumnar.row_group " + "WHERE storage_id = pgcolumnar.get_storage_id('t_col')") + + +def test_an_empty_table_agrees_and_the_agreement_is_not_vacuous(pgc_conn, expect): + """Three arms on a table with no rows, and one of them cannot fail on its own. + + `empty scan` compares two empty results. `pgc_set_hash` renders both `EMPTY`, they + compare equal, and the bash arm passes -- which is not nothing, because a columnar + scan that invented a row would break it, but it is an assertion whose only failing + input is a bug nobody has. The vacuity layer refuses it by default, so the reason + is stated and a POSITIVE CONTROL is added: the same query returns a row once one + exists, which is what proves the comparison can move at all. + + `empty count` and `empty agg` are not vacuous -- 0 and a row of NULLs are values. + """ + p = _pair(pgc_conn, "id int, v text") + c, h = p.both("SELECT * FROM %T") + expect.row_set(c, h, "empty scan", + allow_empty="the table has no rows; the control below is what can fail") + c, h = p.both("SELECT count(*) FROM %T") + expect.row_set(c, h, "empty count") + c, h = p.both("SELECT min(id), max(id), sum(id) FROM %T") + expect.row_set(c, h, "empty agg") + + # THE CONTROL. Without it "both sides empty" is the only thing the first arm has + # ever observed, and an oracle that always returns nothing would satisfy it. + pgc_conn.execute("INSERT INTO t_heap VALUES (1, 'x')") + pgc_conn.execute("INSERT INTO t_col VALUES (1, 'x')") + c, h = p.both("SELECT * FROM %T") + expect.row_set(c, h, "empty scan control: one row is visible to both") + expect.num(len(c), 1, "and the control really did put a row there") + + +def test_a_single_row_agrees(pgc_conn, expect): + """One row is the smallest geometry that stores anything: a stripe, a chunk group, + and a value stream all of length one. A format that assumes a full vector anywhere + breaks here and nowhere else in this file.""" + p = _pair(pgc_conn, "id int, v text", load="SELECT 1, 'only'") + c, h = p.both("SELECT * FROM %T") + expect.row_set(c, h, "single scan") + c, h = p.both("SELECT count(*) FROM %T") + expect.row_set(c, h, "single count") + + +@pytest.mark.parametrize("n", [99, 100, 101, 200, 201, 250]) +def test_the_chunk_group_boundary_is_exact_and_the_data_survives_it(pgc_conn, n, expect): + """N-1, N and N+1 around a 100-row chunk-group limit, for two limits. + + The GROUP COUNT is asserted as well as the data, because the data can agree while + the geometry is wrong: a writer that never closes a group produces one group and + the right rows, and only the count says so. `ceil(N/100)` is the claim, and 99, + 100 and 101 are what distinguish an off-by-one in the close from a correct one -- + exactly the boundary `test-the-exact-boundary-value` is about. + """ + p = _pair(pgc_conn, "id int, v text", + load=f"SELECT g, 'r'||g FROM generate_series(1,{n}) g", + options="chunk_group_row_limit => 100, stripe_row_limit => 100000") + expect.num(_groups(p), (n + 99) // 100, f"cg boundary N={n} groups") + c, h = p.both("SELECT * FROM %T") + expect.row_set(c, h, f"cg boundary N={n} scan") + c, h = p.both(f"SELECT id FROM %T WHERE id BETWEEN 50 AND {n - 10}") + expect.row_set(c, h, f"cg boundary N={n} range") + + +@pytest.mark.parametrize("n", [1000, 1001, 2000, 2001]) +def test_the_stripe_boundary_is_exact_and_the_data_survives_it(pgc_conn, n, expect): + """The same question one level up, at the 1000-row stripe limit the product allows. + + 1000 is the floor `set_options` enforces, so this is the smallest legal stripe and + the most boundaries per row. Note that it is also below one 1024-value vector, + which #1017 measures as a compression cliff -- that is a SIZE question and this is + a CORRECTNESS one, and the oracle here says the rows survive it either way. + """ + p = _pair(pgc_conn, "id int, v text", + load=f"SELECT g, 'r'||g FROM generate_series(1,{n}) g", + options="chunk_group_row_limit => 100, stripe_row_limit => 1000") + expect.num(_stripes(p), (n + 999) // 1000, f"stripe boundary N={n} stripes") + c, h = p.both("SELECT * FROM %T") + expect.row_set(c, h, f"stripe boundary N={n} scan") + c, h = p.both("SELECT count(*), min(id), max(id), sum(id) FROM %T") + expect.row_set(c, h, f"stripe boundary N={n} agg") + + +def test_a_column_that_is_entirely_null_agrees(pgc_conn, expect): + """A column with no values at all, across several chunk groups. + + The zone map for such a column has no minimum and no maximum, and a scan that + treats a missing range as "matches nothing" loses every row of the table rather + than of the column. `minmax` is the arm that sees it: both sides must answer NULL, + NULL, and a columnar side that answered anything else would be reading a range it + does not have. + """ + p = _pair(pgc_conn, "id int, allnull int, v text", + load="SELECT g, NULL::int, 'r'||g FROM generate_series(1,350) g", + options="chunk_group_row_limit => 100") + expect.at_least(_groups(p), 4, "premise: the all-null column spans several chunk groups") + for label, sql in (("allnull column scan", "SELECT * FROM %T"), + ("allnull column count", "SELECT count(allnull) FROM %T"), + ("allnull column isnull", "SELECT count(*) FROM %T WHERE allnull IS NULL"), + ("allnull column minmax", "SELECT min(allnull), max(allnull) FROM %T")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + +def test_a_whole_chunk_group_that_is_null_agrees(pgc_conn, expect): + """Rows 101..200 are NULL and the rest are not, with 100-row groups -- so exactly + one group is entirely NULL and its neighbours are not. + + That is the case a column-wide NULL cannot reach: the skip decision is per group, + so a group with no range sitting between two groups that have one is where a wrong + "cannot match" prunes live rows. The range arm straddles it deliberately: 150..250 + starts inside the null group and ends inside the one after. + """ + p = _pair(pgc_conn, "id int, sometimes int", + load="SELECT g, CASE WHEN g BETWEEN 101 AND 200 THEN NULL ELSE g END " + "FROM generate_series(1,400) g", + options="chunk_group_row_limit => 100") + expect.num(p.one("SELECT count(*) FROM t_col WHERE sometimes IS NULL"), 100, + "premise: exactly one hundred rows are null, so one whole group is") + for label, sql in (("allnull chunk scan", "SELECT * FROM %T"), + ("allnull chunk range", "SELECT id FROM %T WHERE sometimes BETWEEN 150 AND 250"), + ("allnull chunk isnull", "SELECT id FROM %T WHERE sometimes IS NULL")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + +def test_the_empty_string_stays_distinct_from_null(pgc_conn, expect): + """Two different things that a null bitmap can confuse, and a count each. + + A varlena column stores an empty string as a zero-length value and a NULL as a bit, + so a decoder that loses the bitmap returns '' where NULL was -- and both counts move + in opposite directions, which is why both are asserted rather than just the total. + """ + p = _pair(pgc_conn, "id int, s text", + load="SELECT g, CASE WHEN g%2=0 THEN '' WHEN g%3=0 THEN NULL ELSE 'x'||g END " + "FROM generate_series(1,300) g") + expect.at_least(p.one("SELECT count(*) FROM t_col WHERE s = ''"), 1, + "premise: there are empty strings to confuse") + expect.at_least(p.one("SELECT count(*) FROM t_col WHERE s IS NULL"), 1, + "premise: and nulls to confuse them with") + for label, sql in (("empty-vs-null scan", "SELECT * FROM %T"), + ("empty-vs-null empties", "SELECT count(*) FROM %T WHERE s = ''"), + ("empty-vs-null nulls", "SELECT count(*) FROM %T WHERE s IS NULL")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + +def test_a_wide_row_of_sixty_one_columns_agrees(pgc_conn, expect): + """Sixty-one columns, where a per-column offset error shows and a narrow table + hides it: every column after a wrong one reads the previous column's bytes, and a + projection of three scattered columns is what catches it without reading them all.""" + defs = "id int, " + ", ".join(f"c{i} int" for i in range(1, 61)) + sel = "g, " + ", ".join(f"(g*{i} - {i})" for i in range(1, 61)) + p = _pair(pgc_conn, defs, load=f"SELECT {sel} FROM generate_series(1,500) g") + # VIA regclass, not information_schema.columns by name. The unqualified form counts + # every table called t_col in every schema, and the module-scoped matrix fixture has + # one of its own with twenty columns -- so this premise arm reported 81 and caught my + # own query rather than the tree. `'t_col'::regclass` resolves through search_path to + # THIS test's schema and nothing else. + expect.num(p.one("SELECT count(*) FROM pg_attribute " + "WHERE attrelid = 't_col'::regclass " + "AND attnum > 0 AND NOT attisdropped"), 61, + "premise: the table really is sixty-one columns wide") + c, h = p.both("SELECT * FROM %T") + expect.row_set(c, h, "wide row scan") + c, h = p.both("SELECT id, c1, c30, c60 FROM %T WHERE c30 > 5000") + expect.row_set(c, h, "wide row proj") + + +# --------------------------------------------------------------------------- +# Part 3: lightweight encodings +# +# Data shaped so each encoding is the one chosen, and the oracle proves the round trip. +# Which encoding was APPLIED is the native_encoding suite's question; this asks only +# that whatever was applied is reversible. +# +# THE i4 FIXTURE USES random(), which is why `_pair` generates once into heap and +# copies. Run the generator twice and the two tables hold different numbers, and the +# oracle then reports a columnar defect that is really two fixtures -- on the suite +# whose whole purpose is to be believed when it says the two disagree. +# --------------------------------------------------------------------------- + +_ENC_OPTS = ("chunk_group_row_limit => 2000, stripe_row_limit => 20000, " + "compression => 'none'") + + +def test_the_integer_encodings_round_trip(pgc_conn, expect): + """Four shapes in one table, each the input a different encoding is chosen for. + + `seqv` is g*3, so consecutive deltas are constant and delta-of-delta wins. + `lowcard` is g%4, four distinct values, so a dictionary wins. `constv` is one + value, so the column is a constant. `rnd` is a hash-spread bigint, so nothing + lightweight applies and it takes the ordinary path. Putting them in ONE table is + the point: the encodings are chosen per column, and a writer that applied one + column's verdict to another would pass a table with only one shape in it. + + `compression => 'none'` so the codec cannot mask a wrong encoding by compressing + the damage away. + """ + p = _pair(pgc_conn, "id int, seqv bigint, lowcard int, constv int, rnd bigint", + load="SELECT g, g::bigint*3, g%4, 42, ((g*2654435761)%1000000000)::bigint " + "FROM generate_series(1,10000) g", + options=_ENC_OPTS) + expect.num(p.one("SELECT count(DISTINCT lowcard) FROM t_col"), 4, + "premise: the low-cardinality column really has four values") + expect.num(p.one("SELECT count(DISTINCT constv) FROM t_col"), 1, + "premise: and the constant column really has one") + for label, sql in ( + ("enc whole-row", "SELECT * FROM %T"), + ("enc seq range", "SELECT id FROM %T WHERE seqv BETWEEN 100 AND 5000"), + ("enc lowcard eq", "SELECT id FROM %T WHERE lowcard = 2"), + ("enc const scan", "SELECT id FROM %T WHERE constv = 42"), + ("enc aggregate", "SELECT sum(seqv), min(lowcard), max(lowcard), " + "count(constv), sum(rnd) FROM %T")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + +def test_the_float_and_timestamp_encodings_round_trip(pgc_conn, expect): + """Gorilla and delta-of-delta, on the inputs each is chosen for (I4). + + `alt` is a random walk: many distinct values so a dictionary bails, irregular bit + deltas so frame-of-reference and delta lose, small consecutive XOR so Gorilla wins. + `tsreg` is a fixed one-minute interval, so the delta of the delta is zero and DOD + beats plain delta. `fr` cycles through seven values, so frame-of-reference applies. + + THE RANDOM WALK IS WHY THE PAIR IS COPIED RATHER THAN REGENERATED. Measured on this + fixture's generator, 2000 rows: regenerated, all 2000 rows differ; generated once + and copied, 0 differ. An oracle over two different fixtures is not an oracle. + + min/max rather than sum for the floats, deliberately: a float sum has no single + right answer (part 1 measures three from heap alone by row order), and min/max does. + """ + p = _pair(pgc_conn, "id int, alt float8, tsreg timestamp, fr float8", + load="SELECT g, (1000 + sum(random() - 0.5) OVER (ORDER BY g))::float8, " + "TIMESTAMP '2020-01-01' + make_interval(mins => g), " + "(100 + (g%7) * 0.25)::float8 FROM generate_series(1,10000) g", + options=_ENC_OPTS) + expect.at_least(p.one("SELECT count(DISTINCT alt) FROM t_col"), 9000, + "premise: the random walk is high-cardinality, so a dictionary bails") + expect.num(p.one("SELECT count(DISTINCT fr) FROM t_col"), 7, + "premise: and the frame-of-reference column cycles through seven") + for label, sql in ( + ("i4 whole-row", "SELECT * FROM %T"), + ("i4 float agg", "SELECT count(alt), min(alt), max(alt), " + "count(fr), min(fr), max(fr) FROM %T"), + ("i4 ts range", "SELECT id FROM %T WHERE tsreg >= TIMESTAMP '2020-01-05'"), + ("i4 ts minmax", "SELECT min(tsreg), max(tsreg) FROM %T")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + +def test_the_dictionary_encoding_round_trips_including_varlena(pgc_conn, expect): + """Dictionary (I5), including the text and varchar columns that had no lightweight + encoding before it. + + `cat` has four values and `tag` six, so both are dictionary candidates; `hicard` is + an md5 per row, so it is not, and it is in the table to prove the verdict is per + column. `GROUP BY cat` is the arm a whole-row comparison cannot replace: it reads + the column through the grouping path rather than the projection path. + """ + p = _pair(pgc_conn, "id int, cat text, tag varchar(16), code int, hicard text", + load="SELECT g, (ARRAY['north','south','east','west'])[1 + g%4], " + "('t' || (g%6))::varchar(16), g%5, md5(g::text) " + "FROM generate_series(1,10000) g", + options=_ENC_OPTS) + expect.num(p.one("SELECT count(DISTINCT cat) FROM t_col"), 4, + "premise: the dictionary column has four values") + expect.at_least(p.one("SELECT count(DISTINCT hicard) FROM t_col"), 9000, + "premise: and the high-cardinality one is not a candidate") + for label, sql in ( + ("dict whole-row", "SELECT * FROM %T"), + ("dict text eq", "SELECT id FROM %T WHERE cat = 'east'"), + ("dict text agg", "SELECT count(cat), min(cat), max(cat), " + "count(distinct tag) FROM %T"), + ("dict text group", "SELECT cat, count(*) FROM %T GROUP BY cat")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + +def test_an_uncompressed_table_still_round_trips(pgc_conn, expect): + """Encoding is independent of the codec, so a table with compression off must still + decode. Without this arm every encoding above is only ever read back through a + codec, and a bug that the codec happens to mask would never show.""" + p = _pair(pgc_conn, "id int, v bigint", + load="SELECT g, (g%7)::bigint FROM generate_series(1,5000) g", + options="compression => 'none'") + for label, sql in (("enc+nocompress scan", "SELECT * FROM %T"), + ("enc+nocompress agg", "SELECT count(*), sum(v), min(v), max(v) FROM %T")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + +# --------------------------------------------------------------------------- +# Part 4: aggregates over data with nulls and deletes +# +# The vectorized aggregate answers ungrouped count/sum/avg/min/max from the value +# stream. Nulls are skipped by that stream and deletes force a per-group fallback, so a +# fixture with both exercises the fast path AND its fallback in one table. +# --------------------------------------------------------------------------- + + +def test_aggregates_agree_with_nulls_and_deletes_present(pgc_conn, expect): + """Five columns, one with nulls, and one row in fifty deleted. + + The DELETE is what makes this more than part 1's aggregate arms: a row group with a + delete cannot be answered from the value stream alone, so the scan falls back per + group -- and a fallback that double-counts or skips shows in `count(*)` while every + other arm stays green. Both tables get the same DELETE, so the oracle still holds. + """ + p = _pair(pgc_conn, "id int, k int, big int, s smallint, nv int", + load="SELECT g, g%6, g*2, ((g%100)-50)::smallint, " + "CASE WHEN g%9=0 THEN NULL ELSE g%13 END FROM generate_series(1,20000) g", + options="chunk_group_row_limit => 1000, stripe_row_limit => 5000") + before = p.one("SELECT count(*) FROM t_col") + pgc_conn.execute("DELETE FROM t_heap WHERE id % 50 = 0") + pgc_conn.execute("DELETE FROM t_col WHERE id % 50 = 0") + after = p.one("SELECT count(*) FROM t_col") + expect.num(before - after, 400, "premise: the delete removed four hundred rows") + expect.at_least(p.one("SELECT count(*) FROM t_col WHERE nv IS NULL"), 1, + "premise: and there are nulls for the value stream to skip") + for label, sql in ( + ("agg count", "SELECT count(*), count(k), count(nv) FROM %T"), + ("agg sum", "SELECT sum(big), sum(k), sum(nv) FROM %T"), + ("agg avg", "SELECT avg(k), avg(nv) FROM %T"), + ("agg minmax", "SELECT min(k), max(k), min(big), max(big), " + "min(s), max(s), min(nv), max(nv) FROM %T")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + +# --------------------------------------------------------------------------- +# Part 5: bloom-filter equality skipping +# +# Values are hash-spread so every chunk's min/max spans the domain and cannot skip an +# in-range equality probe. The bloom filter is what prunes it, and whether a bloom was +# BUILT is the native_bloom suite's question -- these arms ask whether the answer is +# right, which is the one thing a wrong bloom breaks and a missing one does not. +# --------------------------------------------------------------------------- + +_BLOOM_OPTS = "chunk_group_row_limit => 1000, stripe_row_limit => 20000" + + +def test_bloom_equality_agrees_on_numeric_and_uuid_keys(pgc_conn, expect): + """Hash-spread keys, so min/max cannot prune and only a bloom can. + + The premise is the whole fixture: if the keys were ordered, every arm here would pass + on zone maps alone and say nothing about blooms. `(g*2654435761)%100000` spreads them, + so each chunk's range covers almost the whole domain -- asserted below rather than + assumed, because a fixture that quietly became ordered would make this suite vacuous + in a way no arm would report. + """ + p = _pair(pgc_conn, "id int, k bigint, u uuid", + load="SELECT g, ((g*2654435761)%100000)::bigint, md5((g%99999)::text)::uuid " + "FROM generate_series(1,20000) g", + options=_BLOOM_OPTS) + # Each chunk group's range must span most of the domain, or min/max alone prunes and + # the bloom is never the thing under test. + spread = p.one( + "SELECT min(width) FROM (SELECT max(k) - min(k) AS width FROM (" + " SELECT k, ntile(20) OVER (ORDER BY id) AS grp FROM t_col) s GROUP BY grp) w") + expect.at_least(spread, 90000, + "premise: every chunk's key range spans the domain, so min/max cannot skip") + for label, sql in ( + ("bloom k present", "SELECT id FROM %T WHERE k = ((7*2654435761)%100000)::bigint"), + ("bloom u eq", "SELECT count(*) FROM %T WHERE u = md5('123')::uuid"), + ("bloom k range", "SELECT count(*) FROM %T WHERE k < 50000")): + c, h = p.both(sql) + expect.row_set(c, h, label) + + # A value strictly inside the global min/max that is present in NO row. Every + # hash-spread chunk's range contains it, so min/max cannot exclude it and the answer + # must still be nothing. Derived from the data rather than guessed, because a guessed + # "absent" value that turns out to be present asserts the opposite of the intent. + absent = p.one("SELECT v FROM generate_series((SELECT min(k)+1 FROM t_heap)::int, " + "(SELECT max(k)-1 FROM t_heap)::int) v " + "WHERE v NOT IN (SELECT k FROM t_heap) LIMIT 1") + expect.num(p.one(f"SELECT count(*) FROM t_heap WHERE k = {absent}::bigint"), 0, + "premise: the probe value really is absent from the oracle") + expect.num(p.one(f"SELECT count(*) FROM t_col WHERE k = {absent}::bigint"), 0, + "bloom absent correct") + + +def test_text_bloom_equality_agrees_including_a_mismatched_collation(pgc_conn, expect): + """Deterministic-collation text is bloomed; an explicit mismatched COLLATE is not. + + The last arm is the one that matters: a bloom built under one collation cannot be + used to skip a probe under another, so the filter must NOT be pushed -- and if it + were, the query would return too few rows rather than erroring. That is a wrong + answer, not a failure, which is why it is compared against the oracle rather than + checked for a plan shape. + """ + p = _pair(pgc_conn, 'id int, tk text, tc text COLLATE "C"', + load="SELECT g, 'k' || ((g*2654435761)%50000), 'c' || ((g*40503)%50000) " + "FROM generate_series(1,16000) g", + options=_BLOOM_OPTS) + # THE BASH ARM PROBES A VALUE THAT IS NOT THERE, and that is the fourth unfalsifiable + # arm this port has found -- the subtlest, because the arm is specifically built to + # catch a wrongly-pushed filter and a wrongly-pushed filter produces the same answer + # as the absent value. + # + # `tk` is 'k' || ((g*2654435761)%50000) over 16,000 rows of a 50,000-wide domain, so + # only 32% of values appear. Measured: `tk = 'k100'` matches 0 rows. A bloom wrongly + # pushed under a mismatched collation would skip the chunks holding the match and + # return 0 -- identical to the correct answer for an absent value. Both sides give 0, + # the arm passes, and the one defect it exists to detect is invisible to it. + # + # So the port probes a value that IS present, derived from the data rather than + # guessed. Then a wrongly-pushed filter returns 0 where the oracle returns 1, and the + # arm can fail for its own reason. The name is kept so compare_to_bash.py still pairs + # the two. + present = p.one("SELECT tk FROM t_heap ORDER BY id LIMIT 1") + expect.num(p.one(f"SELECT count(*) FROM t_heap WHERE tk = '{present}'"), 1, + "premise: the mismatched-collation probe value is present exactly once") + for label, sql in ( + ("textbloom present", "SELECT id FROM %T WHERE tk = 'k' || ((7*2654435761)%50000)"), + ("textbloom absent", "SELECT count(*) FROM %T WHERE tk = 'zzzzzzzz'"), + ("textbloom C absent", "SELECT count(*) FROM %T WHERE tc = 'zzzzzzzz'"), + ("textbloom C eq", "SELECT count(*) FROM %T WHERE tc = 'c123'"), + ("textbloom collate-mismatch", + f"SELECT count(*) FROM %T WHERE tk = '{present}' COLLATE \"C\"")): + c, h = p.both(sql) + expect.row_set(c, h, label) + # AND THE DIRECTION IT MUST FAIL IN, stated as its own arm: the mismatched collation + # must return the row, not zero. Without this the comparison above is satisfied by + # both sides returning 0, which is what the bash arm does today. + expect.num(p.one(f"SELECT count(*) FROM t_col WHERE tk = '{present}' COLLATE \"C\""), 1, + "textbloom collate-mismatch returns the row rather than skipping it") + + +# --------------------------------------------------------------------------- +# Part 6: a selective filter with several wide output columns +# --------------------------------------------------------------------------- + + +def test_a_selective_filter_with_a_wide_projection_agrees(pgc_conn, expect): + """One filtered column, four projected, at four selectivities from one row to most. + + `wide nomatch` matches nothing on both sides, which is the second arm in this file + that cannot fail on its own -- kept with a stated reason and a positive control, the + same treatment `empty scan` gets. The other three are values or non-empty sets. + """ + p = _pair(pgc_conn, "id int, sel int, a text, b bigint, c numeric", + load="SELECT g, g, 'a'||g, g::bigint*2, g::numeric*1.5 " + "FROM generate_series(1,20000) g", + options=_BLOOM_OPTS) + c, h = p.both("SELECT id, a, b, c FROM %T WHERE sel = 12345") + expect.row_set(c, h, "wide point") + c, h = p.both("SELECT id, a, b FROM %T WHERE sel BETWEEN 5000 AND 5100") + expect.row_set(c, h, "wide range") + c, h = p.both("SELECT id, a, b, c FROM %T WHERE sel = 999999") + expect.row_set(c, h, "wide nomatch", + allow_empty="no row has sel = 999999; the control below is what can fail") + c, h = p.both("SELECT id, a FROM %T WHERE sel > 100") + expect.row_set(c, h, "wide most") + # THE CONTROL for `wide nomatch`: the same projection at a value that IS present must + # return a row, or "returns nothing" is the only behaviour that arm has ever observed. + c, h = p.both("SELECT id, a, b, c FROM %T WHERE sel = 19999") + expect.row_set(c, h, "wide nomatch control: a present value returns its row") + expect.num(len(c), 1, "and the control really did match one row") + + +# --------------------------------------------------------------------------- +# Part 7: covering count(*) from metadata +# +# count(*) with no filter is answered from each row group's stored row count minus the +# visible-row-mask deletes, skipping the data scan. It must equal the oracle after +# inserts, deletes AND updates, whether that path is taken or not. +# --------------------------------------------------------------------------- + + +def test_a_covering_count_agrees_with_the_path_on_and_off(pgc_conn, expect): + """The same count, both ways through `enable_vectorization`, after a delete and an + update. + + An UPDATE on a columnar table appends the new row and masks the old, so the stored + per-group count and the visible count diverge -- which is exactly the arithmetic the + metadata path has to get right, and exactly what a plain scan gets right for free. + Running it both ways is what distinguishes "the fast path is correct" from "the fast + path was not taken". + + `SET` on this connection rather than the bash suite's `ALTER DATABASE`: that form + exists because each psql invocation there is a new session, and this one is not. + """ + p = _pair(pgc_conn, "id int, v int", + load="SELECT g, g%10 FROM generate_series(1,20000) g", + options="chunk_group_row_limit => 1000, stripe_row_limit => 3000") + pgc_conn.execute("DELETE FROM t_heap WHERE id % 13 = 0") + pgc_conn.execute("DELETE FROM t_col WHERE id % 13 = 0") + pgc_conn.execute("UPDATE t_heap SET v = v + 1 WHERE id % 17 = 0") + pgc_conn.execute("UPDATE t_col SET v = v + 1 WHERE id % 17 = 0") + expect.num(p.one("SELECT count(*) FROM t_heap"), 18462, + "premise: the delete and update leave a count neither one would give alone") + + for mode in ("on", "off"): + pgc_conn.execute(f"SET pgcolumnar.enable_vectorization = {mode}") + c, h = p.both("SELECT count(*) FROM %T") + expect.row_set(c, h, f"count meta={mode}") + pgc_conn.execute("RESET pgcolumnar.enable_vectorization")