pipework 0.19.1 → 0.21.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli/commands/check.d.ts +2 -0
  3. package/dist/cli/commands/check.d.ts.map +1 -1
  4. package/dist/cli/commands/check.js +11 -6
  5. package/dist/cli/commands/check.js.map +1 -1
  6. package/dist/cli/commands/scope.d.ts.map +1 -1
  7. package/dist/cli/commands/scope.js +6 -1
  8. package/dist/cli/commands/scope.js.map +1 -1
  9. package/dist/cli/commands/test.d.ts +12 -1
  10. package/dist/cli/commands/test.d.ts.map +1 -1
  11. package/dist/cli/commands/test.js +39 -10
  12. package/dist/cli/commands/test.js.map +1 -1
  13. package/dist/core/config/load.js +48 -7
  14. package/dist/core/config/load.js.map +1 -1
  15. package/dist/core/config/namespace.d.ts +1 -0
  16. package/dist/core/config/namespace.d.ts.map +1 -1
  17. package/dist/core/config/schema.d.ts +2 -0
  18. package/dist/core/config/schema.d.ts.map +1 -1
  19. package/dist/core/config/schema.js +2 -0
  20. package/dist/core/config/schema.js.map +1 -1
  21. package/dist/data/query/sql/sql.d.ts.map +1 -1
  22. package/dist/data/query/sql/sql.js +103 -75
  23. package/dist/data/query/sql/sql.js.map +1 -1
  24. package/dist/test/fingerprint.d.ts +56 -0
  25. package/dist/test/fingerprint.d.ts.map +1 -0
  26. package/dist/test/fingerprint.js +84 -0
  27. package/dist/test/fingerprint.js.map +1 -0
  28. package/dist/test/namespace.d.ts +12 -0
  29. package/dist/test/namespace.d.ts.map +1 -1
  30. package/dist/test/namespace.js +16 -0
  31. package/dist/test/namespace.js.map +1 -1
  32. package/dist/test/reap.d.ts +3 -1
  33. package/dist/test/reap.d.ts.map +1 -1
  34. package/dist/test/reap.js +95 -2
  35. package/dist/test/reap.js.map +1 -1
  36. package/dist/test/setup.d.ts +8 -0
  37. package/dist/test/setup.d.ts.map +1 -1
  38. package/dist/test/setup.js +187 -53
  39. package/dist/test/setup.js.map +1 -1
  40. package/dist/workspace/affected.d.ts +16 -1
  41. package/dist/workspace/affected.d.ts.map +1 -1
  42. package/dist/workspace/affected.js +45 -11
  43. package/dist/workspace/affected.js.map +1 -1
  44. package/dist/workspace/changed-files.d.ts +19 -2
  45. package/dist/workspace/changed-files.d.ts.map +1 -1
  46. package/dist/workspace/changed-files.js +80 -22
  47. package/dist/workspace/changed-files.js.map +1 -1
  48. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"namespace.d.ts","sourceRoot":"","sources":["../../src/test/namespace.ts"],"names":[],"mappings":"AAqBA;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAUrG;AAED,iEAAiE;AACjE,wBAAgB,gBAAgB,IAAI,MAAM,CAUzC;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C"}
