@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.
- package/dist/clickhouse/rebuild/backfill.d.ts +17 -0
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +4 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +5 -3
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/verify.d.ts +41 -18
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/rebuild/backfill.ts +126 -5
- package/src/clickhouse/rebuild/observe.ts +4 -3
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +106 -1
- package/src/clickhouse/rebuild/steps.ts +14 -4
- package/src/clickhouse/rebuild/verify.ts +131 -36
- package/src/postgres/plan/deep.test.ts +76 -0
- package/src/postgres/plan/deep.ts +13 -2
- package/src/postgres/plan/plan.e2e.test.ts +26 -0
|
@@ -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
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAGlE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAC1E,OAAO,EAAe,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAKxD,OAAO,EAAE,KAAK,EAAwC,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAEvG,gFAAgF;AAChF,MAAM,MAAM,SAAS,GACjB;IACE
|
|
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.
|
|
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:
|
|
92
|
-
*
|
|
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":"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;
|
|
1
|
+
{"version":3,"file":"steps.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/rebuild/steps.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The verification (#3198): row counts and
|
|
3
|
-
* table against the new one, before anyone
|
|
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.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
* new
|
|
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:
|
|
40
|
-
|
|
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
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
60
|
+
"composite": "619bc77b9a85523857c04bc2fd6ab9a538a97bca5c6824aadf06c3b58a517b76"
|
|
61
61
|
}
|
package/dist/manifest.json
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
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: {
|
|
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
|
|
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
|
|
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
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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.
|
|
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
|
-
|
|
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:
|
|
209
|
-
*
|
|
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
|
|
3
|
-
* table against the new one, before anyone
|
|
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.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
* new
|
|
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:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
83
|
-
const newHash =
|
|
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
|
|
86
|
-
const
|
|
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 (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
108
|
-
const rows =
|
|
109
|
-
|
|
110
|
-
const
|
|
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:
|
|
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
|
-
|
|
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");
|