ddlforge 0.1.1 → 0.2.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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ddlforge - Background lock-queue monitor for PostgreSQL execution supervisor.
3
+ *
4
+ * During DDL execution the monitor periodically queries pg_locks and
5
+ * pg_stat_activity to detect when other backends are queuing behind the
6
+ * migration's exclusive lock. If the queue depth exceeds a configurable
7
+ * threshold the monitor cancels the migration backend via pg_cancel_backend()
8
+ * and sets the `avalanche` flag so the executor can surface a clear error.
9
+ *
10
+ * Design notes
11
+ * ────────────
12
+ * • Uses a separate Client connection so it never shares the migration
13
+ * transaction (avoids deadlock on the monitor itself).
14
+ * • All monitor queries use SHORT local timeouts so a hung Postgres cannot
15
+ * also jam the monitor.
16
+ * • The monitor resolves its returned promise with a MonitorResult when it
17
+ * self-terminates; callers must await that to detect the avalanche flag.
18
+ */
19
+ export interface MonitorOptions {
20
+ /** PID of the migration backend being guarded */
21
+ migrationPid: number;
22
+ /**
23
+ * Factory that returns a fresh client already connected to the database.
24
+ * The monitor will call end() on it when done.
25
+ */
26
+ clientFactory: () => Promise<MonitorClient>;
27
+ /** Milliseconds between each poll (default: 500 ms) */
28
+ pollIntervalMs?: number;
29
+ /**
30
+ * Number of waiting backends that triggers a cancellation
31
+ * (default: 1 – i.e. cancel as soon as even one query is blocked).
32
+ */
33
+ queueThreshold?: number;
34
+ /** AbortSignal: when aborted the monitor stops without cancelling */
35
+ signal?: AbortSignal;
36
+ }
37
+ /**
38
+ * Minimal pg Client interface required by the monitor.
39
+ * Using a structural type allows tests to inject simple mocks.
40
+ */
41
+ export interface MonitorClient {
42
+ query(sql: string, params?: unknown[]): Promise<{
43
+ rows: Record<string, unknown>[];
44
+ }>;
45
+ end(): Promise<void>;
46
+ }
47
+ export interface MonitorResult {
48
+ /** True when the monitor detected a queue avalanche and cancelled the backend */
49
+ avalanche: boolean;
50
+ /** Number of waiting backends detected at cancellation time */
51
+ blockedCount: number;
52
+ /** ISO timestamp at which cancellation was issued (empty if no cancellation) */
53
+ cancelledAt: string;
54
+ /** Reason the monitor stopped */
55
+ stopReason: 'aborted' | 'avalanche' | 'error' | 'external-stop';
56
+ }
57
+ export interface BlockedBackend {
58
+ waiterPid: number;
59
+ waiterQuery: string;
60
+ waiterState: string;
61
+ waitEventType: string;
62
+ waitEvent: string;
63
+ blockerMode: string;
64
+ }
65
+ /**
66
+ * Starts a non-blocking background lock-queue monitor.
67
+ *
68
+ * Returns a Promise<MonitorResult> that settles when the monitor self-stops
69
+ * (either via the AbortSignal, an avalanche cancellation, or an internal error).
70
+ *
71
+ * Also returns a `stop()` function that callers use to gracefully terminate
72
+ * the monitor after successful execution.
73
+ */
74
+ export declare function startLockMonitor(options: MonitorOptions): {
75
+ result: Promise<MonitorResult>;
76
+ stop: () => void;
77
+ };
78
+ /**
79
+ * Determines whether a Postgres error code represents a lock-related failure.
80
+ *
81
+ * 55P03 — lock_not_available (SET LOCAL lock_timeout exceeded)
82
+ * 57014 — query_canceled (SET LOCAL statement_timeout exceeded, or pg_cancel_backend)
83
+ */
84
+ export declare function isLockError(code: string): boolean;
85
+ //# sourceMappingURL=locksMonitor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locksMonitor.d.ts","sourceRoot":"","sources":["../../../src/runner/locksMonitor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAOH,MAAM,WAAW,cAAc;IAC7B,iDAAiD;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5C,uDAAuD;IACvD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;KAAE,CAAC,CAAC;IACrF,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,iFAAiF;IACjF,SAAS,EAAE,OAAO,CAAC;IACnB,+DAA+D;IAC/D,YAAY,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,WAAW,EAAE,MAAM,CAAC;IACpB,iCAAiC;IACjC,UAAU,EAAE,SAAS,GAAG,WAAW,GAAG,OAAO,GAAG,eAAe,CAAC;CACjE;AAqCD,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,cAAc,GAAG;IACzD,MAAM,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IAC/B,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB,CA0HA;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEjD"}
@@ -0,0 +1,183 @@
1
+ /**
2
+ * ddlforge - Background lock-queue monitor for PostgreSQL execution supervisor.
3
+ *
4
+ * During DDL execution the monitor periodically queries pg_locks and
5
+ * pg_stat_activity to detect when other backends are queuing behind the
6
+ * migration's exclusive lock. If the queue depth exceeds a configurable
7
+ * threshold the monitor cancels the migration backend via pg_cancel_backend()
8
+ * and sets the `avalanche` flag so the executor can surface a clear error.
9
+ *
10
+ * Design notes
11
+ * ────────────
12
+ * • Uses a separate Client connection so it never shares the migration
13
+ * transaction (avoids deadlock on the monitor itself).
14
+ * • All monitor queries use SHORT local timeouts so a hung Postgres cannot
15
+ * also jam the monitor.
16
+ * • The monitor resolves its returned promise with a MonitorResult when it
17
+ * self-terminates; callers must await that to detect the avalanche flag.
18
+ */
19
+ /** SQL that detects backends queuing on locks held/waited by our migration PID */
20
+ const BLOCKED_QUERY_SQL = `
21
+ SELECT COUNT(*) AS blocked_count
22
+ FROM pg_locks blocker
23
+ JOIN pg_locks waiter
24
+ ON waiter.relation = blocker.relation
25
+ AND waiter.locktype = blocker.locktype
26
+ AND waiter.pid <> blocker.pid
27
+ WHERE blocker.pid = $1
28
+ AND blocker.granted = TRUE
29
+ AND waiter.granted = FALSE
30
+ `;
31
+ /** SQL that also captures pg_stat_activity info for richer diagnostics */
32
+ const BLOCKED_QUERY_WITH_ACTIVITY_SQL = `
33
+ SELECT
34
+ waiter_act.pid AS waiter_pid,
35
+ waiter_act.query AS waiter_query,
36
+ waiter_act.state AS waiter_state,
37
+ waiter_act.wait_event_type AS wait_event_type,
38
+ waiter_act.wait_event AS wait_event,
39
+ blocker.mode AS blocker_mode
40
+ FROM pg_locks blocker
41
+ JOIN pg_locks waiter
42
+ ON waiter.relation = blocker.relation
43
+ AND waiter.locktype = blocker.locktype
44
+ AND waiter.pid <> blocker.pid
45
+ JOIN pg_stat_activity waiter_act
46
+ ON waiter_act.pid = waiter.pid
47
+ WHERE blocker.pid = $1
48
+ AND blocker.granted = TRUE
49
+ AND waiter.granted = FALSE
50
+ LIMIT 20
51
+ `;
52
+ /**
53
+ * Starts a non-blocking background lock-queue monitor.
54
+ *
55
+ * Returns a Promise<MonitorResult> that settles when the monitor self-stops
56
+ * (either via the AbortSignal, an avalanche cancellation, or an internal error).
57
+ *
58
+ * Also returns a `stop()` function that callers use to gracefully terminate
59
+ * the monitor after successful execution.
60
+ */
61
+ export function startLockMonitor(options) {
62
+ const { migrationPid, clientFactory, pollIntervalMs = 500, queueThreshold = 1, signal, } = options;
63
+ let externalStop = false;
64
+ let stopResolve;
65
+ const stopPromise = new Promise(r => { stopResolve = r; });
66
+ /**
67
+ * Abort handler — fires when AbortSignal triggers from the outside
68
+ */
69
+ function onAbort() {
70
+ stopResolve?.();
71
+ }
72
+ if (signal) {
73
+ if (signal.aborted) {
74
+ onAbort();
75
+ }
76
+ else {
77
+ signal.addEventListener('abort', onAbort, { once: true });
78
+ }
79
+ }
80
+ const result = (async () => {
81
+ let client;
82
+ try {
83
+ client = await clientFactory();
84
+ // Set short timeouts on the monitor connection itself
85
+ await client.query(`SET statement_timeout = '5000'`);
86
+ await client.query(`SET lock_timeout = '2000'`);
87
+ while (true) {
88
+ // Yield to event loop & honour stop signals
89
+ await Promise.race([
90
+ new Promise(r => setTimeout(r, pollIntervalMs)),
91
+ stopPromise,
92
+ ]);
93
+ // Check if we were asked to stop externally or via signal
94
+ if (externalStop || signal?.aborted) {
95
+ signal?.removeEventListener('abort', onAbort);
96
+ return {
97
+ avalanche: false,
98
+ blockedCount: 0,
99
+ cancelledAt: '',
100
+ stopReason: externalStop ? 'external-stop' : 'aborted',
101
+ };
102
+ }
103
+ // Query queue depth
104
+ let blockedCount = 0;
105
+ try {
106
+ const countResult = await client.query(BLOCKED_QUERY_SQL, [migrationPid]);
107
+ blockedCount = parseInt(String(countResult.rows[0]?.['blocked_count'] ?? '0'), 10);
108
+ }
109
+ catch {
110
+ // Monitor query failed — skip this cycle rather than crashing
111
+ continue;
112
+ }
113
+ if (blockedCount >= queueThreshold) {
114
+ // Gather rich diagnostics before cancellation
115
+ let blockedBackends = [];
116
+ try {
117
+ const actResult = await client.query(BLOCKED_QUERY_WITH_ACTIVITY_SQL, [migrationPid]);
118
+ blockedBackends = actResult.rows.map(row => ({
119
+ waiterPid: Number(row['waiter_pid']),
120
+ waiterQuery: String(row['waiter_query'] ?? ''),
121
+ waiterState: String(row['waiter_state'] ?? ''),
122
+ waitEventType: String(row['wait_event_type'] ?? ''),
123
+ waitEvent: String(row['wait_event'] ?? ''),
124
+ blockerMode: String(row['blocker_mode'] ?? ''),
125
+ }));
126
+ void blockedBackends; // captured for future extension / structured logging
127
+ }
128
+ catch {
129
+ // diagnostics are best-effort; proceed to cancel regardless
130
+ }
131
+ // Issue pg_cancel_backend — preferred over pg_terminate_backend
132
+ // because it gives the migration client a chance to clean up.
133
+ try {
134
+ await client.query('SELECT pg_cancel_backend($1)', [migrationPid]);
135
+ }
136
+ catch {
137
+ // If cancellation itself fails (e.g. PID already gone), swallow the error
138
+ }
139
+ const cancelledAt = new Date().toISOString();
140
+ signal?.removeEventListener('abort', onAbort);
141
+ return {
142
+ avalanche: true,
143
+ blockedCount,
144
+ cancelledAt,
145
+ stopReason: 'avalanche',
146
+ };
147
+ }
148
+ }
149
+ }
150
+ catch (err) {
151
+ signal?.removeEventListener('abort', onAbort);
152
+ return {
153
+ avalanche: false,
154
+ blockedCount: 0,
155
+ cancelledAt: '',
156
+ stopReason: 'error',
157
+ };
158
+ }
159
+ finally {
160
+ try {
161
+ await client?.end();
162
+ }
163
+ catch { /* ignore */ }
164
+ }
165
+ })();
166
+ return {
167
+ result,
168
+ stop: () => {
169
+ externalStop = true;
170
+ stopResolve?.();
171
+ },
172
+ };
173
+ }
174
+ /**
175
+ * Determines whether a Postgres error code represents a lock-related failure.
176
+ *
177
+ * 55P03 — lock_not_available (SET LOCAL lock_timeout exceeded)
178
+ * 57014 — query_canceled (SET LOCAL statement_timeout exceeded, or pg_cancel_backend)
179
+ */
180
+ export function isLockError(code) {
181
+ return code === '55P03' || code === '57014';
182
+ }
183
+ //# sourceMappingURL=locksMonitor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locksMonitor.js","sourceRoot":"","sources":["../../../src/runner/locksMonitor.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ddlforge",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Ultra-fast, zero-runtime-dependency Postgres migration lock linter and data-loss prevention engine CLI in Node.js 20+ and TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -16,8 +16,8 @@
16
16
  "scripts": {
17
17
  "build": "tsc && node -e \"const fs=require('fs'); try{fs.chmodSync('dist/bin/ddlforge.js', 0o755)}catch{}\"",
18
18
  "test:build": "tsc -p tsconfig.test.json",
19
- "test": "npm run test:build && node --test dist/test/analyzer.test.js",
20
- "test:watch": "node --test --watch dist/test/analyzer.test.js",
19
+ "test": "npm run test:build && node --test dist/test/analyzer.test.js dist/test/runner.test.js",
20
+ "test:watch": "node --test --watch dist/test/analyzer.test.js dist/test/runner.test.js",
21
21
  "prepack": "node -e \"const fs=require('fs'); try{fs.rmSync('dist/test', {recursive:true, force:true})}catch{}\"",
22
22
  "prepublishOnly": "npm run build && node -e \"const fs=require('fs'); try{fs.rmSync('dist/test', {recursive:true, force:true})}catch{}\""
23
23
  },
@@ -39,6 +39,10 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^22.13.10",
42
+ "@types/pg": "^8.23.1",
42
43
  "typescript": "^5.8.2"
44
+ },
45
+ "dependencies": {
46
+ "pg": "^8.23.0"
43
47
  }
44
48
  }