1
+ {"version":3,"file":"namespace.d.ts","sourceRoot":"","sources":["../../src/test/namespace.ts"],"names":[],"mappings":"AAqBA;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAUrG;AAED,iEAAiE;AACjE,wBAAgB,gBAAgB,IAAI,MAAM,CAUzC;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,IAAI,MAAM,CAErC;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C"}
@@ -54,6 +54,18 @@ export function testDbPrefix() {
54
54
  export function appRolePrefix() {
55
55
  return `${projectNamespace()}_app_`;
56
56
  }
57
+ /**
58
+ * Prefix for this project's shared, content-keyed templates: `<project>_tmpl_`.
59
+ *
60
+ * Templates sit in their own prefix rather than under `testDbPrefix()` because
61
+ * they have a different lifecycle and a different reaping rule: a per-run clone
62
+ * dies with its run, a template deliberately outlives every run that uses it
63
+ * and is dropped only once no run wants it. Separate prefixes keep the reaper's
64
+ * two passes from having to tell the names apart by shape. Issue #340.
65
+ */
66
+ export function templateDbPrefix() {
67
+ return `${projectNamespace()}_tmpl_`;
68
+ }
57
69
  /**
58
70
  * `LIKE` pattern matching every per-run test database of THIS project, with the
59
71
  * prefix underscores escaped so they match literally. The reaper uses it to
@@ -62,4 +74,8 @@ export function appRolePrefix() {
62
74
  export function testDbLikePattern() {
63
75
  return `${testDbPrefix().replace(/_/g, '\\_')}%`;
64
76
  }
77
+ /** `LIKE` pattern matching every shared template of THIS project. */
78
+ export function templateDbLikePattern() {
79
+ return `${templateDbPrefix().replace(/_/g, '\\_')}%`;
80
+ }
65
81
  //# sourceMappingURL=namespace.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../src/test/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEhC,gEAAgE;AAChE,EAAE;AACF,6EAA6E;AAC7E,4EAA4E;AAC5E,8EAA8E;AAC9E,2EAA2E;AAC3E,8EAA8E;AAC9E,2EAA2E;AAC3E,4EAA4E;AAC5E,EAAE;AACF,oBAAoB;AACpB,4EAA4E;AAC5E,oEAAoE;AACpE,gFAAgF;AAChF,wDAAwD;AAExD,IAAI,MAAM,GAAkB,IAAI,CAAA;AAEhC;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,WAA+B,EAAE,OAA2B;IAC3F,MAAM,GAAG,GAAG,WAAW,KAAK,SAAS,IAAI,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,WAAW;QAC9E,CAAC,CAAC,OAAO,KAAK,SAAS,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO;YAC1D,CAAC,CAAC,UAAU,CAAA;IACd,0EAA0E;IAC1E,qEAAqE;IACrE,wEAAwE;IACxE,yEAAyE;IACzE,MAAM,KAAK,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAA;IACnF,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,KAAK,EAAE,CAAA;AACpD,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,gBAAgB;IAC9B,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,MAAM,CAAA;IAClC,IAAI,OAA2B,CAAA;IAC/B,IAAI,CAAC;QACH,OAAO,GAAI,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAAuB,CAAC,IAAI,CAAA;IAC9G,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;IAC3E,CAAC;IACD,MAAM,GAAG,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC,CAAA;IAC1E,OAAO,MAAM,CAAA;AACf,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,YAAY;IAC1B,OAAO,GAAG,gBAAgB,EAAE,QAAQ,CAAA;AACtC,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,aAAa;IAC3B,OAAO,GAAG,gBAAgB,EAAE,OAAO,CAAA;AACrC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,GAAG,YAAY,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAA;AAClD,CAAC"}
1
+ {"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../src/test/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEhC,gEAAgE;AAChE,EAAE;AACF,6EAA6E;AAC7E,4EAA4E;AAC5E,8EAA8E;AAC9E,2EAA2E;AAC3E,8EAA8E;AAC9E,2EAA2E;AAC3E,4EAA4E;AAC5E,EAAE;AACF,oBAAoB;AACpB,4EAA4E;AAC5E,oEAAoE;AACpE,gFAAgF;AAChF,wDAAwD;AAExD,IAAI,MAAM,GAAkB,IAAI,CAAA;AAEhC;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,WAA+B,EAAE,OAA2B;IAC3F,MAAM,GAAG,GAAG,WAAW,KAAK,SAAS,IAAI,WAAW,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,WAAW;QAC9E,CAAC,CAAC,OAAO,KAAK,SAAS,IAAI,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO;YAC1D,CAAC,CAAC,UAAU,CAAA;IACd,0EAA0E;IAC1E,qEAAqE;IACrE,wEAAwE;IACxE,yEAAyE;IACzE,MAAM,KAAK,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAA;IACnF,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,KAAK,EAAE,CAAA;AACpD,CAAC;AAED,iEAAiE;AACjE,MAAM,UAAU,gBAAgB;IAC9B,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,MAAM,CAAA;IAClC,IAAI,OAA2B,CAAA;IAC/B,IAAI,CAAC;QACH,OAAO,GAAI,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAAuB,CAAC,IAAI,CAAA;IAC9G,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;IAC3E,CAAC;IACD,MAAM,GAAG,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,EAAE,OAAO,CAAC,CAAA;IAC1E,OAAO,MAAM,CAAA;AACf,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,YAAY;IAC1B,OAAO,GAAG,gBAAgB,EAAE,QAAQ,CAAA;AACtC,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,aAAa;IAC3B,OAAO,GAAG,gBAAgB,EAAE,OAAO,CAAA;AACrC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB;IAC9B,OAAO,GAAG,gBAAgB,EAAE,QAAQ,CAAA;AACtC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB;IAC/B,OAAO,GAAG,YAAY,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAA;AAClD,CAAC;AAED,qEAAqE;AACrE,MAAM,UAAU,qBAAqB;IACnC,OAAO,GAAG,gBAAgB,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,GAAG,CAAA;AACtD,CAAC"}
@@ -10,6 +10,8 @@ export interface ReapResult {
10
10
  * SIGKILL, reboot). The reaper reads "lock is free" as "owning run is dead".
11
11
  */
12
12
  export declare function runLockKey(nonce: string): bigint;
13
+ export declare function templateUseKey(fingerprint: string): bigint;
14
+ export declare function templateBuildKey(fingerprint: string): bigint;
13
15
  /** Admin (`/postgres`) connection URL for a database URL. */
14
16
  export declare function getAdminUrl(databaseUrl: string): string;
15
17
  /**
@@ -24,7 +26,7 @@ export declare function getAdminUrl(databaseUrl: string): string;
24
26
  * Both conditions only ever make the reaper more conservative, so it can
25
27
  * never drop a database belonging to a live or still-provisioning run.
26
28
  */
27
- export declare function reapOrphanTestDatabases(admin: postgres.Sql): Promise<ReapResult>;
29
+ export declare function reapOrphanTestDatabases(admin: postgres.Sql, liveTemplates?: ReadonlySet<string>): Promise<ReapResult>;
28
30
  /**
29
31
  * Reap orphan test databases across every Postgres server referenced by the
30
32
  * manifold's database config. Used by `pipework test --reap`.
@@ -1 +1 @@
1
- {"version":3,"file":"reap.d.ts","sourceRoot":"","sources":["../../src/test/reap.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAA;AAC/B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAUnD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7C;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAIvD;AAMD;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,GAAG,OAAO,CAAC,UAAU,CAAC,CA0BtF;AAED;;;GAGG;AACH,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,CAgBzF"}
1
+ {"version":3,"file":"reap.d.ts","sourceRoot":"","sources":["../../src/test/reap.ts"],"names":[],"mappings":"AACA,OAAO,QAAQ,MAAM,UAAU,CAAA;AAC/B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAgBnD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,gBAAgB,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7C;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AA0BD,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE5D;AAED,6DAA6D;AAC7D,wBAAgB,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAIvD;AAcD;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,KAAK,EAAE,QAAQ,CAAC,GAAG,EACnB,aAAa,GAAE,WAAW,CAAC,MAAM,CAAa,GAC7C,OAAO,CAAC,UAAU,CAAC,CA2BrB;AA4DD;;;GAGG;AACH,wBAAsB,2BAA2B,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,CAgBzF"}
package/dist/test/reap.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import postgres from 'postgres';
2
3
  import { quoteIdentifier } from '../data/db/identifiers.js';
3
- import { testDbPrefix, appRolePrefix, testDbLikePattern } from './namespace.js';
4
+ import { testDbPrefix, appRolePrefix, testDbLikePattern, templateDbPrefix, templateDbLikePattern, } from './namespace.js';
4
5
  // Per-run test database and role names end with `_<pid>_<nonce>`, where the
5
6
  // nonce is 12 random hex chars. The reaper recovers the nonce from a database
6
7
  // name to rebuild that run's advisory-lock key.
@@ -14,6 +15,35 @@ const NONCE_SUFFIX = /_([0-9a-f]{12})$/;
14
15
  export function runLockKey(nonce) {
15
16
  return BigInt('0x' + nonce);
16
17
  }
18
+ /**
19
+ * Advisory-lock keys for a shared template, derived from its content
20
+ * fingerprint. Both are salted hashes truncated to 60 bits, which keeps them
21
+ * inside a signed `bigint` and makes an accidental collision with a 48-bit run
22
+ * lock key or with each other a non-event.
23
+ *
24
+ * Two keys, because the two things being coordinated are different:
25
+ *
26
+ * - the USE key is taken in SHARE mode by every process that intends to clone
27
+ * the template, for that process's whole life, and in EXCLUSIVE mode by the
28
+ * reaper. That is what stops a reaper from dropping a template between
29
+ * another process's readiness check and its `CREATE DATABASE ... WITH
30
+ * TEMPLATE`: the reaper simply cannot get the lock while anyone holds it.
31
+ * - the BUILD key is taken in EXCLUSIVE mode only while building, so that N
32
+ * processes starting at once produce one template and one migration replay
33
+ * rather than N. It is separate because advisory locks do not upgrade from
34
+ * share to exclusive, and a builder must keep its own pin while it builds.
35
+ *
36
+ * Issue #340.
37
+ */
38
+ function templateLockKey(salt, fingerprint) {
39
+ return BigInt('0x' + createHash('sha256').update(`${salt}:${fingerprint}`).digest('hex').slice(0, 15));
40
+ }
41
+ export function templateUseKey(fingerprint) {
42
+ return templateLockKey('pipework-template-use', fingerprint);
43
+ }
44
+ export function templateBuildKey(fingerprint) {
45
+ return templateLockKey('pipework-template-build', fingerprint);
46
+ }
17
47
  /** Admin (`/postgres`) connection URL for a database URL. */
18
48
  export function getAdminUrl(databaseUrl) {
19
49
  const url = new URL(databaseUrl);
@@ -23,6 +53,12 @@ export function getAdminUrl(databaseUrl) {
23
53
  function pairedRoleName(datname) {
24
54
  return appRolePrefix() + datname.slice(testDbPrefix().length);
25
55
  }
56
+ /** A shared template's app role is content-keyed the same way the template is. */
57
+ function pairedTemplateRoleName(datname) {
58
+ return appRolePrefix() + datname.slice(templateDbPrefix().length);
59
+ }
60
+ /** Content fingerprint trailing a shared template's name: 16 lowercase hex. */
61
+ const TEMPLATE_FINGERPRINT_SUFFIX = /_([0-9a-f]{16})$/;
26
62
  /**
27
63
  * Drop test databases left behind by runs that never reached teardown
28
64
  * (Ctrl-C, timeout, crash). A database is reaped only when BOTH hold:
@@ -35,7 +71,7 @@ function pairedRoleName(datname) {
35
71
  * Both conditions only ever make the reaper more conservative, so it can
36
72
  * never drop a database belonging to a live or still-provisioning run.
37
73
  */
38
- export async function reapOrphanTestDatabases(admin) {
74
+ export async function reapOrphanTestDatabases(admin, liveTemplates = new Set()) {
39
75
  const rows = await admin `
40
76
  SELECT datname, numbackends
41
77
  FROM pg_stat_database
@@ -61,6 +97,63 @@ export async function reapOrphanTestDatabases(admin) {
61
97
  }
62
98
  }
63
99
  }
100
+ droppedDatabases.push(...(await reapStaleTemplates(admin, liveTemplates)).droppedDatabases);
101
+ return { droppedDatabases };
102
+ }
103
+ /**
104
+ * Drop shared templates nobody wants any more.
105
+ *
106
+ * A template is deliberately long-lived — it survives the run that built it, so
107
+ * the next run clones instead of replaying migrations (#340) — which means
108
+ * "left behind by a dead run" is no longer a reason to drop one. The two
109
+ * reasons that remain:
110
+ *
111
+ * - it is not in `liveTemplates`, the set of content keys the calling run
112
+ * actually intends to use. Editing a migration changes the key, so the
113
+ * template built from the old migrations is named something nobody will ask
114
+ * for again. That is what makes invalidation automatic rather than manual.
115
+ * - nobody holds its use lock, in any mode. Every process that intends to
116
+ * clone a template holds that lock in SHARE mode for its whole life, so
117
+ * failing to take it EXCLUSIVE means no live process is cloning from it —
118
+ * including no process sitting between a readiness check and its `CREATE
119
+ * DATABASE ... WITH TEMPLATE`.
120
+ *
121
+ * `numbackends = 0` is required as well, for the same conservatism as clones.
122
+ *
123
+ * An empty `liveTemplates` — what `pipework test --reap` passes — therefore
124
+ * means "drop every template not currently in use", which is the right reading
125
+ * of an explicit cleanup command.
126
+ */
127
+ async function reapStaleTemplates(admin, liveTemplates) {
128
+ const rows = await admin `
129
+ SELECT datname, numbackends
130
+ FROM pg_stat_database
131
+ WHERE datname LIKE ${templateDbLikePattern()}
132
+ `;
133
+ const droppedDatabases = [];
134
+ for (const { datname, numbackends } of rows) {
135
+ if (numbackends > 0)
136
+ continue;
137
+ if (liveTemplates.has(datname))
138
+ continue;
139
+ const fingerprint = TEMPLATE_FINGERPRINT_SUFFIX.exec(datname)?.[1];
140
+ // A name in the template namespace without a fingerprint was not written by
141
+ // this scheme. Leave it alone rather than guess at its owner.
142
+ if (fingerprint === undefined)
143
+ continue;
144
+ const key = templateUseKey(fingerprint);
145
+ if (!(await tryAcquire(admin, key)))
146
+ continue;
147
+ try {
148
+ if (await dropDatabase(admin, datname)) {
149
+ droppedDatabases.push(datname);
150
+ await dropRole(admin, pairedTemplateRoleName(datname));
151
+ }
152
+ }
153
+ finally {
154
+ await admin `SELECT pg_advisory_unlock(${key.toString()}::bigint)`;
155
+ }
156
+ }
64
157
  return { droppedDatabases };
65
158
  }
66
159
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"reap.js","sourceRoot":"","sources":["../../src/test/reap.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"reap.js","sourceRoot":"","sources":["../../src/test/reap.ts"],"names":[],"mappings":"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"}
@@ -17,4 +17,12 @@ export declare function withTemplateRetry<T>(attempt: () => Promise<T>, sleep?:
17
17
  export declare function teardownTestDatabases(): Promise<void>;
18
18
  export declare function getTestDatabase(name: string): TestDatabase | undefined;
19
19
  export declare function getTestDatabases(): ReadonlyMap<string, TestDatabase>;
20
+ /**
21
+ * Forget the in-process template cache, so the next `setupTestDatabases` takes
22
+ * the same path a freshly-forked worker takes: shared lock, readiness check,
23
+ * clone. This is how a single process can prove the cross-process claim — that
24
+ * a second fork reuses the template rather than rebuilding it — without
25
+ * actually forking. Test-only. Issue #340.
26
+ */
27
+ export declare function forgetTemplateCacheForTests(): void;
20
28
  //# sourceMappingURL=setup.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../src/test/setup.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAUnD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAA;CACjB;AAyCD,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,CAiC/F;AAuLD;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,KAAK,GAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAyD,EAC5F,QAAQ,GAAE,SAAS,MAAM,EAA6B,GACrD,OAAO,CAAC,CAAC,CAAC,CASZ;AA4ED,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAuB3D;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAEtE;AAED,wBAAgB,gBAAgB,IAAI,WAAW,CAAC,MAAM,EAAE,YAAY,CAAC,CAEpE"}
1
+ {"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../src/test/setup.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAWnD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAA;CACjB;AAoED,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC,CAqD/F;AAiSD;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,KAAK,GAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAyD,EAC5F,QAAQ,GAAE,SAAS,MAAM,EAA6B,GACrD,OAAO,CAAC,CAAC,CAAC,CASZ;AA4ED,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAuB3D;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAEtE;AAED,wBAAgB,gBAAgB,IAAI,WAAW,CAAC,MAAM,EAAE,YAAY,CAAC,CAEpE;AAED;;;;;;GAMG;AACH,wBAAgB,2BAA2B,IAAI,IAAI,CAElD"}
@@ -7,8 +7,13 @@ import { applyMigrations } from '../data/migrate/apply.js';
7
7
  import { missingQueueTables, queueTableMessage } from '../core/validation/queue-tables.js';
8
8
  import { probeRlsRole, rlsProbeTarget, rlsRoleMessage } from '../core/validation/rls-role.js';
9
9
  import { provisionFrameworkTables } from '../data/migrate/provision-framework.js';
10
- import { getAdminUrl, reapOrphanTestDatabases, runLockKey } from './reap.js';
11
- import { testDbPrefix, appRolePrefix } from './namespace.js';
10
+ import { getAdminUrl, reapOrphanTestDatabases, runLockKey, templateBuildKey, templateUseKey } from './reap.js';
11
+ import { testDbPrefix, appRolePrefix, templateDbPrefix } from './namespace.js';
12
+ import { templateFingerprint, templateInputs } from './fingerprint.js';
13
+ // Marker table written inside a template as the last act of a successful build.
14
+ // A template database that exists without this row is the wreckage of a builder
15
+ // that died mid-migration: it must be rebuilt, never cloned.
16
+ const TEMPLATE_MARKER_TABLE = 'pipework_test_template';
12
17
  const testDatabases = new Map();
13
18
  const templates = new Map();
14
19
  let adminClient = null;
@@ -19,18 +24,20 @@ const runNonce = randomBytes(6).toString('hex');
19
24
  let lockClient = null;
20
25
  let reaped = false;
21
26
  let workerShutdownArmed = false;
22
- function templateDbName(baseName) {
23
- return `${testDbPrefix()}${baseName}_tmpl_${process.pid}_${runNonce}`;
27
+ function templateDbName(baseName, fingerprint) {
28
+ return `${templateDbPrefix()}${baseName}_${fingerprint}`;
24
29
  }
25
30
  function testDbName(baseName) {
26
31
  return `${testDbPrefix()}${baseName}_${process.pid}_${runNonce}`;
27
32
  }
28
33
  // The app role is named to match what the orphan reaper derives from the
29
- // *template* database name (`reap.ts` `pairedRoleName`): the template outlives
30
- // every per-file clone, so when the reaper drops a leaked template it drops
31
- // this role with it. Without the `_tmpl_` segment the role would leak forever.
32
- function appRoleName(baseName) {
33
- return `${appRolePrefix()}${baseName}_tmpl_${process.pid}_${runNonce}`;
34
+ // *template* database name (`reap.ts` `pairedTemplateRoleName`): the template
35
+ // outlives every per-file clone and every run, so when the reaper finally drops
36
+ // a stale template it drops this role with it. The role must be content-keyed
37
+ // too — its grants are baked into the template's catalog by OID, so a template
38
+ // shared across processes needs a role shared across processes.
39
+ function appRoleName(baseName, fingerprint) {
40
+ return `${appRolePrefix()}${baseName}_${fingerprint}`;
34
41
  }
35
42
  export async function setupTestDatabases(instance) {
36
43
  const databases = [...instance.config.databases()];
@@ -50,25 +57,42 @@ export async function setupTestDatabases(instance) {
50
57
  throw new Error(`[pipework] Test bootstrap failed:\n\n ${rlsRoleMessage(name, probe)}\n`);
51
58
  }
52
59
  }
60
+ // Template identities are pure functions of config and migration content, so
61
+ // they are computed before any connection opens — the reaper needs the set of
62
+ // templates this run intends to use before it decides which templates are
63
+ // stale enough to drop.
64
+ const today = new Date();
65
+ const defaultName = instance.config.databaseNames()[0];
66
+ const plans = databases.map(([name, dbConfig]) => {
67
+ const fingerprint = templateFingerprint(templateInputs(name, dbConfig, instance.config.raw, name === defaultName, today));
68
+ return {
69
+ name,
70
+ dbConfig,
71
+ fingerprint,
72
+ templateDb: templateDbName(name, fingerprint),
73
+ appRole: appRoleName(name, fingerprint),
74
+ isDefaultDatabase: name === defaultName,
75
+ };
76
+ });
53
77
  const firstDb = databases[0];
54
78
  if (firstDb !== undefined) {
55
- await prepareRun(getAdminUrl(firstDb[1].url));
79
+ await prepareRun(getAdminUrl(firstDb[1].url), new Set(plans.map(plan => plan.templateDb)));
56
80
  }
57
- for (const [name, dbConfig] of databases) {
58
- const template = await ensureTemplate(instance, name, dbConfig);
59
- const testDb = await cloneTestDatabase(name, dbConfig, template);
60
- testDatabases.set(name, testDb);
81
+ for (const plan of plans) {
82
+ const template = await ensureTemplate(instance, plan);
83
+ const testDb = await cloneTestDatabase(plan.name, plan.dbConfig, template);
84
+ testDatabases.set(plan.name, testDb);
61
85
  }
62
86
  return testDatabases;
63
87
  }
64
88
  // Acquire this run's advisory lock and reap orphans before provisioning. The
65
89
  // lock is taken before the first CREATE DATABASE so a concurrent run's reaper
66
90
  // can never mistake this run's freshly-created, backend-less databases — the
67
- // per-process template included — for orphans. It is held on a dedicated
91
+ // per-run clones included — for orphans. It is held on a dedicated
68
92
  // connection (idle/lifetime timeouts off) so Postgres releases it only when
69
93
  // this process dies, and is never unlocked per file: the template outlives
70
94
  // every file's database, so the lock that protects it must outlive them too.
71
- async function prepareRun(adminUrl) {
95
+ async function prepareRun(adminUrl, liveTemplates) {
72
96
  if (adminClient === null) {
73
97
  adminClient = postgres(adminUrl, { max: 1, onnotice: () => { } });
74
98
  }
@@ -80,23 +104,127 @@ async function prepareRun(adminUrl) {
80
104
  if (!reaped) {
81
105
  reaped = true;
82
106
  try {
83
- await reapOrphanTestDatabases(adminClient);
107
+ await reapOrphanTestDatabases(adminClient, liveTemplates);
84
108
  }
85
109
  catch {
86
110
  // Reaping is best effort — never let stale-orphan cleanup break setup.
87
111
  }
88
112
  }
89
113
  }
90
- // Build the per-process template for one database, once. Subsequent test files
91
- // hit the cache and skip straight to cloning. Issue #321.
92
- async function ensureTemplate(instance, name, dbConfig) {
93
- const cached = templates.get(name);
114
+ /**
115
+ * Get the shared template for one database, building it only if no process has
116
+ * built it yet.
117
+ *
118
+ * The protocol, in order, and the order is the point:
119
+ *
120
+ * 1. Take a SHARED advisory lock on the template's content key, on this run's
121
+ * lifetime connection. That pins the template: the reaper drops a template
122
+ * only under an EXCLUSIVE lock, so from here until this process dies, no
123
+ * reaper can pull the template out from under a clone.
124
+ * 2. Ask whether the template is ready. Because the pin is already held, a
125
+ * `yes` cannot go stale.
126
+ * 3. If not ready, take an EXCLUSIVE lock on the separate BUILD key and ask
127
+ * again — a concurrent builder may have finished while we waited — then
128
+ * build. Two processes starting together therefore converge on one
129
+ * template, and the loser pays a wait rather than a second migration replay.
130
+ *
131
+ * There is deliberately no fallback: if the shared template cannot be built or
132
+ * validated, setup fails loudly. Quietly reverting to a private per-process
133
+ * build would re-hide exactly the cost this exists to remove. Issue #340.
134
+ */
135
+ async function ensureTemplate(instance, plan) {
136
+ const cached = templates.get(plan.name);
94
137
  if (cached !== undefined)
95
138
  return cached;
96
- const templateDb = templateDbName(name);
139
+ const useKey = templateUseKey(plan.fingerprint).toString();
140
+ const buildKey = templateBuildKey(plan.fingerprint).toString();
141
+ await lockClient `SELECT pg_advisory_lock_shared(${useKey}::bigint)`;
142
+ if (!await templateIsReady(plan)) {
143
+ await lockClient `SELECT pg_advisory_lock(${buildKey}::bigint)`;
144
+ try {
145
+ if (!await templateIsReady(plan)) {
146
+ await buildTemplate(instance, plan);
147
+ }
148
+ }
149
+ finally {
150
+ await lockClient `SELECT pg_advisory_unlock(${buildKey}::bigint)`;
151
+ }
152
+ }
153
+ // Checked on every process, not just the one that happened to build. A
154
+ // template is keyed by the migrations that made it; the QUEUES a manifold
155
+ // registers are not part of that key, so two instances can legitimately share
156
+ // one template while disagreeing about which queue tables must exist. Running
157
+ // this only inside the build would make the check depend on who got there
158
+ // first — which is to say, it would silently stop running.
159
+ await assertQueueTables(instance, plan);
160
+ const template = {
161
+ templateDb: plan.templateDb,
162
+ appRole: plan.appRole,
163
+ fingerprint: plan.fingerprint,
164
+ };
165
+ templates.set(plan.name, template);
166
+ return template;
167
+ }
168
+ // D2 loudness at test bootstrap: a registered queue whose table did not come
169
+ // out of the migrations would otherwise fail at first insert, deep in a test.
170
+ // Checked on the database queue migrations are emitted into — the sole
171
+ // database, or the declared defaultDatabase (same rule as startup validation).
172
+ async function assertQueueTables(instance, plan) {
173
+ const declaredNames = instance.config.databaseNames();
174
+ const queueDbName = declaredNames.length === 1 ? declaredNames[0] : instance.config.raw.defaultDatabase;
175
+ if (plan.name !== queueDbName)
176
+ return;
177
+ const missing = await missingQueueTables(siblingUrl(plan.dbConfig, plan.templateDb));
178
+ if (missing.length > 0) {
179
+ throw new Error(`[pipework] Test bootstrap failed:\n\n` +
180
+ missing.map(table => ` ${queueTableMessage(table, plan.name)}`).join('\n') + '\n');
181
+ }
182
+ }
183
+ /** Connection URL for a database on the same server as `dbConfig.url`. */
184
+ function siblingUrl(dbConfig, database) {
185
+ const url = new URL(dbConfig.url);
186
+ url.pathname = `/${database}`;
187
+ return url.toString();
188
+ }
189
+ /**
190
+ * Is this template complete and usable?
191
+ *
192
+ * A missing database, an unreachable one, or one without the marker row all
193
+ * answer `false` — the caller then rebuilds it under the exclusive build lock.
194
+ * That is recovery from a half-built template, not a fallback to per-process
195
+ * building: the result is still the one shared template every process uses.
196
+ */
197
+ async function templateIsReady(plan) {
198
+ const client = postgres(siblingUrl(plan.dbConfig, plan.templateDb), {
199
+ max: 1,
200
+ onnotice: () => { },
201
+ connect_timeout: 10,
202
+ });
203
+ try {
204
+ const rows = await client `
205
+ SELECT fingerprint FROM ${client(TEMPLATE_MARKER_TABLE)} LIMIT 1
206
+ `;
207
+ return rows[0]?.fingerprint === plan.fingerprint;
208
+ }
209
+ catch {
210
+ // Database absent, still being built, or missing its marker table.
211
+ return false;
212
+ }
213
+ finally {
214
+ await client.end();
215
+ }
216
+ }
217
+ // Build one shared template from scratch. Runs under the exclusive build lock,
218
+ // with this process's shared use-lock already held. Issue #321, #340.
219
+ async function buildTemplate(instance, plan) {
220
+ const { dbConfig } = plan;
221
+ const templateDb = plan.templateDb;
97
222
  const templateIdent = quoteIdentifier(templateDb, 'database');
98
223
  const admin = drizzle(adminClient);
99
- await admin.execute(sql.raw(`DROP DATABASE IF EXISTS ${templateIdent}`));
224
+ // A template database that exists at this point failed its readiness check —
225
+ // the leftovers of a builder that died. Drop and start over.
226
+ await closeTemplateClients(admin, templateDb);
227
+ await withTemplateRetry(() => admin.execute(sql.raw(`DROP DATABASE IF EXISTS ${templateIdent}`)));
100
228
  await admin.execute(sql.raw(`CREATE DATABASE ${templateIdent}`));
101
229
  // #325: a test database is dropped or rolled back — durable commits are pure
102
230
  // overhead. `synchronous_commit` is a per-database setting and does not copy
@@ -121,21 +249,6 @@ async function ensureTemplate(instance, name, dbConfig) {
121
249
  unloggedTables: true,
122
250
  });
123
251
  }
124
- // D2 loudness at test bootstrap: a registered queue whose table did not come
125
- // out of the migrations above would otherwise fail at first insert, deep in
126
- // a test. Checked on the database queue migrations are emitted into — the
127
- // sole database, or the declared defaultDatabase (same rule as startup
128
- // validation).
129
- const declaredNames = instance.config.databaseNames();
130
- const queueDbName = declaredNames.length === 1 ? declaredNames[0] : instance.config.raw.defaultDatabase;
131
- if (name === queueDbName) {
132
- const missing = await missingQueueTables(templateUrlStr);
133
- if (missing.length > 0) {
134
- await templateClient.end();
135
- throw new Error(`[pipework] Test bootstrap failed:\n\n` +
136
- missing.map(table => ` ${queueTableMessage(table, name)}`).join('\n') + '\n');
137
- }
138
- }
139
252
  // Framework-owned partitioned tables (trace family) are kept out of app
140
253
  // migrations (#313) — pipework creates them and their day partitions here so
141
254
  // a traced test never hits a missing relation or a missing partition.
@@ -144,15 +257,21 @@ async function ensureTemplate(instance, name, dbConfig) {
144
257
  errorRetentionDays: instance.config.trace.errorRetentionDays,
145
258
  auditRetentionDays: instance.config.audit.retentionDays,
146
259
  partitionLeadDays: instance.config.trace.partitionLeadDays,
147
- }, name === instance.config.databaseNames()[0]);
260
+ }, plan.isDefaultDatabase);
261
+ // The marker is written last, and its presence is what makes this template
262
+ // clonable by any other process. Everything above must have succeeded first;
263
+ // a builder that dies before this point leaves a database that every reader
264
+ // correctly refuses to clone.
265
+ await templateDbHandle.execute(sql.raw(`CREATE TABLE ${quoteIdentifier(TEMPLATE_MARKER_TABLE, 'table')} (` +
266
+ `fingerprint text PRIMARY KEY, built_at timestamptz NOT NULL DEFAULT now(), builder_pid integer NOT NULL)`));
267
+ await templateDbHandle.execute(sql `
268
+ INSERT INTO ${sql.identifier(TEMPLATE_MARKER_TABLE)} (fingerprint, builder_pid)
269
+ VALUES (${plan.fingerprint}, ${process.pid})
270
+ `);
148
271
  await templateClient.end();
149
- const appRole = appRoleName(name);
150
- await provisionTemplateRole(adminClient, templateUrlStr, appRole);
151
- const template = { templateDb, appRole };
152
- templates.set(name, template);
153
- return template;
272
+ await provisionTemplateRole(adminClient, templateUrlStr, plan.appRole);
154
273
  }
155
- // Clone one test file's database from the per-process template. A clone copies
274
+ // Clone one test file's database from the shared template. A clone copies
156
275
  // the template's tables, partitions, and in-database grants; only the cluster
157
276
  // catalog's CONNECT privilege has to be re-granted on the new database. Issue #321.
158
277
  async function cloneTestDatabase(name, dbConfig, template) {
@@ -182,12 +301,16 @@ async function cloneTestDatabase(name, dbConfig, template) {
182
301
  database: dbName,
183
302
  };
184
303
  }
185
- // Close our own straggler client connections to the template so the clone can
186
- // proceed. Two guards keep this from aborting the clone:
304
+ // Close straggler client connections to the template so the clone can proceed.
305
+ // Three guards keep this from aborting the clone — or from sabotaging a peer:
187
306
  // - `backend_type = 'client backend'` excludes background workers. An
188
307
  // autovacuum worker attaches to every database and runs as superuser, and a
189
308
  // non-superuser test role cannot signal it — targeting it would raise
190
309
  // `permission denied to terminate process` (42501) and abort the statement.
310
+ // - `state = 'idle'` excludes backends doing work. The template is now shared
311
+ // across processes (#340), so an active backend may be another run's
312
+ // builder or readiness check rather than a leak of ours; an idle one is
313
+ // doing nothing and its owner reconnects transparently.
191
314
  // - best-effort: if a client backend is still unkillable (owned by a role we
192
315
  // cannot signal), swallow it. The `CREATE` retry waits out whatever remains.
193
316
  async function closeTemplateClients(admin, templateDb) {
@@ -198,6 +321,7 @@ async function closeTemplateClients(admin, templateDb) {
198
321
  WHERE datname = ${templateDb}
199
322
  AND pid <> pg_backend_pid()
200
323
  AND backend_type = 'client backend'
324
+ AND state = 'idle'
201
325
  `);
202
326
  }
203
327
  catch {
@@ -255,9 +379,9 @@ async function provisionTemplateRole(client, templateUrl, roleName) {
255
379
  }
256
380
  // Close this run's persistent admin and lock connections. Releasing
257
381
  // `lockClient` drops the run's advisory lock — so this runs exactly once, at
258
- // worker shutdown, never per file: the lock guards the per-process template
259
- // against a sibling worker's orphan reaper, and the template outlives every
260
- // file, so the lock must outlive every file too.
382
+ // worker shutdown, never per file: these connections hold the run's advisory
383
+ // lock and this run's shared pin on every template it uses, and both must
384
+ // outlive every file in the worker.
261
385
  async function closeRunConnections() {
262
386
  if (adminClient !== null) {
263
387
  await adminClient.end();
@@ -288,9 +412,9 @@ function armWorkerShutdown() {
288
412
  });
289
413
  }
290
414
  }
291
- // Drop this file's clones. The per-process template and its app role
292
- // deliberately outlive teardown — the next file reuses the template, and the
293
- // next run's reaper eventually drops them. The run's admin and lock
415
+ // Drop this file's clones. The shared template and its app role deliberately
416
+ // outlive teardown — the next file, the next process, and the next run all
417
+ // reuse it, and a reaper drops it only once its content key goes stale (#340). The run's admin and lock
294
418
  // connections also survive: `lockClient` holds the advisory lock that protects
295
419
  // the live template from a sibling worker's reaper, so it must stay open for
296
420
  // the worker's whole life — `armWorkerShutdown` closes both once, at worker
@@ -324,4 +448,14 @@ export function getTestDatabase(name) {
324
448
  export function getTestDatabases() {
325
449
  return testDatabases;
326
450
  }
451
+ /**
452
+ * Forget the in-process template cache, so the next `setupTestDatabases` takes
453
+ * the same path a freshly-forked worker takes: shared lock, readiness check,
454
+ * clone. This is how a single process can prove the cross-process claim — that
455
+ * a second fork reuses the template rather than rebuilding it — without
456
+ * actually forking. Test-only. Issue #340.
457
+ */
458
+ export function forgetTemplateCacheForTests() {
459
+ templates.clear();
460
+ }
327
461
  //# sourceMappingURL=setup.js.map