@intentius/chant-lexicon-sql 0.114.0 → 0.114.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,6 +41,21 @@
41
41
  * With a replica down (#3270) the kill goes on without it, and each sync
42
42
  * either finds nothing that replica alone has and returns, or stops the step
43
43
  * naming it (`./replicas.ts`).
44
+ *
45
+ * In materialized-view mode a partition is copied in two parts
46
+ * (INTENTIUS/sql-yodeler#45). The rows before the cut-over are copied as
47
+ * above. The rows at or after it are the view's only when they were written
48
+ * after the view was made; the ones the old table already held (a booking
49
+ * next month, an expiry date) reach the new table only through the backfill.
50
+ * The two kinds cannot be told apart by their values, so the second part
51
+ * copies the difference: for each distinct row at or after the cut-over, as
52
+ * many copies as the old table has more of it than the new one. That copy
53
+ * converges on its own, so a run killed during it, or a partition copied
54
+ * again, copies only what is still missing. A row the view is a moment from
55
+ * committing can be copied by both: the copy is followed by a check for rows
56
+ * the new table holds more often than the old one, which deletes and copies
57
+ * those again. Whatever is left, the verification finds, and the swap does
58
+ * not go ahead.
44
59
  */
45
60
  import { type ClickHouseReceiptStore } from "./receipts.js";
46
61
  import { type RebuildObservation } from "./observe.js";
@@ -55,6 +70,8 @@ export interface BackfillResult {
55
70
  skipped: number;
56
71
  /** Partitions this run found partly copied and cleared before copying again. */
57
72
  cleared: number;
73
+ /** Distinct rows at or after the cut-over found in the new table too often (copied while the view was writing them), deleted and copied again. */
74
+ repaired: number;
58
75
  }
59
76
  /** The effect a partition's copy is, and its receipt's address suffix. */
60
77
  export declare const partitionEffect: (key: string, partition: string) => string;
@@ -1 +1 @@
1
- {"version":3,"file":"backfill.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/backfill.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAKH,OAAO,EAA0C,KAAK,sBAAsB,EAAE,MAAM,YAAY,CAAC;AACjG,OAAO,EAAkB,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAGpE,OAAO,EAAkE,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1G,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,mCAAmC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,0EAA0E;AAC1E,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,EAAE,WAAW,MAAM,KAAG,MAAuC,CAAC;AAEzG,6DAA6D;AAC7D,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CASlH;AAED,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,sBAAsB,CAAC;IAClC,0GAA0G;IAC1G,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9D;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,CAoFhG"}
1
+ {"version":3,"file":"backfill.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/backfill.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0DG;AAKH,OAAO,EAA0C,KAAK,sBAAsB,EAAE,MAAM,YAAY,CAAC;AACjG,OAAO,EAAkB,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAIpE,OAAO,EAAkE,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1G,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,mCAAmC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;IAChB,kJAAkJ;IAClJ,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,0EAA0E;AAC1E,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,EAAE,WAAW,MAAM,KAAG,MAAuC,CAAC;AAEzG,6DAA6D;AAC7D,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAiBlH;AAED,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,sBAAsB,CAAC;IAClC,0GAA0G;IAC1G,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9D;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,GAAE,YAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,CAwFhG"}
@@ -46,9 +46,10 @@ export type DualWrite = {
46
46
  /**
47
47
  * A materialized view on the old table writes every row whose
48
48
  * `cutoverColumn` is at or after the cut-over into the new table; the
49
- * backfill copies the rows before it. The cut-over is the server's time
50
- * when the view is made plus `cutoverDelay` (default `1m`), and the
51
- * backfill starts once it has passed. The column must be a time
49
+ * backfill copies the rows before it, and the rows at or after it that
50
+ * the table held before the view was made. The cut-over is the server's
51
+ * time when the view is made plus `cutoverDelay` (default `1m`), rounded
52
+ * up to a whole second, and the backfill starts once it has passed. The column must be a time
52
53
  * (`Date`, `DateTime`, `DateTime64`) that rows arrive in order of, give
53
54
  * or take the delay; a row that arrives later than that is caught by
54
55
  * the verification, which then fails the run.
@@ -1 +1 @@
1
- {"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/observe.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"observe.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/observe.ts"],"names":[],"mappings":"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"}
@@ -73,7 +73,8 @@ export interface DualWriteResult {
73
73
  * old table that writes every row at or after the cut-over into the new
74
74
  * table. The cut-over is the server's time now plus the delay, kept in the
75
75
  * view's own comment (`cutover=`), so a later run and the backfill read it
76
- * back from the server. App mode creates nothing; its gate is the Op's.
76
+ * back from the server. The rows at or after the cut-over that the old table
77
+ * already held are the backfill's (`./backfill.ts`). App mode creates nothing; its gate is the Op's.
77
78
  */
78
79
  export declare function startDualWrite(run: RebuildRun): Promise<DualWriteResult>;
79
80
  /** The cut-over in milliseconds, from the dual-write view; undefined in app mode. */
@@ -88,8 +89,9 @@ export interface SwapResult {
88
89
  retainUntil?: string;
89
90
  }
90
91
  /**
91
- * Swap the new table in: `EXCHANGE TABLES`, then drop the dual-write view,
92
- * rename the old table (now under the new table's name) to `t__chant_old`
92
+ * Swap the new table in: compare every row of the two tables once more, as
93
+ * the verification does, and stop on any difference; then `EXCHANGE TABLES`,
94
+ * drop the dual-write view, rename the old table (now under the new table's name) to `t__chant_old`
93
95
  * with its retention date, recreate the materialized views that read the
94
96
  * table, and stamp the table's own comment as declared.
95
97
  *
@@ -1 +1 @@
1
- {"version":3,"file":"steps.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/steps.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"steps.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/steps.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAGlE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,EAAyE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAKjI,OAAO,EAML,KAAK,SAAS,EACd,KAAK,kBAAkB,EACxB,MAAM,WAAW,CAAC;AAEnB,gCAAgC;AAChC,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,gBAAgB,CAAC;IACzB,wCAAwC;IACxC,QAAQ,EAAE,cAAc,CAAC;IACzB,wEAAwE;IACxE,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,sEAAsE;IACtE,QAAQ,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6HAA6H;IAC7H,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,sHAAsH;AACtH,wBAAsB,SAAS,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAGzE;AAED,0EAA0E;AAC1E,eAAO,MAAM,MAAM,GAAI,IAAI,MAAM,KAAG,MAAuE,CAAC;AAE5G,qCAAqC;AACrC,eAAO,MAAM,UAAU,GAAI,IAAI,MAAM,KAAG,MAA4D,CAAC;AAErG,eAAO,MAAM,OAAO,GAAI,KAAK,UAAU,KAAG,OAAO,CAAC,kBAAkB,CAAyD,CAAC;AAe9H,wBAAsB,MAAM,CAAC,GAAG,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM5F;AAED;;;;;;;;GAQG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE,IAAI,CAAC,kBAAkB,EAAE,YAAY,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO5I;AAOD,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,yBAAyB;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CAoB3E;AAID,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IACxB,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,eAAe,CAAC,CA4B9E;AAED,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE,kBAAkB,GAAG,MAAM,GAAG,SAAS,CAKpF;AAID,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CA2CrE;AAeD,uFAAuF;AACvF,wBAAsB,cAAc,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAOtI;AASD,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,GAAG,EAAE,OAAO,CAAC;IACb,oHAAoH;IACpH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,sFAAsF;AACtF,wBAAsB,UAAU,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CAavE;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,2GAA2G;AAC3G,wBAAsB,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAUvE;AAID,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,KAAK,GAAG,QAAQ,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAoBtI"}
@@ -1,20 +1,36 @@
1
1
  /**
2
- * The verification (#3198): row counts and checksums per partition, the old
3
- * table against the new one, before anyone is asked to approve the swap.
2
+ * The verification (#3198, INTENTIUS/sql-yodeler#45): row counts and
3
+ * checksums per partition, the old table against the new one, before anyone
4
+ * is asked to approve the swap.
4
5
  *
5
6
  * Per partition of the old table: the number of rows and the sum of a
6
7
  * 64-bit hash of each row's copied columns, read in one grouped query on
7
8
  * each side. The old side casts each column to its type in the new table
8
9
  * first, so a column whose type the rebuild changes hashes the same when
9
- * its values survived the change. In materialized-view mode both sides are
10
- * limited to the rows before the cut-over, which are the backfill's; the
11
- * rows after it were written by the dual-write view and keep arriving.
10
+ * its values survived the change.
11
+ *
12
+ * Every row is compared, in both modes. In materialized-view mode the rows
13
+ * at or after the cut-over are in the new table twice over: those the old
14
+ * table held before the view existed, which the backfill copies, and those
15
+ * written since, which the view writes. Comparing only the rows before the
16
+ * cut-over let a swap go ahead without the first kind (sql-yodeler#45).
17
+ * Writes go on while the tables are read, and a row the old table has
18
+ * committed may be a moment from committing in the new one through the view,
19
+ * so the old table is read first and a difference is read again a few times
20
+ * before it counts.
21
+ *
22
+ * The same comparison runs again in the Swap step just before the
23
+ * `EXCHANGE`, so rows that reach the old table alone after this step (a row
24
+ * later than the cut-over delay, with a time before the cut-over) stop the
25
+ * swap too, rather than stay behind in the old table.
12
26
  *
13
27
  * Any difference fails the run, naming the partitions; the run's onFailure
14
28
  * then drops the new table. On a match the result carries the digest the
15
- * swap gate binds: the rebuild's plan and the verification together, so an
16
- * approval is for this plan with these counts, and a changed count needs a
17
- * new approval.
29
+ * swap gate binds: the rebuild's plan and the counts and checksums of the
30
+ * rows before the cut-over, which no later write changes, so an approval
31
+ * is for this plan with these counts, and a changed count needs a new
32
+ * approval. The rows at or after the cut-over keep arriving through the view
33
+ * and are compared, not bound.
18
34
  */
19
35
  import { type RebuildObservation } from "./observe.js";
20
36
  import { type RebuildRun } from "./steps.js";
@@ -27,25 +43,32 @@ export interface VerifyResult {
27
43
  state: RebuildObservation["state"];
28
44
  /** The digest the swap gate binds: plan plus verification. Absent once the swap has run: there is nothing left for that gate to approve. */
29
45
  planDigest?: string;
46
+ /** Partitions of the old table compared. */
30
47
  partitions: number;
48
+ /** Rows compared: every row of the old table. */
31
49
  rows: number;
32
50
  /** One line for the run record and the gate. */
33
51
  summary: string;
52
+ /** Per partition, the rows before the cut-over (every row in app mode): what the swap gate binds. */
34
53
  verification: PartitionCheck[];
35
54
  }
36
55
  /** A verification that found the tables different. */
37
56
  export declare class RebuildVerificationError extends Error {
38
57
  readonly table: string;
39
- readonly mismatches: Array<{
40
- partition: string;
41
- old?: PartitionCheck;
42
- new?: PartitionCheck;
43
- }>;
44
- constructor(table: string, mismatches: Array<{
45
- partition: string;
46
- old?: PartitionCheck;
47
- new?: PartitionCheck;
48
- }>);
58
+ readonly mismatches: Mismatch[];
59
+ constructor(table: string, mismatches: Mismatch[]);
49
60
  }
61
+ export type Mismatch = {
62
+ partition: string;
63
+ old?: PartitionCheck;
64
+ new?: PartitionCheck;
65
+ };
66
+ /** The 64-bit hash of a row's copied columns, as the backfill and the verification compute it. */
67
+ export declare const rowHash: (columns: readonly string[]) => string;
68
+ /**
69
+ * Before the swap: the new table holds every row the old one does. Throws a
70
+ * {@link RebuildVerificationError} when it does not, so nothing is swapped.
71
+ */
72
+ export declare function assertSameRows(run: RebuildRun, o: RebuildObservation): Promise<void>;
50
73
  export declare function verifyRebuild(run: RebuildRun): Promise<VerifyResult>;
51
74
  //# sourceMappingURL=verify.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/verify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,EAAsC,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAExF,OAAO,EAAuD,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAE/F,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,4IAA4I;IAC5I,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,cAAc,EAAE,CAAC;CAChC;AAED,sDAAsD;AACtD,qBAAa,wBAAyB,SAAQ,KAAK;IAE/C,QAAQ,CAAC,KAAK,EAAE,MAAM;IACtB,QAAQ,CAAC,UAAU,EAAE,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,cAAc,CAAC;QAAC,GAAG,CAAC,EAAE,cAAc,CAAA;KAAE,CAAC;gBADpF,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,cAAc,CAAC;QAAC,GAAG,CAAC,EAAE,cAAc,CAAA;KAAE,CAAC;CAYhG;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CA8D1E"}
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/verify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAKH,OAAO,EAAsC,KAAK,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAExF,OAAO,EAAuD,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAE/F,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACnC,4IAA4I;IAC5I,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,qGAAqG;IACrG,YAAY,EAAE,cAAc,EAAE,CAAC;CAChC;AAED,sDAAsD;AACtD,qBAAa,wBAAyB,SAAQ,KAAK;IAE/C,QAAQ,CAAC,KAAK,EAAE,MAAM;IACtB,QAAQ,CAAC,UAAU,EAAE,QAAQ,EAAE;gBADtB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,QAAQ,EAAE;CAalC;AAyED,MAAM,MAAM,QAAQ,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,cAAc,CAAC;IAAC,GAAG,CAAC,EAAE,cAAc,CAAA;CAAE,CAAC;AAYzF,kGAAkG;AAClG,eAAO,MAAM,OAAO,GAAI,SAAS,SAAS,MAAM,EAAE,KAAG,MAA8D,CAAC;AAEpH;;;GAGG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,UAAU,EAAE,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAG1F;AAED,wBAAsB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CA2B1E"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "4d4b3853d5595af4a169a82b6c45f87a103adace1fdff4e61181bcf34aa0a0e2",
4
+ "manifest.json": "82beced7beffe2bdd0dcf24728910539758d9d4dc85c94fe6418167bcbea9636",
5
5
  "meta.json": "5b7a5d171aa8bd21c0d55cfbe421143d67da02b7ef924a9b66166790c3fc7b22",
6
6
  "types/index.d.ts": "0286ebb367773e84c76818b0f69cb14cff8328fd2f8c65ed87563c8d5815458c",
7
7
  "rules/postgres-templates.ts": "f7244417426c441180ccb5c52da32cd812960d6d4eaba1fbdb17f47fcae707b2",
@@ -57,5 +57,5 @@
57
57
  "rules/sqlpg117.ts": "3d5c2d57ccdee3cf91c3ba3cb6363750787a850df8ebd75e9cc3f33113303827",
58
58
  "rules/sqlpg118.ts": "e2a40a88a9374c4d99d02118ca4c60877ba0c6eb1ce603a93b24f5fd23d81dde"
59
59
  },
60
- "composite": "8d565dbc9a455095b5d5e81aac886a7af1b2cb1f3a6e21d0a4ed513b08e1b52a"
60
+ "composite": "619bc77b9a85523857c04bc2fd6ab9a538a97bca5c6824aadf06c3b58a517b76"
61
61
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sql",
3
- "version": "0.114.0",
3
+ "version": "0.114.1",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "SQL",
6
6
  "intrinsics": [],
@@ -1 +1 @@
1
- {"version":3,"file":"deep.d.ts","sourceRoot":"","sources":["../../../src/postgres/plan/deep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAmB,KAAK,qBAAqB,EAAgC,MAAM,mCAAmC,CAAC;AAE9H,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACvF,OAAO,EAA2B,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAI7E,OAAO,EAAkD,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErG,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAcrC,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,CAAC,EAAE,YAAY,GAAG,KAAK,CAGhD;AA6CD,uGAAuG;AACvG,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,iBAAiB,EAAE,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAwCpH;AAED,wBAAsB,oBAAoB,CACxC,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/E,GAAG,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,GACnC,OAAO,CAAC,qBAAqB,CAAC,CAwChC"}
1
+ {"version":3,"file":"deep.d.ts","sourceRoot":"","sources":["../../../src/postgres/plan/deep.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAmB,KAAK,qBAAqB,EAAgC,MAAM,mCAAmC,CAAC;AAE9H,OAAO,EAAyC,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AACvF,OAAO,EAA2B,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAI7E,OAAO,EAAkD,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErG,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAcrC,+EAA+E;AAC/E,wBAAgB,SAAS,CAAC,CAAC,EAAE,YAAY,GAAG,KAAK,CAGhD;AA6CD,uGAAuG;AACvG,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,iBAAiB,EAAE,CAAC,EAAE,iBAAiB,GAAG,KAAK,CAmDpH;AAED,wBAAsB,oBAAoB,CACxC,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/E,GAAG,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC,GACnC,OAAO,CAAC,qBAAqB,CAAC,CAwChC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-sql",
3
- "version": "0.114.0",
3
+ "version": "0.114.1",
4
4
  "description": "SQL lexicon for chant — database schema as typed declarations, one dialect at a time; ClickHouse first, with types read from a pinned server's catalog",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -70,7 +70,7 @@
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@intentius/chant": "^0.114.0",
73
+ "@intentius/chant": "^0.114.1",
74
74
  "typescript": "^5.9.3"
75
75
  }
76
76
  }
@@ -41,6 +41,21 @@
41
41
  * With a replica down (#3270) the kill goes on without it, and each sync
42
42
  * either finds nothing that replica alone has and returns, or stops the step
43
43
  * naming it (`./replicas.ts`).
44
+ *
45
+ * In materialized-view mode a partition is copied in two parts
46
+ * (INTENTIUS/sql-yodeler#45). The rows before the cut-over are copied as
47
+ * above. The rows at or after it are the view's only when they were written
48
+ * after the view was made; the ones the old table already held (a booking
49
+ * next month, an expiry date) reach the new table only through the backfill.
50
+ * The two kinds cannot be told apart by their values, so the second part
51
+ * copies the difference: for each distinct row at or after the cut-over, as
52
+ * many copies as the old table has more of it than the new one. That copy
53
+ * converges on its own, so a run killed during it, or a partition copied
54
+ * again, copies only what is still missing. A row the view is a moment from
55
+ * committing can be copied by both: the copy is followed by a check for rows
56
+ * the new table holds more often than the old one, which deletes and copies
57
+ * those again. Whatever is left, the verification finds, and the swap does
58
+ * not go ahead.
44
59
  */
45
60
 
46
61
  import { EffectReceipt, receiptExpectation } from "@intentius/chant/effect-receipt";
@@ -50,6 +65,7 @@ import { clickhouseReceiptStore, receiptAddress, type ClickHouseReceiptStore } f
50
65
  import { RebuildRefusal, type RebuildObservation } from "./observe";
51
66
  import { DDL_SETTINGS } from "./replicas";
52
67
  import { sourcePartitionExpression, sourcePartitions } from "./partitions";
68
+ import { rowHash } from "./verify";
53
69
  import { cutoverOf, observe, serverNow, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
54
70
 
55
71
  export interface BackfillResult {
@@ -62,6 +78,8 @@ export interface BackfillResult {
62
78
  skipped: number;
63
79
  /** Partitions this run found partly copied and cleared before copying again. */
64
80
  cleared: number;
81
+ /** Distinct rows at or after the cut-over found in the new table too often (copied while the view was writing them), deleted and copied again. */
82
+ repaired: number;
65
83
  }
66
84
 
67
85
  /** The effect a partition's copy is, and its receipt's address suffix. */
@@ -74,7 +92,15 @@ export function partitionExpectation(o: RebuildObservation, partition: string, c
74
92
  EffectReceipt(effect, {
75
93
  effect,
76
94
  flavor: "hash",
77
- inputs: { table: o.names.key, newTable: o.newTable!.uuid, partition, copied: o.copied, cutover: cutover === undefined ? null : new Date(cutover).toISOString() },
95
+ inputs: {
96
+ table: o.names.key,
97
+ newTable: o.newTable!.uuid,
98
+ partition,
99
+ copied: o.copied,
100
+ cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
101
+ // The rows at or after the cut-over the old table held are copied too (sql-yodeler#45); a receipt from before that copied fewer.
102
+ ...(cutover === undefined ? {} : { after: "difference" }),
103
+ },
78
104
  }),
79
105
  );
80
106
  }
@@ -88,7 +114,7 @@ export interface BackfillDeps {
88
114
 
89
115
  export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promise<BackfillResult> {
90
116
  const o = await observe(run);
91
- const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0 };
117
+ const result: BackfillResult = { state: o.state, partitions: 0, copied: 0, skipped: 0, cleared: 0, repaired: 0 };
92
118
  if (o.state !== "rebuild") return result;
93
119
  if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
94
120
  const n = o.names;
@@ -113,6 +139,7 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
113
139
  const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
114
140
  const columns = o.copied.map((c) => ident(c.name)).join(", ");
115
141
  const select = o.copied.map((c) => ident(c.source)).join(", ");
142
+ const later = cutColumn && cutover !== undefined ? await laterRowsCopy(run, o, cutColumn, cutover, fromOld) : undefined;
116
143
 
117
144
  const identity = { ...(run.marker?.stack ? { stack: run.marker.stack } : {}), ...(run.marker?.env ? { env: run.marker.env } : {}) };
118
145
  const receipts =
@@ -137,17 +164,19 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
137
164
  }
138
165
 
139
166
  const queryId = `chant-rebuild-${o.newTable.uuid}-${p}`;
167
+ const laterQueryId = `${queryId}-after`;
168
+ const killWhere = `query_id IN (${sqlString(queryId)}, ${sqlString(laterQueryId)})`;
140
169
  if (o.replicated) {
141
170
  // On every replica that answers: the copy may be running on another one
142
171
  // than this run's. A replica that is down is skipped once Keeper sees it
143
172
  // inactive; nothing of its can be running a copy that still writes,
144
173
  // and a part it wrote before going down is waited for by the sync below.
145
- await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE query_id = ${sqlString(queryId)} SYNC`, {
174
+ await clickhouseQuery(run.target.endpoint, `KILL QUERY ON CLUSTER ${sqlString(n.database)} WHERE ${killWhere} SYNC`, {
146
175
  settings: DDL_SETTINGS,
147
176
  });
148
177
  await syncReplica(run, o, n.database, n.newName);
149
178
  } else {
150
- await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE query_id = ${sqlString(queryId)} SYNC`);
179
+ await clickhouseQuery(run.target.endpoint, `KILL QUERY WHERE ${killWhere} SYNC`);
151
180
  }
152
181
  const mine = `${fromOld} = ${sqlString(p)}${targetRange}`;
153
182
  const [left] = await clickhouseQuery<{ n: string | number }>(run.target.endpoint, `SELECT count() AS n FROM ${n.newTable} WHERE ${mine}`);
@@ -163,15 +192,107 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
163
192
  const sql = `INSERT INTO ${n.newTable} (${columns}) SELECT ${select} FROM ${n.table} WHERE _partition_id = ${sqlString(p)}${sourceRange}`;
164
193
  run.log(sql);
165
194
  await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
195
+ if (later) result.repaired += await copyAfterCutover(run, o, later, p, laterQueryId);
166
196
  // The receipt, last, on success only.
167
197
  await receipts.write({ name: effect, effect, flavor: "hash", inputs: {} }, expectation);
168
198
  result.copied++;
169
199
  await deps.afterPartition?.(p);
170
200
  }
171
- run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first`);
201
+ run.log(`-- backfill of ${n.key}: ${result.partitions} partition(s), ${result.copied} copied, ${result.skipped} already copied, ${result.cleared} cleared first${result.repaired > 0 ? `, ${result.repaired} row(s) after the cut-over copied twice and repaired` : ""}`);
172
202
  return result;
173
203
  }
174
204
 
205
+ /** The statements that copy one old partition's rows at or after the cut-over. */
206
+ interface LaterRows {
207
+ /**
208
+ * Per distinct row (by its hash), the old table's copies past the number
209
+ * the new table holds, into the new table.
210
+ */
211
+ copy: (partition: string) => string;
212
+ /** The hashes of the rows the new table holds more copies of than the old one. */
213
+ extras: (partition: string) => string;
214
+ /** Delete every copy of those rows from the new table. */
215
+ drop: (partition: string, hashes: readonly string[]) => string;
216
+ }
217
+
218
+ /** The most extra rows one partition's repair deletes and copies again; past that the verification decides. */
219
+ const REPAIR_LIMIT = 1000;
220
+ const REPAIR_ROUNDS = 3;
221
+
222
+ /**
223
+ * The statements for the rows at or after the cut-over. The old side casts
224
+ * each column to its type in the new table before hashing, as the
225
+ * verification does, so a row and its copy hash the same.
226
+ */
227
+ async function laterRowsCopy(
228
+ run: RebuildRun,
229
+ o: RebuildObservation,
230
+ cut: RebuildObservation["copied"][number],
231
+ cutover: number,
232
+ fromOld: string,
233
+ ): Promise<LaterRows> {
234
+ const n = o.names;
235
+ const types = new Map(
236
+ (
237
+ await clickhouseQuery<{ name: string; type: string }>(
238
+ run.target.endpoint,
239
+ `SELECT name, type FROM system.columns WHERE database = ${sqlString(n.database)} AND table = ${sqlString(n.newName)}`,
240
+ )
241
+ ).map((c) => [c.name, c.type]),
242
+ );
243
+ const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
244
+ const newHash = rowHash(o.copied.map((c) => ident(c.name)));
245
+ const columns = o.copied.map((c) => ident(c.name)).join(", ");
246
+ const aliases = o.copied.map((_, i) => `__chant_c${i}`);
247
+ const fromOldSide = o.copied.map((c, i) => `${ident(c.source)} AS ${aliases[i]}`).join(", ");
248
+ const oldRows = (p: string) => `FROM ${n.table} WHERE _partition_id = ${sqlString(p)} AND ${ident(cut.source)} >= ${utcLiteral(cutover)}`;
249
+ const newRows = (p: string) => `FROM ${n.newTable} WHERE ${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)}`;
250
+ return {
251
+ copy: (p) =>
252
+ `INSERT INTO ${n.newTable} (${columns}) SELECT ${aliases.join(", ")} FROM (` +
253
+ `SELECT ${fromOldSide}, ${oldHash} AS __chant_h, row_number() OVER (PARTITION BY __chant_h) AS __chant_k ${oldRows(p)}` +
254
+ `) AS old_rows LEFT JOIN (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p)} GROUP BY __chant_h) AS new_rows ` +
255
+ `USING (__chant_h) WHERE __chant_k > ifNull(__chant_n, 0)`,
256
+ extras: (p) =>
257
+ `SELECT toString(__chant_h) AS h FROM (SELECT ${newHash} AS __chant_h, count() AS __chant_n ${newRows(p)} GROUP BY __chant_h) AS new_rows ` +
258
+ `LEFT JOIN (SELECT ${oldHash} AS __chant_h, count() AS __chant_o ${oldRows(p)} GROUP BY __chant_h) AS old_rows ` +
259
+ `USING (__chant_h) WHERE __chant_n > ifNull(__chant_o, 0) LIMIT ${REPAIR_LIMIT + 1}`,
260
+ drop: (p, hashes) => `ALTER TABLE ${n.newTable} DELETE WHERE ${fromOld} = ${sqlString(p)} AND ${ident(cut.name)} >= ${utcLiteral(cutover)} AND ${newHash} IN (${hashes.join(", ")})`,
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Copy one old partition's rows at or after the cut-over that the new table
266
+ * lacks. A row the view had written to the old table but not yet to the new
267
+ * one when the copy read them is copied by both, and is then in the new
268
+ * table once too often: every copy of such a row is deleted from the new
269
+ * table and the difference copied again, a few rounds at most. What is left
270
+ * after that, the verification finds. Returns the rows repaired.
271
+ */
272
+ async function copyAfterCutover(run: RebuildRun, o: RebuildObservation, later: LaterRows, p: string, queryId: string): Promise<number> {
273
+ const n = o.names;
274
+ const insert = async () => {
275
+ const sql = later.copy(p);
276
+ run.log(sql);
277
+ await clickhouseQuery(run.target.endpoint, sql, { queryId, settings: { async_insert: "0", insert_deduplicate: "0" }, ...(run.signal ? { signal: run.signal } : {}) });
278
+ };
279
+ await insert();
280
+ let repaired = 0;
281
+ for (let round = 0; round < REPAIR_ROUNDS; round++) {
282
+ await syncReplica(run, o, n.database, n.newName);
283
+ const extras = (await clickhouseQuery<{ h: string }>(run.target.endpoint, later.extras(p))).map((r) => r.h);
284
+ if (extras.length === 0 || extras.length > REPAIR_LIMIT) break;
285
+ run.log(`-- partition ${p}: ${extras.length} row(s) after the cut-over in the new table more often than in the old one; deleting them and copying again`);
286
+ const sql = later.drop(p, extras);
287
+ run.log(sql);
288
+ await clickhouseQuery(run.target.endpoint, sql);
289
+ await waitOn(run, n.database, n.newName);
290
+ repaired += extras.length;
291
+ await insert();
292
+ }
293
+ return repaired;
294
+ }
295
+
175
296
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
176
297
  return new Promise((resolve, reject) => {
177
298
  const t = setTimeout(resolve, ms);
@@ -55,9 +55,10 @@ export type DualWrite =
55
55
  /**
56
56
  * A materialized view on the old table writes every row whose
57
57
  * `cutoverColumn` is at or after the cut-over into the new table; the
58
- * backfill copies the rows before it. The cut-over is the server's time
59
- * when the view is made plus `cutoverDelay` (default `1m`), and the
60
- * backfill starts once it has passed. The column must be a time
58
+ * backfill copies the rows before it, and the rows at or after it that
59
+ * the table held before the view was made. The cut-over is the server's
60
+ * time when the view is made plus `cutoverDelay` (default `1m`), rounded
61
+ * up to a whole second, and the backfill starts once it has passed. The column must be a time
61
62
  * (`Date`, `DateTime`, `DateTime64`) that rows arrive in order of, give
62
63
  * or take the delay; a row that arrives later than that is caught by
63
64
  * the verification, which then fails the run.
@@ -7,6 +7,9 @@
7
7
  * table, keeps the old table, and drops it at the second gate.
8
8
  * - A failure: rows that land in the old table after the backfill fail the
9
9
  * verification, and onFailure drops the new table.
10
+ * - Rows dated after the cut-over (sql-yodeler#45): the ones the table held
11
+ * before the view are copied by the backfill, the ones written during the
12
+ * rebuild arrive through the view, and the swap leaves none behind.
10
13
  * - An interrupted backfill: stopped after three partitions, then killed
11
14
  * between a partition's INSERT and its receipt, then resumed: the receipts
12
15
  * skip what was copied, the half-copied partition is cleared first, and the
@@ -174,7 +177,7 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
174
177
  expect(outcome(r, "RebuildState")).toBe("rebuild");
175
178
  expect(outcome(r, "VerifiedRows")).toBe(1600);
176
179
  expect(outcome(r, "VerifiedPartitions")).toBe(4);
177
- expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) before the cut-over/);
180
+ expect(String(outcome(r, "Verification"))).toMatch(/4 partition\(s\), 1600 row\(s\) \(1600 of them before the cut-over/);
178
181
  expect(await count("shop.events__chant_new")).toBe(1600);
179
182
  expect(await exists("shop", "events__chant_dual")).toBe(true);
180
183
  // Nothing visible to a plan or an apply: the working objects are left out.
@@ -235,6 +238,108 @@ describe.skipIf(!enabled)("a sorting-key change, rebuilt through its gates", ()
235
238
  }, 300_000);
236
239
  });
237
240
 
241
+ describe.skipIf(!enabled)("rows dated after the cut-over that the table already held (sql-yodeler#45)", () => {
242
+ const bookingsDdl = (orderBy: string) => `CREATE TABLE shop.bookings
243
+ (
244
+ id UInt64,
245
+ kind LowCardinality(String),
246
+ at DateTime
247
+ )
248
+ ENGINE = MergeTree
249
+ PARTITION BY toYYYYMM(at)
250
+ ORDER BY ${orderBy}`;
251
+ const V1: Obj = { export: "bookings", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: bookingsDdl("(kind, at)") };
252
+ const V2: Obj = { ...V1, ddl: bookingsDdl("(kind, id)") };
253
+ const config = (): ClickHouseRebuildOpConfig => ({
254
+ name: "rebuild-bookings",
255
+ env: "e2e",
256
+ table: "shop.bookings",
257
+ dualWrite: { mode: "materialized-view", cutoverColumn: "at", cutoverDelay: "2s" },
258
+ build: false,
259
+ path: dir,
260
+ output: "bookings-v2.json",
261
+ retain: "0s",
262
+ stack: MARKER.stack,
263
+ ownershipEnv: MARKER.env,
264
+ });
265
+
266
+ test("every row is copied and compared, those written during the rebuild too, and the swap leaves none behind", async () => {
267
+ await clickhouseApply({ buildPath: join(dir, writeBuild("bookings-v1.json", [DB, V1])), environment: "e2e" }, undefined, deps());
268
+ // 600 rows seven hours apart, from four months ago to about two months
269
+ // ahead, with an identical pair among the future ones.
270
+ await q(
271
+ "INSERT INTO shop.bookings SELECT number, ['view','cart','buy'][number % 3 + 1], toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(number * 7) FROM numbers(600)",
272
+ );
273
+ await q("INSERT INTO shop.bookings VALUES (599, 'buy', toStartOfHour(now()) - toIntervalDay(120) + toIntervalHour(599 * 7))");
274
+ const future = Number((await q<{ n: string }>("SELECT count() AS n FROM shop.bookings WHERE at > now() + 60"))[0]!.n);
275
+ expect(future).toBeGreaterThan(150);
276
+ writeBuild("bookings-v2.json", [DB, V2]);
277
+
278
+ const ledger = new Ledger();
279
+ const first = await runOp(config(), ledger.port);
280
+ expect(first.status).toBe("gated");
281
+ expect(first.gate).toMatchObject({ gate: "approve-rebuild-bookings" });
282
+ expect(outcome(first, "VerifiedRows")).toBe(601);
283
+ expect(await count("shop.bookings__chant_new")).toBe(601);
284
+
285
+ // Written during the rebuild, after the cut-over: through the view.
286
+ await q("INSERT INTO shop.bookings VALUES (1000, 'buy', now() + toIntervalDay(40)), (1001, 'cart', now())");
287
+ const again = await runOp(config(), ledger.port);
288
+ expect(again.status).toBe("gated");
289
+ expect(outcome(again, "VerifiedRows")).toBe(603);
290
+ // The rows before the cut-over are what the approval binds; later rows do not change it.
291
+ expect(again.gate!.planDigest).toBe(first.gate!.planDigest);
292
+
293
+ ledger.approveLast();
294
+ await q("INSERT INTO shop.bookings VALUES (1002, 'view', now() + toIntervalDay(70))");
295
+ const swapped = await runOp(config(), ledger.port);
296
+ expect(swapped.status).toBe("gated");
297
+ expect(swapped.gate).toMatchObject({ gate: "approve-rebuild-bookings-drop" });
298
+ expect(await showCreate("shop.bookings")).toMatch(/ORDER BY \(kind, id\)/);
299
+ expect(await count("shop.bookings")).toBe(604);
300
+ expect(await count("shop.bookings__chant_old")).toBe(604);
301
+ const diff = await q("SELECT * FROM shop.bookings__chant_old EXCEPT ALL SELECT * FROM shop.bookings");
302
+ expect(diff).toEqual([]);
303
+ }, 300_000);
304
+
305
+ test("a row that reaches the old table alone after the verification stops the swap", async () => {
306
+ const clicksDdl = (orderBy: string) => `CREATE TABLE shop.visits
307
+ (
308
+ id UInt64,
309
+ at DateTime
310
+ )
311
+ ENGINE = MergeTree
312
+ PARTITION BY toYYYYMM(at)
313
+ ORDER BY ${orderBy}`;
314
+ const W1: Obj = { export: "visits", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("at") };
315
+ const W2: Obj = { ...W1, ddl: clicksDdl("(id, at)") };
316
+ await clickhouseApply({ buildPath: join(dir, writeBuild("visits-v1.json", [DB, W1])), environment: "e2e" }, undefined, deps());
317
+ await q("INSERT INTO shop.visits SELECT number, now() - toIntervalDay(number % 90) FROM numbers(300)");
318
+ writeBuild("visits-v2.json", [DB, W2]);
319
+ const args = {
320
+ table: "shop.visits",
321
+ buildPath: "visits-v2.json",
322
+ environment: "e2e",
323
+ dualWrite: { mode: "materialized-view" as const, cutoverColumn: "at", cutoverDelay: "2s" },
324
+ stack: MARKER.stack,
325
+ ownershipEnv: MARKER.env,
326
+ cwd: dir,
327
+ };
328
+ await rebuildActivities.clickhouseRebuildCreate(args, undefined, deps());
329
+ await rebuildActivities.clickhouseRebuildDualWrite(args, undefined, deps());
330
+ await rebuildActivities.clickhouseRebuildBackfill(args, undefined, deps());
331
+ expect(await rebuildActivities.clickhouseRebuildVerify(args, undefined, deps())).toMatchObject({ rows: 300 });
332
+
333
+ // Late: a time well before the cut-over, written after the verification.
334
+ await q("INSERT INTO shop.visits VALUES (9999, now() - toIntervalDay(30))");
335
+ await expect(rebuildActivities.clickhouseRebuildSwap(args, undefined, deps())).rejects.toThrow(/does not match the old one in 1 partition/);
336
+ expect(await showCreate("shop.visits")).toMatch(/ORDER BY at/);
337
+ expect(await count("shop.visits")).toBe(301);
338
+ expect(await count("shop.visits__chant_new")).toBe(300);
339
+ await rebuildActivities.clickhouseRebuildCompensate(args, undefined, deps());
340
+ }, 300_000);
341
+ });
342
+
238
343
  describe.skipIf(!enabled)("a failure runs onFailure, which drops the new table", () => {
239
344
  const clicksDdl = (orderBy: string) => `CREATE TABLE shop.clicks (id UInt64, url String) ENGINE = MergeTree ORDER BY ${orderBy}`;
240
345
  const V1: Obj = { export: "clicks", type: "ClickHouse::Table", dependsOn: ["shop"], ddl: clicksDdl("id") };
@@ -16,6 +16,7 @@ import { createStatement, ident, qualifiedIdent, renamedDeclaration, sqlString,
16
16
  import { waitForMutations } from "../apply/mutations";
17
17
  import { renderFor } from "../topology";
18
18
  import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
19
+ import { assertSameRows } from "./verify";
19
20
  import {
20
21
  changesBetween,
21
22
  observeRebuild,
@@ -155,7 +156,8 @@ export interface DualWriteResult {
155
156
  * old table that writes every row at or after the cut-over into the new
156
157
  * table. The cut-over is the server's time now plus the delay, kept in the
157
158
  * view's own comment (`cutover=`), so a later run and the backfill read it
158
- * back from the server. App mode creates nothing; its gate is the Op's.
159
+ * back from the server. The rows at or after the cut-over that the old table
160
+ * already held are the backfill's (`./backfill.ts`). App mode creates nothing; its gate is the Op's.
159
161
  */
160
162
  export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult> {
161
163
  const o = await observe(run);
@@ -172,7 +174,10 @@ export async function startDualWrite(run: RebuildRun): Promise<DualWriteResult>
172
174
  `(copied: ${o.copied.map((c) => c.name).join(", ")})`,
173
175
  );
174
176
  }
175
- const cutover = (await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? "1m");
177
+ // On a whole second: a DateTime column holds whole seconds, and a row written
178
+ // during the cut-over's second would otherwise carry a time before it and
179
+ // reach the old table after the backfill has started.
180
+ const cutover = Math.ceil(((await serverNow(run.target)) + parseDuration(run.dualWrite.cutoverDelay ?? "1m")) / 1000) * 1000;
176
181
  const iso = new Date(cutover).toISOString();
177
182
  const select = o.copied.map((c) => (c.source === c.name ? ident(c.name) : `${ident(c.source)} AS ${ident(c.name)}`)).join(", ");
178
183
  const comment = stampedComment(`chant rebuild of ${o.names.key}: rows at or after the cut-over`, run.marker, working(o.names.key, "dual", { cutover: iso }));
@@ -205,8 +210,9 @@ export interface SwapResult {
205
210
  }
206
211
 
207
212
  /**
208
- * Swap the new table in: `EXCHANGE TABLES`, then drop the dual-write view,
209
- * rename the old table (now under the new table's name) to `t__chant_old`
213
+ * Swap the new table in: compare every row of the two tables once more, as
214
+ * the verification does, and stop on any difference; then `EXCHANGE TABLES`,
215
+ * drop the dual-write view, rename the old table (now under the new table's name) to `t__chant_old`
210
216
  * with its retention date, recreate the materialized views that read the
211
217
  * table, and stamp the table's own comment as declared.
212
218
  *
@@ -238,6 +244,10 @@ export async function swapTables(run: RebuildRun): Promise<SwapResult> {
238
244
  }
239
245
  if (!o.exchanged) {
240
246
  if (!o.newTable) throw new RebuildRefusal(`${n.key}: there is no new table to swap in`);
247
+ // The new table holds every row the old one does, or nothing is swapped
248
+ // (sql-yodeler#45): rows that reached the old table alone since the
249
+ // verification would stay behind in it.
250
+ await assertSameRows(run, o);
241
251
  await q(run, `EXCHANGE TABLES ${n.table} AND ${n.newTable}`);
242
252
  }
243
253
  // The dual-write view now reads the new table by name; drop it before
@@ -1,20 +1,36 @@
1
1
  /**
2
- * The verification (#3198): row counts and checksums per partition, the old
3
- * table against the new one, before anyone is asked to approve the swap.
2
+ * The verification (#3198, INTENTIUS/sql-yodeler#45): row counts and
3
+ * checksums per partition, the old table against the new one, before anyone
4
+ * is asked to approve the swap.
4
5
  *
5
6
  * Per partition of the old table: the number of rows and the sum of a
6
7
  * 64-bit hash of each row's copied columns, read in one grouped query on
7
8
  * each side. The old side casts each column to its type in the new table
8
9
  * first, so a column whose type the rebuild changes hashes the same when
9
- * its values survived the change. In materialized-view mode both sides are
10
- * limited to the rows before the cut-over, which are the backfill's; the
11
- * rows after it were written by the dual-write view and keep arriving.
10
+ * its values survived the change.
11
+ *
12
+ * Every row is compared, in both modes. In materialized-view mode the rows
13
+ * at or after the cut-over are in the new table twice over: those the old
14
+ * table held before the view existed, which the backfill copies, and those
15
+ * written since, which the view writes. Comparing only the rows before the
16
+ * cut-over let a swap go ahead without the first kind (sql-yodeler#45).
17
+ * Writes go on while the tables are read, and a row the old table has
18
+ * committed may be a moment from committing in the new one through the view,
19
+ * so the old table is read first and a difference is read again a few times
20
+ * before it counts.
21
+ *
22
+ * The same comparison runs again in the Swap step just before the
23
+ * `EXCHANGE`, so rows that reach the old table alone after this step (a row
24
+ * later than the cut-over delay, with a time before the cut-over) stop the
25
+ * swap too, rather than stay behind in the old table.
12
26
  *
13
27
  * Any difference fails the run, naming the partitions; the run's onFailure
14
28
  * then drops the new table. On a match the result carries the digest the
15
- * swap gate binds: the rebuild's plan and the verification together, so an
16
- * approval is for this plan with these counts, and a changed count needs a
17
- * new approval.
29
+ * swap gate binds: the rebuild's plan and the counts and checksums of the
30
+ * rows before the cut-over, which no later write changes, so an approval
31
+ * is for this plan with these counts, and a changed count needs a new
32
+ * approval. The rows at or after the cut-over keep arriving through the view
33
+ * and are compared, not bound.
18
34
  */
19
35
 
20
36
  import { computePlanDigest } from "@intentius/chant/op";
@@ -34,10 +50,13 @@ export interface VerifyResult {
34
50
  state: RebuildObservation["state"];
35
51
  /** The digest the swap gate binds: plan plus verification. Absent once the swap has run: there is nothing left for that gate to approve. */
36
52
  planDigest?: string;
53
+ /** Partitions of the old table compared. */
37
54
  partitions: number;
55
+ /** Rows compared: every row of the old table. */
38
56
  rows: number;
39
57
  /** One line for the run record and the gate. */
40
58
  summary: string;
59
+ /** Per partition, the rows before the cut-over (every row in app mode): what the swap gate binds. */
41
60
  verification: PartitionCheck[];
42
61
  }
43
62
 
@@ -45,7 +64,7 @@ export interface VerifyResult {
45
64
  export class RebuildVerificationError extends Error {
46
65
  constructor(
47
66
  readonly table: string,
48
- readonly mismatches: Array<{ partition: string; old?: PartitionCheck; new?: PartitionCheck }>,
67
+ readonly mismatches: Mismatch[],
49
68
  ) {
50
69
  const shown = mismatches
51
70
  .slice(0, 10)
@@ -53,23 +72,38 @@ export class RebuildVerificationError extends Error {
53
72
  .join("; ");
54
73
  super(
55
74
  `${table}: the new table does not match the old one in ${mismatches.length} partition(s): ${shown}${mismatches.length > 10 ? "; ..." : ""}. ` +
56
- `Nothing was swapped. In materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, is the usual cause.`,
75
+ `Nothing was swapped. In materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, is the usual cause; ` +
76
+ `in app mode, a write the application did not stop.`,
57
77
  );
58
78
  this.name = "RebuildVerificationError";
59
79
  }
60
80
  }
61
81
 
62
- export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
63
- const o = await observe(run);
64
- if (o.state !== "rebuild") return { state: o.state, partitions: 0, rows: 0, verification: [], summary: `nothing to verify: ${o.state}` };
82
+ /** How often a difference is read again before it counts, and how long apart. */
83
+ const COMPARE_ATTEMPTS = 5;
84
+ const COMPARE_INTERVAL_MS = 1000;
85
+
86
+ /** One side's counts per old partition: every row, and the rows before the cut-over. */
87
+ interface SideCounts {
88
+ all: Map<string, PartitionCheck>;
89
+ before: Map<string, PartitionCheck>;
90
+ }
91
+
92
+ /**
93
+ * Read both tables' counts and checksums per old partition, the old table
94
+ * first, until they agree or `attempts` reads have disagreed. Returns the
95
+ * last read and the partitions that differ in it.
96
+ */
97
+ async function compareTables(
98
+ run: RebuildRun,
99
+ o: RebuildObservation,
100
+ opts: { attempts?: number; intervalMs?: number } = {},
101
+ ): Promise<{ old: SideCounts; new: SideCounts; mismatches: Mismatch[] }> {
65
102
  if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
66
103
  const n = o.names;
67
104
  const cutover = cutoverOf(run, o);
68
105
  await waitOn(run, n.database, n.name);
69
106
  await waitOn(run, n.database, n.newName);
70
- // In a Replicated database: count what every replica wrote, not what this one has fetched so far.
71
- await syncReplica(run, o, n.database, n.name);
72
- await syncReplica(run, o, n.database, n.newName);
73
107
 
74
108
  const types = new Map(
75
109
  (
@@ -79,35 +113,81 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
79
113
  )
80
114
  ).map((c) => [c.name, c.type]),
81
115
  );
82
- const oldHash = `sum(cityHash64(toString(tuple(${o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`).join(", ")}))))`;
83
- const newHash = `sum(cityHash64(toString(tuple(${o.copied.map((c) => ident(c.name)).join(", ")}))))`;
116
+ const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
117
+ const newHash = rowHash(o.copied.map((c) => ident(c.name)));
84
118
  const cut = run.dualWrite.mode === "materialized-view" ? o.copied.find((c) => c.name === (run.dualWrite as { cutoverColumn: string }).cutoverColumn) : undefined;
85
- const oldWhere = cut && cutover !== undefined ? ` WHERE ${ident(cut.source)} < ${utcLiteral(cutover)}` : "";
86
- const newWhere = cut && cutover !== undefined ? ` WHERE ${ident(cut.name)} < ${utcLiteral(cutover)}` : "";
119
+ const oldBefore = cut && cutover !== undefined ? `${ident(cut.source)} < ${utcLiteral(cutover)}` : "1";
120
+ const newBefore = cut && cutover !== undefined ? `${ident(cut.name)} < ${utcLiteral(cutover)}` : "1";
87
121
  const fromOld = sourcePartitionExpression(o.live.partitionKey, o.newTable.partitionKey, o.copied);
88
122
 
89
- const read = async (sql: string) =>
90
- new Map(
91
- (await clickhouseQuery<{ p: string; rows: string | number; checksum: string }>(run.target.endpoint, sql)).map((r) => [
92
- r.p,
93
- { partition: r.p, rows: Number(r.rows), checksum: r.checksum },
94
- ]),
123
+ const read = async (table: string, partition: string, hash: string, before: string): Promise<SideCounts> => {
124
+ const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string }>(
125
+ run.target.endpoint,
126
+ `SELECT ${partition} AS p, count() AS rows, toString(sum(${hash})) AS checksum, countIf(${before}) AS brows, toString(sumIf(${hash}, ${before})) AS bchecksum ` +
127
+ `FROM ${table} GROUP BY p`,
95
128
  );
96
- const before = await read(`SELECT _partition_id AS p, count() AS rows, toString(${oldHash}) AS checksum FROM ${n.table}${oldWhere} GROUP BY p`);
97
- const after = await read(`SELECT ${fromOld} AS p, count() AS rows, toString(${newHash}) AS checksum FROM ${n.newTable}${newWhere} GROUP BY p`);
129
+ const side: SideCounts = { all: new Map(), before: new Map() };
130
+ for (const r of rows) {
131
+ side.all.set(r.p, { partition: r.p, rows: Number(r.rows), checksum: r.checksum });
132
+ // A partition with no row before the cut-over is left out, as a WHERE would leave it out.
133
+ if (Number(r.brows) > 0) side.before.set(r.p, { partition: r.p, rows: Number(r.brows), checksum: r.bchecksum });
134
+ }
135
+ return side;
136
+ };
137
+
138
+ const attempts = Math.max(1, opts.attempts ?? COMPARE_ATTEMPTS);
139
+ for (let attempt = 1; ; attempt++) {
140
+ // In a Replicated database: count what every replica wrote, not what this one has fetched so far.
141
+ await syncReplica(run, o, n.database, n.name);
142
+ await syncReplica(run, o, n.database, n.newName);
143
+ // The old table first: a row the view has yet to commit in the new one is read as missing there, never the other way round.
144
+ const before = await read(n.table, "_partition_id", oldHash, oldBefore);
145
+ const after = await read(n.newTable, fromOld, newHash, newBefore);
146
+ const mismatches = differences(before.all, after.all);
147
+ if (mismatches.length === 0 || attempt >= attempts) return { old: before, new: after, mismatches };
148
+ run.log(`-- ${n.key}: ${mismatches.length} partition(s) differ (${mismatches.map((m) => m.partition).slice(0, 5).join(", ")}); reading again (${attempt}/${attempts})`);
149
+ await sleep(opts.intervalMs ?? COMPARE_INTERVAL_MS, run.signal);
150
+ }
151
+ }
152
+
153
+ export type Mismatch = { partition: string; old?: PartitionCheck; new?: PartitionCheck };
98
154
 
99
- const mismatches: Array<{ partition: string; old?: PartitionCheck; new?: PartitionCheck }> = [];
155
+ function differences(before: Map<string, PartitionCheck>, after: Map<string, PartitionCheck>): Mismatch[] {
156
+ const out: Mismatch[] = [];
100
157
  for (const p of [...new Set([...before.keys(), ...after.keys()])].sort()) {
101
158
  const a = before.get(p);
102
159
  const b = after.get(p);
103
- if (!a || !b || a.rows !== b.rows || a.checksum !== b.checksum) mismatches.push({ partition: p, ...(a ? { old: a } : {}), ...(b ? { new: b } : {}) });
160
+ if (!a || !b || a.rows !== b.rows || a.checksum !== b.checksum) out.push({ partition: p, ...(a ? { old: a } : {}), ...(b ? { new: b } : {}) });
104
161
  }
105
- if (mismatches.length > 0) throw new RebuildVerificationError(n.key, mismatches);
162
+ return out;
163
+ }
164
+
165
+ /** The 64-bit hash of a row's copied columns, as the backfill and the verification compute it. */
166
+ export const rowHash = (columns: readonly string[]): string => `cityHash64(toString(tuple(${columns.join(", ")})))`;
167
+
168
+ /**
169
+ * Before the swap: the new table holds every row the old one does. Throws a
170
+ * {@link RebuildVerificationError} when it does not, so nothing is swapped.
171
+ */
172
+ export async function assertSameRows(run: RebuildRun, o: RebuildObservation): Promise<void> {
173
+ const { mismatches } = await compareTables(run, o);
174
+ if (mismatches.length > 0) throw new RebuildVerificationError(o.names.key, mismatches);
175
+ }
176
+
177
+ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
178
+ const o = await observe(run);
179
+ if (o.state !== "rebuild") return { state: o.state, partitions: 0, rows: 0, verification: [], summary: `nothing to verify: ${o.state}` };
180
+ const cutover = cutoverOf(run, o);
181
+ const compared = await compareTables(run, o);
182
+ if (compared.mismatches.length > 0) throw new RebuildVerificationError(o.names.key, compared.mismatches);
106
183
 
107
- const verification = [...before.values()].sort((x, y) => x.partition.localeCompare(y.partition));
108
- const rows = verification.reduce((s, v) => s + v.rows, 0);
109
- const range = cutover !== undefined ? ` before the cut-over at ${new Date(cutover).toISOString()}` : "";
110
- const summary = `${n.key}: ${verification.length} partition(s), ${rows} row(s)${range}, counts and checksums equal in the old and new tables`;
184
+ const all = [...compared.old.all.values()];
185
+ const rows = all.reduce((s, v) => s + v.rows, 0);
186
+ // What the approval binds: the rows before the cut-over (every row in app mode), which later writes do not change.
187
+ const verification = [...compared.old.before.values()].sort((x, y) => x.partition.localeCompare(y.partition));
188
+ const bound = verification.reduce((s, v) => s + v.rows, 0);
189
+ const range = cutover !== undefined ? ` (${bound} of them before the cut-over at ${new Date(cutover).toISOString()})` : "";
190
+ const summary = `${o.names.key}: ${all.length} partition(s), ${rows} row(s)${range}, counts and checksums equal in the old and new tables`;
111
191
  run.log(`-- ${summary}`);
112
192
  return {
113
193
  state: o.state,
@@ -116,9 +196,24 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
116
196
  cutover: cutover === undefined ? null : new Date(cutover).toISOString(),
117
197
  verification,
118
198
  }),
119
- partitions: verification.length,
199
+ partitions: all.length,
120
200
  rows,
121
201
  summary,
122
202
  verification,
123
203
  };
124
204
  }
205
+
206
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
207
+ return new Promise((resolve, reject) => {
208
+ signal?.throwIfAborted();
209
+ const t = setTimeout(resolve, ms);
210
+ signal?.addEventListener(
211
+ "abort",
212
+ () => {
213
+ clearTimeout(t);
214
+ reject(signal.reason);
215
+ },
216
+ { once: true },
217
+ );
218
+ });
219
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The Postgres deep reader's columns (INTENTIUS/sql-yodeler#47): matched by
3
+ * name, not by position, because Postgres keeps a table's columns in the
4
+ * order they were added and a column renamed by expand and contract is last
5
+ * on the server.
6
+ */
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import * as pg from "../entities";
10
+ import { POSTGRES_ENTITY_TYPES } from "../entity-types";
11
+ import type { LivePgObject } from "../live/catalog";
12
+ import { inDeclaredVocabulary, liveProps } from "./deep";
13
+ import { canonicalPgObject } from "./normalize";
14
+
15
+ type Props = Record<string, unknown>;
16
+
17
+ const declared = (pg.table`
18
+ CREATE TABLE shop.orders (
19
+ id bigint GENERATED ALWAYS AS IDENTITY,
20
+ email text NOT NULL, -- previously: customer_email
21
+ status text DEFAULT 'placed'::text NOT NULL,
22
+ amount numeric(12,2) NOT NULL,
23
+ refunded_at timestamp with time zone,
24
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
25
+ )` as unknown as { props: Props }).props;
26
+
27
+ /** What the deep reader makes of the server's definition of the table. */
28
+ function observe(statement: string): Props {
29
+ const o = { type: POSTGRES_ENTITY_TYPES.table, schema: "shop", name: "orders", oid: "1", statement } as unknown as LivePgObject;
30
+ const live = liveProps(o);
31
+ return inDeclaredVocabulary(declared, live, canonicalPgObject(POSTGRES_ENTITY_TYPES.table, declared), canonicalPgObject(POSTGRES_ENTITY_TYPES.table, live));
32
+ }
33
+
34
+ const names = (p: Props) => (p.columns as Array<{ name: string }>).map((c) => c.name);
35
+
36
+ describe("Postgres deep observation of a table's columns", () => {
37
+ test("a column the server holds last and the declaration earlier is not drift", () => {
38
+ const props = observe(`CREATE TABLE shop.orders (
39
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
40
+ status text DEFAULT 'placed'::text NOT NULL,
41
+ amount numeric(12,2) NOT NULL,
42
+ refunded_at timestamp with time zone,
43
+ email text NOT NULL,
44
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
45
+ )`);
46
+ expect(props.columns).toEqual(declared.columns);
47
+ });
48
+
49
+ test("a column that differs is reported at its declared place, and an undeclared one comes after the declared ones", () => {
50
+ const props = observe(`CREATE TABLE shop.orders (
51
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
52
+ note text,
53
+ status text DEFAULT 'placed'::text NOT NULL,
54
+ amount numeric(12,2) NOT NULL,
55
+ refunded_at timestamp with time zone,
56
+ email character varying(200) NOT NULL,
57
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
58
+ )`);
59
+ expect(names(props)).toEqual(["id", "email", "status", "amount", "refunded_at", "note"]);
60
+ const columns = props.columns as Props[];
61
+ const declaredColumns = declared.columns as Props[];
62
+ expect(columns[1]).not.toEqual(declaredColumns[1]);
63
+ expect(columns.slice(2, 5)).toEqual(declaredColumns.slice(2, 5));
64
+ });
65
+
66
+ test("a declared column the server does not have leaves the others matched by name", () => {
67
+ const props = observe(`CREATE TABLE shop.orders (
68
+ id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
69
+ status text DEFAULT 'placed'::text NOT NULL,
70
+ amount numeric(12,2) NOT NULL,
71
+ email text NOT NULL,
72
+ CONSTRAINT orders_pkey PRIMARY KEY (id)
73
+ )`);
74
+ expect(names(props)).toEqual(["id", "email", "status", "amount"]);
75
+ });
76
+ });
@@ -108,11 +108,22 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalP
108
108
  if (d.kind === "table") {
109
109
  const declaredColumns = (declared.columns as unknown[] | undefined) ?? [];
110
110
  const liveColumns = (live.columns as unknown[] | undefined) ?? [];
111
- out.columns = liveColumns.map((c, i) => {
111
+ // Columns are matched by name and listed in the declaration's order, the
112
+ // live columns the declaration does not name after them. Postgres keeps a
113
+ // table's columns in the order they were added, and no ALTER moves one: a
114
+ // column renamed by expand and contract is last on the server and where
115
+ // the old one was in the declaration (INTENTIUS/sql-yodeler#47). The plan
116
+ // compares columns by name too, so an order the server cannot be brought
117
+ // to is not drift.
118
+ const read = liveColumns.map((c, i) => {
112
119
  const lc = l.columns[i];
113
120
  const dc = lc ? d.columns.find((x) => x.name === lc.name) : undefined;
114
- return lc && dc && columnShape(lc) === columnShape(dc) ? declaredColumns[dc.position] : c;
121
+ return { at: dc?.position, value: lc && dc && columnShape(lc) === columnShape(dc) ? declaredColumns[dc.position] : c };
115
122
  });
123
+ out.columns = [
124
+ ...read.filter((c) => c.at !== undefined).sort((a, b) => a.at! - b.at!),
125
+ ...read.filter((c) => c.at === undefined),
126
+ ].map((c) => c.value);
116
127
  }
117
128
  if (d.kind === "table" || d.kind === "domain") {
118
129
  // A constraint the declaration says the same as the server is the declaration's (its name included or left out).
@@ -91,6 +91,32 @@ describe.skipIf(!enabled)("planning the getting-started example against a server
91
91
  expect((await planPgAgainstServer("e2e", file, profile("star"))).changes.map((c) => `${c.field}: ${c.before} -> ${c.after}`)).toEqual([]);
92
92
  }, 600_000);
93
93
 
94
+ test("a column renamed by expand and contract, last on the server, plans and reads back with no drift (sql-yodeler#47)", async () => {
95
+ const admin = await server!.connect();
96
+ await admin.query("CREATE DATABASE renamed");
97
+ await admin.end();
98
+ const orders = pg.table`CREATE TABLE public.orders (
99
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
100
+ email text NOT NULL, -- previously: customer_email
101
+ status text DEFAULT 'placed'::text NOT NULL,
102
+ refunded_at timestamp with time zone
103
+ )`;
104
+ const out = sqlSerializer.serialize(new Map<string, unknown>([["orders", orders]]) as never) as SerializerResult;
105
+ const file = join(dir, "renamed.json");
106
+ writeFileSync(file, out.primary);
107
+ const client = await server!.connect("renamed");
108
+ await client.query(
109
+ "CREATE TABLE public.orders (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_email text NOT NULL, status text DEFAULT 'placed'::text NOT NULL, refunded_at timestamp with time zone);" +
110
+ "ALTER TABLE public.orders ADD COLUMN email text; UPDATE public.orders SET email = customer_email; ALTER TABLE public.orders ALTER COLUMN email SET NOT NULL; ALTER TABLE public.orders DROP COLUMN customer_email",
111
+ );
112
+ expect((await planPgAgainstServer("e2e", file, profile("renamed"))).changes).toEqual([]);
113
+
114
+ const props = (orders as unknown as { props: Record<string, unknown> }).props;
115
+ const deep = await observeResourcesDeep({ environment: "e2e", entityNames: ["orders"], entities: new Map([["orders", { entityType: "Postgres::Table", props }]]), ...profile("renamed") });
116
+ expect(deep.resources.orders!.properties.columns).toEqual(props.columns);
117
+ await client.end();
118
+ }, 600_000);
119
+
94
120
  test("a serial table plans with no changes after apply; serial to bigserial is integer to bigint, unqualified", async () => {
95
121
  const admin = await server!.connect();
96
122
  await admin.query("CREATE DATABASE serials");