ddlforge 0.1.0 → 0.2.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.
@@ -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.0",
3
+ "version": "0.2.0",
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
+ }