|
| 1 | +# Copyright Materialize, Inc. and contributors. All rights reserved. |
| 2 | +# |
| 3 | +# Use of this software is governed by the Business Source License |
| 4 | +# included in the LICENSE file at the root of this repository. |
| 5 | +# |
| 6 | +# As of the Change Date specified in that file, in accordance with |
| 7 | +# the Business Source License, use of this software will be governed |
| 8 | +# by the Apache License, Version 2.0. |
| 9 | +from textwrap import dedent |
| 10 | + |
| 11 | +from materialize.checks.actions import Testdrive |
| 12 | +from materialize.checks.checks import Check, externally_idempotent |
| 13 | + |
| 14 | +# Row packing canonicalizes floats (-0.0 packs as +0.0, every NaN packs as one |
| 15 | +# bit pattern) so that byte equality of packed rows agrees with SQL float |
| 16 | +# equality. Data written by versions without that canonicalization carries the |
| 17 | +# raw bit patterns, so these checks exercise the cross-version story: rows |
| 18 | +# written by an older version must cancel against retractions written by a |
| 19 | +# newer one, and must land in the same DISTINCT/GROUP BY/index groups. |
| 20 | +# |
| 21 | +# In multi-version scenarios validate() also runs on versions without the |
| 22 | +# canonicalization, where -0.0 is a distinct arrangement key, so the |
| 23 | +# assertions that depend on it are gated on version 26.33 (which is when the |
| 24 | +# canonicalization was introduced). Row counts and upsert results hold on all |
| 25 | +# versions and are asserted unconditionally. |
| 26 | +# |
| 27 | +# NOTE: a genuine -0.0 needs a text->float cast ('-0'), a -0.0 literal goes |
| 28 | +# through numeric (no signed zero) and arrives as +0.0. |
| 29 | + |
| 30 | + |
| 31 | +class FloatCanonicalizationTable(Check): |
| 32 | + """-0.0 and NaN in a table across versions: retraction of old-encoding |
| 33 | + rows, DISTINCT/GROUP BY collapse, and index point lookups.""" |
| 34 | + |
| 35 | + def initialize(self) -> Testdrive: |
| 36 | + return Testdrive(dedent(""" |
| 37 | + > CREATE TABLE float_canon_table (id INT, f DOUBLE PRECISION); |
| 38 | + > INSERT INTO float_canon_table VALUES |
| 39 | + (1, '-0'), (2, '0'), (3, 'NaN'), (4, '-0'), (5, 1.5); |
| 40 | + """)) |
| 41 | + |
| 42 | + def manipulate(self) -> list[Testdrive]: |
| 43 | + return [ |
| 44 | + Testdrive(dedent(s)) |
| 45 | + for s in [ |
| 46 | + """ |
| 47 | + > CREATE MATERIALIZED VIEW float_canon_table_mv AS |
| 48 | + SELECT f, COUNT(*) AS c FROM float_canon_table GROUP BY f; |
| 49 | + > CREATE DEFAULT INDEX ON float_canon_table; |
| 50 | + > INSERT INTO float_canon_table VALUES (6, '-0'); |
| 51 | + """, |
| 52 | + """ |
| 53 | + > DELETE FROM float_canon_table WHERE id = 4; |
| 54 | + > INSERT INTO float_canon_table VALUES (7, '0'), (8, 'NaN'); |
| 55 | + """, |
| 56 | + ] |
| 57 | + ] |
| 58 | + |
| 59 | + def validate(self) -> Testdrive: |
| 60 | + return Testdrive(dedent(""" |
| 61 | + > SELECT count(*) FROM float_canon_table; |
| 62 | + 7 |
| 63 | +
|
| 64 | + >[version>=2603300] SELECT count(*) FROM (SELECT DISTINCT f FROM float_canon_table); |
| 65 | + 3 |
| 66 | +
|
| 67 | + >[version>=2603300] SELECT f::text, c FROM float_canon_table_mv; |
| 68 | + 0 4 |
| 69 | + 1.5 1 |
| 70 | + NaN 2 |
| 71 | +
|
| 72 | + >[version>=2603300] SELECT id FROM float_canon_table WHERE f = 0; |
| 73 | + 1 |
| 74 | + 2 |
| 75 | + 6 |
| 76 | + 7 |
| 77 | +
|
| 78 | + >[version>=2603300] SELECT id FROM float_canon_table WHERE f = 'NaN'; |
| 79 | + 3 |
| 80 | + 8 |
| 81 | + """)) |
| 82 | + |
| 83 | + |
| 84 | +@externally_idempotent(False) |
| 85 | +class FloatCanonicalizationPgCdc(Check): |
| 86 | + """-0.0 and NaN ingested from a Postgres source across versions: an |
| 87 | + upstream DELETE/UPDATE after an upgrade must retract rows whose additions |
| 88 | + were written with the old float encoding.""" |
| 89 | + |
| 90 | + def initialize(self) -> Testdrive: |
| 91 | + return Testdrive(dedent(""" |
| 92 | + $ postgres-execute connection=postgres://postgres:postgres@postgres |
| 93 | + CREATE USER postgres_float_canon WITH SUPERUSER PASSWORD 'postgres'; |
| 94 | + ALTER USER postgres_float_canon WITH replication; |
| 95 | + DROP PUBLICATION IF EXISTS float_canon_publication; |
| 96 | + DROP TABLE IF EXISTS float_canon_pg_table; |
| 97 | + CREATE TABLE float_canon_pg_table (id INT PRIMARY KEY, f DOUBLE PRECISION); |
| 98 | + ALTER TABLE float_canon_pg_table REPLICA IDENTITY FULL; |
| 99 | + INSERT INTO float_canon_pg_table VALUES (1, '-0'), (2, '0'), (3, 'NaN'), (4, '-0'); |
| 100 | + CREATE PUBLICATION float_canon_publication FOR ALL TABLES; |
| 101 | +
|
| 102 | + > CREATE SECRET float_canon_pgpass AS 'postgres'; |
| 103 | +
|
| 104 | + > CREATE CONNECTION float_canon_pg_conn FOR POSTGRES |
| 105 | + HOST 'postgres', |
| 106 | + DATABASE postgres, |
| 107 | + USER postgres_float_canon, |
| 108 | + PASSWORD SECRET float_canon_pgpass; |
| 109 | +
|
| 110 | + > CREATE SOURCE float_canon_pg_source |
| 111 | + FROM POSTGRES CONNECTION float_canon_pg_conn |
| 112 | + (PUBLICATION 'float_canon_publication'); |
| 113 | + > CREATE TABLE float_canon_pg FROM SOURCE float_canon_pg_source |
| 114 | + (REFERENCE float_canon_pg_table); |
| 115 | +
|
| 116 | + # Wait for the snapshot so the initial rows are ingested (and |
| 117 | + # thus encoded) by the version running this phase. |
| 118 | + > SELECT count(*) FROM float_canon_pg; |
| 119 | + 4 |
| 120 | + """)) |
| 121 | + |
| 122 | + def manipulate(self) -> list[Testdrive]: |
| 123 | + return [ |
| 124 | + Testdrive(dedent(s)) |
| 125 | + for s in [ |
| 126 | + """ |
| 127 | + $ postgres-execute connection=postgres://postgres:postgres@postgres |
| 128 | + INSERT INTO float_canon_pg_table VALUES (5, '-0'), (6, 'NaN'); |
| 129 | + """, |
| 130 | + """ |
| 131 | + $ postgres-execute connection=postgres://postgres:postgres@postgres |
| 132 | + DELETE FROM float_canon_pg_table WHERE id IN (1, 6); |
| 133 | + UPDATE float_canon_pg_table SET f = '0' WHERE id = 4; |
| 134 | + """, |
| 135 | + ] |
| 136 | + ] |
| 137 | + |
| 138 | + def validate(self) -> Testdrive: |
| 139 | + return Testdrive(dedent(""" |
| 140 | + > SELECT count(*) FROM float_canon_pg; |
| 141 | + 4 |
| 142 | +
|
| 143 | + >[version>=2603300] SELECT count(*) FROM (SELECT DISTINCT f FROM float_canon_pg); |
| 144 | + 2 |
| 145 | +
|
| 146 | + >[version>=2603300] SELECT id FROM float_canon_pg WHERE f = 0; |
| 147 | + 2 |
| 148 | + 4 |
| 149 | + 5 |
| 150 | +
|
| 151 | + >[version>=2603300] SELECT id FROM float_canon_pg WHERE f = 'NaN'; |
| 152 | + 3 |
| 153 | + """)) |
| 154 | + |
| 155 | + |
| 156 | +def float_canon_schemas() -> str: |
| 157 | + return dedent(""" |
| 158 | + $ set float-canon-keyschema={ |
| 159 | + "type": "record", |
| 160 | + "name": "Key", |
| 161 | + "fields": [ {"name": "key1", "type": "double"} ] |
| 162 | + } |
| 163 | +
|
| 164 | + $ set float-canon-schema={ |
| 165 | + "type" : "record", |
| 166 | + "name" : "test", |
| 167 | + "fields" : [ {"name": "f1", "type": "double"} ] |
| 168 | + } |
| 169 | + """) |
| 170 | + |
| 171 | + |
| 172 | +class FloatCanonicalizationUpsert(Check): |
| 173 | + """-0.0 in a Kafka upsert source's key and value across versions: a -0.0 |
| 174 | + and a +0.0 key are the same key, and a post-upgrade tombstone must retract |
| 175 | + a value row written with the old float encoding.""" |
| 176 | + |
| 177 | + def initialize(self) -> Testdrive: |
| 178 | + return Testdrive(float_canon_schemas() + dedent(""" |
| 179 | + $ kafka-create-topic topic=float-canon-upsert |
| 180 | +
|
| 181 | + $ kafka-ingest format=avro key-format=avro topic=float-canon-upsert key-schema=${float-canon-keyschema} schema=${float-canon-schema} |
| 182 | + {"key1": -0.0} {"f1": 1.0} |
| 183 | + {"key1": 2.0} {"f1": -0.0} |
| 184 | +
|
| 185 | + > CREATE SOURCE float_canon_upsert_src |
| 186 | + FROM KAFKA CONNECTION kafka_conn (TOPIC 'testdrive-float-canon-upsert-${testdrive.seed}') |
| 187 | + > CREATE TABLE float_canon_upsert FROM SOURCE float_canon_upsert_src (REFERENCE "testdrive-float-canon-upsert-${testdrive.seed}") |
| 188 | + FORMAT AVRO USING CONFLUENT SCHEMA REGISTRY CONNECTION csr_conn |
| 189 | + ENVELOPE UPSERT |
| 190 | +
|
| 191 | + # Wait for the snapshot so the initial rows are ingested (and |
| 192 | + # thus encoded) by the version running this phase. |
| 193 | + > SELECT count(*) FROM float_canon_upsert; |
| 194 | + 2 |
| 195 | + """)) |
| 196 | + |
| 197 | + def manipulate(self) -> list[Testdrive]: |
| 198 | + return [ |
| 199 | + Testdrive(float_canon_schemas() + dedent(s)) |
| 200 | + for s in [ |
| 201 | + """ |
| 202 | + # The +0.0 key is the same key as the -0.0 key, so this |
| 203 | + # replaces the (0, 1) row rather than adding a third row. |
| 204 | + $ kafka-ingest format=avro key-format=avro topic=float-canon-upsert key-schema=${float-canon-keyschema} schema=${float-canon-schema} |
| 205 | + {"key1": 0.0} {"f1": 3.0} |
| 206 | + """, |
| 207 | + """ |
| 208 | + # Tombstone the key whose value row (f1 = -0.0) may have been |
| 209 | + # written with the old float encoding. |
| 210 | + $ kafka-ingest format=avro key-format=avro topic=float-canon-upsert key-schema=${float-canon-keyschema} schema=${float-canon-schema} |
| 211 | + {"key1": 2.0} |
| 212 | + """, |
| 213 | + ] |
| 214 | + ] |
| 215 | + |
| 216 | + def validate(self) -> Testdrive: |
| 217 | + return Testdrive(dedent(""" |
| 218 | + > SELECT key1::text, f1::text FROM float_canon_upsert; |
| 219 | + 0 3 |
| 220 | + """)) |
0 commit comments