jinaga-server 3.7.5 → 3.8.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/.claude/skills/degrees-of-freedom/.openskills.json +7 -0
- package/.claude/skills/degrees-of-freedom/SKILL.md +18 -0
- package/.claude/skills/degrees-of-freedom/degrees-of-freedom-constitution.md +142 -0
- package/.claude/skills/night-shift-setup/.openskills.json +7 -0
- package/.claude/skills/night-shift-setup/SKILL.md +95 -0
- package/.claude/skills/night-shift-setup/configuring-a-repository.md +111 -0
- package/.claude/skills/night-shift-setup/creating-the-log.md +59 -0
- package/.claude/skills/night-shift-setup/creating-the-routines.md +80 -0
- package/.claude/skills/night-shift-setup/log-model.factual +971 -0
- package/.claude/skills/night-shift-worker/.openskills.json +7 -0
- package/.claude/skills/night-shift-worker/SKILL.md +199 -0
- package/.claude/skills/night-shift-worker/night-shift-log.md +72 -0
- package/.claude/skills/night-shift-worker/scripts/register-stack.sh +173 -0
- package/.claude/skills/night-shift-worker/stacking.md +57 -0
- package/.claude/skills/night-shift-worker/verifying-a-merged-fix.md +16 -0
- package/.claude/skills/refining-issues/.openskills.json +7 -0
- package/.claude/skills/refining-issues/SKILL.md +327 -0
- package/.claude/skills/refining-issues/references/worked-example.md +234 -0
- package/.github/workflows/main.yml +2 -2
- package/.night-shift/config.md +84 -0
- package/.night-shift/skills.md +17 -0
- package/README.md +21 -0
- package/dist/http/router.js +19 -23
- package/dist/http/router.js.map +1 -1
- package/dist/http/stream.d.ts +0 -1
- package/dist/http/stream.js +6 -17
- package/dist/http/stream.js.map +1 -1
- package/dist/jinaga-server.js +74 -7
- package/dist/jinaga-server.js.map +1 -1
- package/dist/postgres/postgres-store.js +18 -4
- package/dist/postgres/postgres-store.js.map +1 -1
- package/dist/postgres/purge-sql.d.ts +10 -0
- package/dist/postgres/purge-sql.js +77 -50
- package/dist/postgres/purge-sql.js.map +1 -1
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +1 -1
- package/integration-test/jinaga-test/postgres-keystore-race.test.js +9 -7
- package/integration-test/jinaga-test/purge.test.js +52 -1
- package/package.json +2 -2
- package/src/http/router.ts +31 -39
- package/src/http/stream.ts +21 -36
- package/src/jinaga-server.ts +92 -4
- package/src/postgres/postgres-store.ts +19 -5
- package/src/postgres/purge-sql.ts +89 -55
- package/test/http/streamLogVolumeSpec.ts +284 -0
- package/test/jinaga-server/poolConfigurationSpec.ts +147 -0
- package/test/jinaga-server/tracePoolSpec.ts +368 -0
- package/test/postgres/purgeSqlSpec.ts +123 -22
package/src/http/router.ts
CHANGED
|
@@ -41,6 +41,13 @@ import { outputReadResultsStreaming } from "./output-formatters";
|
|
|
41
41
|
import { arrayToResultStream, ResultStream } from "./result-stream";
|
|
42
42
|
import { Stream } from "./stream";
|
|
43
43
|
|
|
44
|
+
// A streaming response reports itself twice: one line when the stream opens
|
|
45
|
+
// and one when it ends, carrying the messages sent, how long it lasted, and
|
|
46
|
+
// what ended it. Issue #192: the per-message and per-lifecycle-step lines this
|
|
47
|
+
// replaces made the log grow with traffic rather than with the number of events
|
|
48
|
+
// worth knowing about, and `STREAM DONE`'s message count already said what the
|
|
49
|
+
// per-message lines said one at a time. A plain GET says nothing at all, since
|
|
50
|
+
// Trace.dependency already records it. Failures still log.
|
|
44
51
|
function getOrStream<U>(
|
|
45
52
|
getMethod: ((req: RequestUser, params: { [key: string]: string }, query: qs.ParsedQs) => Promise<U | null>),
|
|
46
53
|
streamMethod: ((req: RequestUser, params: { [key: string]: string }, query: qs.ParsedQs) => Promise<Stream<U> | null>)
|
|
@@ -49,75 +56,68 @@ function getOrStream<U>(
|
|
|
49
56
|
const connectionId = Math.random().toString(36).substring(2, 10);
|
|
50
57
|
const user = <RequestUser>(req as any).user;
|
|
51
58
|
const accept = req.headers["accept"];
|
|
52
|
-
|
|
53
|
-
console.log(`[HttpConnection:${connectionId}] New request - User: ${user?.id || 'anonymous'}, Accept: ${accept}, URL: ${req.url}`);
|
|
54
|
-
|
|
59
|
+
|
|
55
60
|
if (accept && accept.indexOf("application/x-jinaga-feed-stream") >= 0) {
|
|
56
|
-
console.log(`[HttpConnection:${connectionId}] STREAMING request detected`);
|
|
57
|
-
|
|
58
61
|
streamMethod(user, req.params, req.query)
|
|
59
62
|
.then(response => {
|
|
60
63
|
if (!response) {
|
|
61
|
-
console.log(`[HttpConnection:${connectionId}] Stream method returned null - sending 404`);
|
|
62
64
|
sendRouteMiss(res);
|
|
63
65
|
next();
|
|
64
66
|
}
|
|
65
67
|
else {
|
|
66
|
-
console.log(`[HttpConnection:${connectionId}] Setting up streaming response`);
|
|
67
|
-
|
|
68
68
|
res.type("application/x-jinaga-feed-stream");
|
|
69
69
|
res.set("Connection", "keep-alive");
|
|
70
70
|
res.set("Cache-Control", "no-cache");
|
|
71
71
|
res.set("Access-Control-Allow-Origin", "*");
|
|
72
72
|
res.flushHeaders();
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
|
|
74
|
+
const openedAt = Date.now();
|
|
75
|
+
console.log(`[HttpConnection:${connectionId}] Stream opened - User: ${user?.id || 'anonymous'}, URL: ${req.url}`);
|
|
76
|
+
|
|
76
77
|
let clientDisconnected = false;
|
|
77
|
-
let timeoutTriggered = false;
|
|
78
78
|
let streamClosed = false;
|
|
79
|
-
|
|
79
|
+
// What closed the stream from outside, where something
|
|
80
|
+
// other than the feed itself closed it. One value
|
|
81
|
+
// rather than a flag per cause, because a stream ends
|
|
82
|
+
// exactly once and only the first cause is the reason.
|
|
83
|
+
let closedBy: "client disconnect" | "timeout" | null = null;
|
|
84
|
+
|
|
80
85
|
// Client disconnect handler
|
|
81
86
|
req.on("close", () => {
|
|
82
|
-
console.log(`[HttpConnection:${connectionId}] CLIENT DISCONNECTED`);
|
|
83
87
|
clientDisconnected = true;
|
|
84
88
|
if (!streamClosed) {
|
|
85
|
-
|
|
86
|
-
response.close();
|
|
89
|
+
closedBy = "client disconnect";
|
|
87
90
|
streamClosed = true;
|
|
91
|
+
response.close();
|
|
88
92
|
}
|
|
89
93
|
});
|
|
90
|
-
|
|
94
|
+
|
|
91
95
|
// Timeout handler
|
|
92
96
|
const timeout = setTimeout(() => {
|
|
93
|
-
console.log(`[HttpConnection:${connectionId}] TIMEOUT TRIGGERED (5 minutes)`);
|
|
94
|
-
timeoutTriggered = true;
|
|
95
97
|
if (!streamClosed) {
|
|
96
|
-
|
|
97
|
-
response.close();
|
|
98
|
+
closedBy = "timeout";
|
|
98
99
|
streamClosed = true;
|
|
100
|
+
response.close();
|
|
99
101
|
}
|
|
100
102
|
}, 5 * 60 * 1000);
|
|
101
|
-
|
|
103
|
+
|
|
102
104
|
let messageCount = 0;
|
|
103
|
-
|
|
105
|
+
|
|
104
106
|
response
|
|
105
107
|
.next(data => {
|
|
106
108
|
messageCount++;
|
|
107
109
|
const writeStart = Date.now();
|
|
108
|
-
|
|
110
|
+
|
|
109
111
|
if (clientDisconnected) {
|
|
110
112
|
console.warn(`[HttpConnection:${connectionId}] Attempted to write to disconnected client - Message #${messageCount}`);
|
|
111
113
|
return;
|
|
112
114
|
}
|
|
113
|
-
|
|
115
|
+
|
|
114
116
|
try {
|
|
115
117
|
const jsonData = JSON.stringify(data) + "\n\n";
|
|
116
118
|
res.write(jsonData);
|
|
117
119
|
const writeDuration = Date.now() - writeStart;
|
|
118
|
-
|
|
119
|
-
console.log(`[HttpConnection:${connectionId}] Message #${messageCount} sent - Size: ${jsonData.length} bytes, Duration: ${writeDuration}ms`);
|
|
120
|
-
|
|
120
|
+
|
|
121
121
|
if (writeDuration > 50) {
|
|
122
122
|
console.warn(`[HttpConnection:${connectionId}] SLOW write operation - Duration: ${writeDuration}ms`);
|
|
123
123
|
}
|
|
@@ -126,24 +126,19 @@ function getOrStream<U>(
|
|
|
126
126
|
}
|
|
127
127
|
})
|
|
128
128
|
.done(() => {
|
|
129
|
-
console.log(`[HttpConnection:${connectionId}] STREAM DONE - Messages sent: ${messageCount}, Client disconnected: ${clientDisconnected}, Timeout triggered: ${timeoutTriggered}`);
|
|
130
|
-
|
|
131
129
|
clearTimeout(timeout);
|
|
132
130
|
streamClosed = true;
|
|
133
|
-
|
|
131
|
+
|
|
132
|
+
console.log(`[HttpConnection:${connectionId}] Stream ended - Messages sent: ${messageCount}, Duration: ${Date.now() - openedAt}ms, Reason: ${closedBy ?? "done"}`);
|
|
133
|
+
|
|
134
134
|
try {
|
|
135
135
|
if (!clientDisconnected) {
|
|
136
|
-
console.log(`[HttpConnection:${connectionId}] Ending socket connection`);
|
|
137
136
|
res.socket?.end();
|
|
138
|
-
} else {
|
|
139
|
-
console.log(`[HttpConnection:${connectionId}] Client already disconnected - skipping socket end`);
|
|
140
137
|
}
|
|
141
138
|
} catch (error) {
|
|
142
139
|
console.error(`[HttpConnection:${connectionId}] ERROR ending socket: ${error}`);
|
|
143
140
|
}
|
|
144
141
|
});
|
|
145
|
-
|
|
146
|
-
console.log(`[HttpConnection:${connectionId}] Stream handlers configured`);
|
|
147
142
|
}
|
|
148
143
|
})
|
|
149
144
|
.catch(error => {
|
|
@@ -152,16 +147,13 @@ function getOrStream<U>(
|
|
|
152
147
|
});
|
|
153
148
|
}
|
|
154
149
|
else {
|
|
155
|
-
console.log(`[HttpConnection:${connectionId}] Regular GET request`);
|
|
156
150
|
getMethod(user, req.params, req.query)
|
|
157
151
|
.then(response => {
|
|
158
152
|
if (!response) {
|
|
159
|
-
console.log(`[HttpConnection:${connectionId}] Get method returned null - sending 404`);
|
|
160
153
|
sendRouteMiss(res);
|
|
161
154
|
next();
|
|
162
155
|
}
|
|
163
156
|
else {
|
|
164
|
-
console.log(`[HttpConnection:${connectionId}] Sending JSON response`);
|
|
165
157
|
res.type("json");
|
|
166
158
|
res.send(JSON.stringify(response));
|
|
167
159
|
next();
|
package/src/http/stream.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
// This class logs nothing on its success path. A stream's lifetime is reported
|
|
2
|
+
// once by the request that opened it (see getOrStream in router.ts), and a line
|
|
3
|
+
// per feed, per replayed item, or per done handler says the same thing again
|
|
4
|
+
// once per unit of traffic. Issue #192: those per-item lines were 11.9% of a
|
|
5
|
+
// 2.8-million-line production log. Failures still log, because a failed handler
|
|
6
|
+
// is an event an operator cannot reconstruct from the summary.
|
|
1
7
|
export class Stream<T> {
|
|
2
8
|
private queue: T[] = [];
|
|
3
9
|
private handlers: ((data: T) => void)[] = [];
|
|
4
10
|
private doneHandlers: (() => void)[] = [];
|
|
5
11
|
private closed = false;
|
|
6
12
|
private readonly streamId: string;
|
|
7
|
-
private feedCount = 0;
|
|
8
13
|
|
|
9
14
|
constructor() {
|
|
10
15
|
this.streamId = Math.random().toString(36).substring(2, 10);
|
|
11
|
-
console.log(`[Stream:${this.streamId}] Created new stream`);
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
next(handler: (data: T) => void): Stream<T> {
|
|
@@ -16,21 +20,18 @@ export class Stream<T> {
|
|
|
16
20
|
console.warn(`[Stream:${this.streamId}] Attempted to add handler to closed stream`);
|
|
17
21
|
return this;
|
|
18
22
|
}
|
|
19
|
-
|
|
23
|
+
|
|
20
24
|
this.handlers.push(handler);
|
|
21
|
-
|
|
22
|
-
console.log(`[Stream:${this.streamId}] Added handler - Total handlers: ${this.handlers.length}, Queued items: ${queueLength}`);
|
|
23
|
-
|
|
25
|
+
|
|
24
26
|
// Replay queued data to new handler
|
|
25
27
|
this.queue.forEach((data, index) => {
|
|
26
28
|
try {
|
|
27
29
|
handler(data);
|
|
28
|
-
console.log(`[Stream:${this.streamId}] Replayed queued item ${index + 1}/${queueLength} to new handler`);
|
|
29
30
|
} catch (error) {
|
|
30
31
|
console.error(`[Stream:${this.streamId}] Error replaying queued item ${index + 1}: ${error}`);
|
|
31
32
|
}
|
|
32
33
|
});
|
|
33
|
-
|
|
34
|
+
|
|
34
35
|
return this;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -44,9 +45,8 @@ export class Stream<T> {
|
|
|
44
45
|
}
|
|
45
46
|
return this;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
|
|
48
49
|
this.doneHandlers.push(handler);
|
|
49
|
-
console.log(`[Stream:${this.streamId}] Added done handler - Total done handlers: ${this.doneHandlers.length}`);
|
|
50
50
|
return this;
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -55,70 +55,55 @@ export class Stream<T> {
|
|
|
55
55
|
console.warn(`[Stream:${this.streamId}] Attempted to close already closed stream`);
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
console.log(`[Stream:${this.streamId}] CLOSING stream - Handlers: ${this.handlers.length}, Done handlers: ${this.doneHandlers.length}, Queue: ${this.queue.length}, Feed count: ${this.feedCount}`);
|
|
60
|
-
|
|
58
|
+
|
|
61
59
|
this.closed = true;
|
|
62
|
-
|
|
60
|
+
|
|
63
61
|
// Execute done handlers
|
|
64
|
-
const doneHandlerCount = this.doneHandlers.length;
|
|
65
62
|
this.doneHandlers.forEach((handler, index) => {
|
|
66
63
|
try {
|
|
67
64
|
handler();
|
|
68
|
-
console.log(`[Stream:${this.streamId}] Executed done handler ${index + 1}/${doneHandlerCount}`);
|
|
69
65
|
} catch (error) {
|
|
70
66
|
console.error(`[Stream:${this.streamId}] Error in done handler ${index + 1}: ${error}`);
|
|
71
67
|
}
|
|
72
68
|
});
|
|
73
|
-
|
|
69
|
+
|
|
74
70
|
// Clear all references
|
|
75
71
|
this.queue = [];
|
|
76
72
|
this.handlers = [];
|
|
77
73
|
this.doneHandlers = [];
|
|
78
|
-
|
|
79
|
-
console.log(`[Stream:${this.streamId}] Stream closed and cleaned up`);
|
|
80
74
|
}
|
|
81
75
|
|
|
82
76
|
feed(data: any): void {
|
|
83
77
|
const feedStart = Date.now();
|
|
84
|
-
|
|
78
|
+
|
|
85
79
|
if (this.closed) {
|
|
86
80
|
console.warn(`[Stream:${this.streamId}] Attempted to feed data to closed stream - Data type: ${typeof data}`);
|
|
87
81
|
return;
|
|
88
82
|
}
|
|
89
|
-
|
|
90
|
-
this.feedCount++;
|
|
83
|
+
|
|
91
84
|
const handlerCount = this.handlers.length;
|
|
92
|
-
|
|
93
|
-
console.log(`[Stream:${this.streamId}] FEEDING data - Feed #${this.feedCount}, Handlers: ${handlerCount}, Queue size before: ${this.queue.length}`);
|
|
94
|
-
|
|
85
|
+
|
|
95
86
|
this.queue.push(data);
|
|
96
|
-
|
|
87
|
+
|
|
97
88
|
// Notify all handlers
|
|
98
|
-
let successCount = 0;
|
|
99
|
-
let errorCount = 0;
|
|
100
|
-
|
|
101
89
|
this.handlers.forEach((handler, index) => {
|
|
102
90
|
try {
|
|
103
91
|
const handlerStart = Date.now();
|
|
104
92
|
handler(data);
|
|
105
93
|
const handlerDuration = Date.now() - handlerStart;
|
|
106
|
-
|
|
107
|
-
|
|
94
|
+
|
|
108
95
|
if (handlerDuration > 50) {
|
|
109
96
|
console.warn(`[Stream:${this.streamId}] SLOW handler - Handler ${index + 1}/${handlerCount}, Duration: ${handlerDuration}ms`);
|
|
110
97
|
}
|
|
111
98
|
} catch (error) {
|
|
112
|
-
errorCount++;
|
|
113
99
|
console.error(`[Stream:${this.streamId}] ERROR in handler ${index + 1}/${handlerCount}: ${error}`);
|
|
114
100
|
}
|
|
115
101
|
});
|
|
116
|
-
|
|
102
|
+
|
|
117
103
|
const totalDuration = Date.now() - feedStart;
|
|
118
|
-
|
|
119
|
-
|
|
104
|
+
|
|
120
105
|
if (totalDuration > 100) {
|
|
121
106
|
console.warn(`[Stream:${this.streamId}] SLOW feed operation - Duration: ${totalDuration}ms`);
|
|
122
107
|
}
|
|
123
108
|
}
|
|
124
|
-
}
|
|
109
|
+
}
|
package/src/jinaga-server.ts
CHANGED
|
@@ -253,22 +253,82 @@ function getPool(uriOrPool: string | Pool, pools: { [uri: string]: Pool; }): Poo
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
type PoolSettingDeclaration = {
|
|
257
|
+
variable: string,
|
|
258
|
+
defaultValue: number,
|
|
259
|
+
minimum: number
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// The settings of a pool that this server builds for itself, each named by the
|
|
263
|
+
// environment variable that sets it. A pool the caller supplies is used as
|
|
264
|
+
// given, so none of these applies to it.
|
|
265
|
+
const poolSettingDeclarations = {
|
|
266
|
+
// A pool of zero connections can serve nothing, so one is the minimum.
|
|
267
|
+
max: { variable: "POSTGRES_POOL_MAX", defaultValue: 10, minimum: 1 },
|
|
268
|
+
idleTimeoutMillis: { variable: "POSTGRES_IDLE_TIMEOUT_MILLIS", defaultValue: 30000, minimum: 0 },
|
|
269
|
+
// Zero means wait forever for a connection to come free.
|
|
270
|
+
connectionTimeoutMillis: { variable: "POSTGRES_CONNECTION_TIMEOUT_MILLIS", defaultValue: 0, minimum: 0 }
|
|
271
|
+
} satisfies { [setting: string]: PoolSettingDeclaration };
|
|
272
|
+
|
|
273
|
+
type PoolSettings = {
|
|
274
|
+
[setting in keyof typeof poolSettingDeclarations]: number
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// The one rule by which every pool setting is read from the environment. An
|
|
278
|
+
// absent or empty variable takes the default; anything that is not an integer
|
|
279
|
+
// at or above the setting's minimum is refused rather than resolved, so the
|
|
280
|
+
// environment either names a pool that can be built or no pool at all.
|
|
281
|
+
function readPoolSetting({ variable, defaultValue, minimum }: PoolSettingDeclaration): number {
|
|
282
|
+
const value = process.env[variable];
|
|
283
|
+
if (value === undefined || value.trim() === '') {
|
|
284
|
+
return defaultValue;
|
|
285
|
+
}
|
|
286
|
+
const parsed = /^\d+$/.test(value.trim()) ? parseInt(value.trim(), 10) : NaN;
|
|
287
|
+
if (isNaN(parsed) || parsed < minimum) {
|
|
288
|
+
throw new Error(`The environment variable ${variable} is set to ${JSON.stringify(value)}, which is not an integer of at least ${minimum}.`);
|
|
289
|
+
}
|
|
290
|
+
return parsed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function readPoolSettings(): PoolSettings {
|
|
294
|
+
return {
|
|
295
|
+
max: readPoolSetting(poolSettingDeclarations.max),
|
|
296
|
+
idleTimeoutMillis: readPoolSetting(poolSettingDeclarations.idleTimeoutMillis),
|
|
297
|
+
connectionTimeoutMillis: readPoolSetting(poolSettingDeclarations.connectionTimeoutMillis)
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
256
301
|
function createPool(postgresUri: string): Pool {
|
|
302
|
+
const settings = readPoolSettings();
|
|
257
303
|
const postgresPool = new Pool({
|
|
258
304
|
connectionString: postgresUri,
|
|
259
|
-
|
|
260
|
-
parseInt(process.env.POSTGRES_IDLE_TIMEOUT_MILLIS) :
|
|
261
|
-
30000,
|
|
305
|
+
...settings
|
|
262
306
|
});
|
|
263
307
|
|
|
308
|
+
Trace.info(`Postgres pool created with max ${settings.max}, idleTimeoutMillis ${settings.idleTimeoutMillis}, connectionTimeoutMillis ${settings.connectionTimeoutMillis}`);
|
|
309
|
+
|
|
264
310
|
tracePool(postgresPool);
|
|
265
311
|
|
|
266
312
|
return postgresPool;
|
|
267
313
|
}
|
|
268
314
|
|
|
315
|
+
const poolWindowMilliseconds = 10000;
|
|
316
|
+
|
|
269
317
|
export function tracePool(postgresPool: Pool) {
|
|
318
|
+
// Pool pressure is reported once per window of time rather than once per
|
|
319
|
+
// checkout, so the output rate no longer follows the acquire rate and no
|
|
320
|
+
// longer peaks when the pool is saturated. The window carries the largest
|
|
321
|
+
// waiting count it saw, so a spike that comes and goes inside one window
|
|
322
|
+
// still appears in the log.
|
|
323
|
+
let acquiredInWindow = false;
|
|
324
|
+
let peakWaiting = 0;
|
|
325
|
+
|
|
270
326
|
postgresPool.on('acquire', (client) => {
|
|
271
|
-
|
|
327
|
+
acquiredInWindow = true;
|
|
328
|
+
observeWaiting();
|
|
329
|
+
});
|
|
330
|
+
postgresPool.on('release', (err, client) => {
|
|
331
|
+
observeWaiting();
|
|
272
332
|
});
|
|
273
333
|
postgresPool.on('connect', (client) => {
|
|
274
334
|
Trace.metric('Postgres connected', poolMetrics());
|
|
@@ -280,6 +340,34 @@ export function tracePool(postgresPool: Pool) {
|
|
|
280
340
|
Trace.error(err);
|
|
281
341
|
});
|
|
282
342
|
|
|
343
|
+
// The window closes on a timer so that a storm which stops in the middle
|
|
344
|
+
// of a window still reports its peak. The timer is unreferenced, so it
|
|
345
|
+
// does not hold the process open, and it stops once the pool has ended.
|
|
346
|
+
const windowTimer = setInterval(closeWindow, poolWindowMilliseconds);
|
|
347
|
+
windowTimer.unref?.();
|
|
348
|
+
|
|
349
|
+
function observeWaiting() {
|
|
350
|
+
if (postgresPool.waitingCount > peakWaiting) {
|
|
351
|
+
peakWaiting = postgresPool.waitingCount;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function closeWindow() {
|
|
356
|
+
if (postgresPool.ended) {
|
|
357
|
+
clearInterval(windowTimer);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (acquiredInWindow) {
|
|
361
|
+
const metrics = poolMetrics();
|
|
362
|
+
Trace.metric('Postgres pool', { ...metrics, peakWaiting });
|
|
363
|
+
if (peakWaiting > 0) {
|
|
364
|
+
Trace.warn(`Postgres pool saturated: ${peakWaiting} waiting at peak, ${metrics.waiting} waiting of ${metrics.total} at window close.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
acquiredInWindow = false;
|
|
368
|
+
peakWaiting = 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
283
371
|
function poolMetrics() {
|
|
284
372
|
return {
|
|
285
373
|
total: postgresPool.totalCount,
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
} from "./maps";
|
|
45
45
|
import { ResultSetFact, ResultSetRow, ResultSetTree, resultSqlFromSpecification, SqlQueryTree } from "./specification-result-sql";
|
|
46
46
|
import { sqlFromFeed } from "./specification-sql";
|
|
47
|
-
import { purgeDescendantsSql,
|
|
47
|
+
import { purgeDescendantsSql, purgeSqlFromSpecifications } from "./purge-sql";
|
|
48
48
|
|
|
49
49
|
interface PostgresFactRecord extends FactRecord {
|
|
50
50
|
timestamp: Date; // Maps to date_learned column
|
|
@@ -451,12 +451,26 @@ export class PostgresStore implements Storage {
|
|
|
451
451
|
}
|
|
452
452
|
|
|
453
453
|
async purge(purgeConditions: Specification[]): Promise<number> {
|
|
454
|
-
|
|
454
|
+
// Purge all conditions on one root type in a single statement,
|
|
455
|
+
// so that no condition deletes the triggers of another.
|
|
456
|
+
const conditionsByGivenType = new Map<string, Specification[]>();
|
|
455
457
|
for (const specification of purgeConditions) {
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
+
const givenType = specification.given[0].label.type;
|
|
459
|
+
const group = conditionsByGivenType.get(givenType) ?? [];
|
|
460
|
+
group.push(specification);
|
|
461
|
+
conditionsByGivenType.set(givenType, group);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
let count = 0;
|
|
465
|
+
for (const specifications of conditionsByGivenType.values()) {
|
|
466
|
+
let factTypes = this.factTypeMap;
|
|
467
|
+
let roleMap = this.roleMap;
|
|
468
|
+
for (const specification of specifications) {
|
|
469
|
+
factTypes = await this.loadFactTypesFromSpecification(specification);
|
|
470
|
+
roleMap = await this.loadRolesFromSpecification(specification, factTypes);
|
|
471
|
+
}
|
|
458
472
|
|
|
459
|
-
const purgeCommand =
|
|
473
|
+
const purgeCommand = purgeSqlFromSpecifications(specifications, factTypes, roleMap, this.schema);
|
|
460
474
|
if (!purgeCommand) {
|
|
461
475
|
continue;
|
|
462
476
|
}
|
|
@@ -43,6 +43,29 @@ export function purgeDescendantsSql(triggerCount: number, schema: string): strin
|
|
|
43
43
|
|
|
44
44
|
export function purgeSqlFromSpecification(specification: Specification, factTypes: FactTypeMap, roleMap: RoleMap, schema: string):
|
|
45
45
|
{ sql: string, parameters: (string | number)[] } | null {
|
|
46
|
+
return purgeSqlFromSpecifications([specification], factTypes, roleMap, schema);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Generate one statement that purges under every given purge condition at once.
|
|
51
|
+
* The conditions must share a given type. Every trigger of every condition,
|
|
52
|
+
* and every ancestor of those triggers, is spared. Running the conditions one
|
|
53
|
+
* statement at a time would let one condition delete another's triggers.
|
|
54
|
+
*/
|
|
55
|
+
export function purgeSqlFromSpecifications(specifications: Specification[], factTypes: FactTypeMap, roleMap: RoleMap, schema: string):
|
|
56
|
+
{ sql: string, parameters: (string | number)[] } | null {
|
|
57
|
+
const queryDescriptions = specifications
|
|
58
|
+
.map(specification => purgeQueryDescription(specification, factTypes, roleMap))
|
|
59
|
+
.filter((queryDescription): queryDescription is QueryDescription => queryDescription !== null);
|
|
60
|
+
|
|
61
|
+
if (queryDescriptions.length === 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return generatePurgeSqlQuery(queryDescriptions, schema);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function purgeQueryDescription(specification: Specification, factTypes: FactTypeMap, roleMap: RoleMap): QueryDescription | null {
|
|
46
69
|
const queryDescriptionBuilder = new QueryDescriptionBuilder(factTypes, roleMap);
|
|
47
70
|
|
|
48
71
|
let queryDescription = QueryDescription.unsatisfiable;
|
|
@@ -71,61 +94,56 @@ export function purgeSqlFromSpecification(specification: Specification, factType
|
|
|
71
94
|
return null;
|
|
72
95
|
}
|
|
73
96
|
|
|
74
|
-
const query = generatePurgeSqlQuery(queryDescription, schema);
|
|
75
|
-
return query;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
function generatePurgeSqlQuery(queryDescription: QueryDescription, schema: string):
|
|
80
|
-
{ sql: string, parameters: (string | number)[] } {
|
|
81
97
|
if (queryDescription.existentialConditions.length > 0) {
|
|
82
98
|
throw new Error("Purge conditions should not have existential conditions");
|
|
83
99
|
}
|
|
84
100
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
`
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
110
|
-
.join("");
|
|
101
|
+
return queryDescription;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function generatePurgeSqlQuery(queryDescriptions: QueryDescription[], schema: string):
|
|
105
|
+
{ sql: string, parameters: (string | number)[] } {
|
|
106
|
+
const parameters: (string | number)[] = [];
|
|
107
|
+
const candidateCtes: string[] = [];
|
|
108
|
+
const rootSelects: string[] = [];
|
|
109
|
+
const triggerSelects: string[] = [];
|
|
110
|
+
|
|
111
|
+
queryDescriptions.forEach((queryDescription, conditionIndex) => {
|
|
112
|
+
const name = `candidates${conditionIndex + 1}`;
|
|
113
|
+
const offset = parameters.length;
|
|
114
|
+
candidateCtes.push(`${name} AS (\n${generateCandidates(queryDescription, offset, schema)})`);
|
|
115
|
+
// Remove parameter 2, which is the hash of the first fact
|
|
116
|
+
parameters.push(
|
|
117
|
+
queryDescription.parameters[0],
|
|
118
|
+
...queryDescription.parameters.slice(2)
|
|
119
|
+
);
|
|
120
|
+
rootSelects.push(` SELECT purge_root FROM ${name}\n`);
|
|
121
|
+
queryDescription.outputs.forEach((label, index) => {
|
|
122
|
+
triggerSelects.push(` SELECT trigger${index + 1} AS fact_id FROM ${name}\n`);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
111
125
|
|
|
112
126
|
const sql =
|
|
113
|
-
`WITH
|
|
114
|
-
`
|
|
115
|
-
`
|
|
116
|
-
`
|
|
117
|
-
` FROM ${schema}.fact f${firstFactIndex}\n${joins.join("")}` +
|
|
118
|
-
` WHERE ${inputWhereClauses}\n` +
|
|
127
|
+
`WITH ${candidateCtes.join(", ")}, roots AS (\n` +
|
|
128
|
+
rootSelects.join(` UNION\n`) +
|
|
129
|
+
`), triggers AS (\n` +
|
|
130
|
+
triggerSelects.join(` UNION\n`) +
|
|
119
131
|
`), targets AS (\n` +
|
|
120
132
|
` SELECT a.fact_id\n` +
|
|
121
133
|
` FROM ${schema}.ancestor a\n` +
|
|
122
|
-
` JOIN
|
|
134
|
+
` JOIN roots r ON r.purge_root = a.ancestor_fact_id\n` +
|
|
123
135
|
` WHERE NOT EXISTS (\n` +
|
|
124
136
|
` SELECT 1\n` +
|
|
125
|
-
` FROM
|
|
126
|
-
` WHERE
|
|
137
|
+
` FROM triggers t\n` +
|
|
138
|
+
` WHERE a.fact_id = t.fact_id\n` +
|
|
139
|
+
` )\n` +
|
|
140
|
+
` AND NOT EXISTS (\n` +
|
|
141
|
+
` SELECT 1\n` +
|
|
142
|
+
` FROM triggers t\n` +
|
|
143
|
+
` JOIN ${schema}.ancestor a2\n` +
|
|
144
|
+
` ON a2.fact_id = t.fact_id\n` +
|
|
145
|
+
` WHERE a.fact_id = a2.ancestor_fact_id\n` +
|
|
127
146
|
` )\n` +
|
|
128
|
-
triggerAncestorClauses +
|
|
129
147
|
`), facts AS (\n` +
|
|
130
148
|
` DELETE\n` +
|
|
131
149
|
` FROM ${schema}.fact f\n` +
|
|
@@ -133,21 +151,37 @@ function generatePurgeSqlQuery(queryDescription: QueryDescription, schema: strin
|
|
|
133
151
|
` RETURNING f.fact_id\n` +
|
|
134
152
|
`)\n` +
|
|
135
153
|
`SELECT fact_id FROM facts\n`;
|
|
136
|
-
|
|
137
|
-
// Remove parameter 1, which is the hash of the first fact
|
|
138
|
-
const parameters = [
|
|
139
|
-
queryDescription.parameters[0],
|
|
140
|
-
...queryDescription.parameters.slice(2)
|
|
141
|
-
];
|
|
142
|
-
|
|
143
154
|
|
|
144
155
|
return {
|
|
145
156
|
sql,
|
|
146
|
-
parameters
|
|
157
|
+
parameters
|
|
147
158
|
};
|
|
148
159
|
}
|
|
149
160
|
|
|
150
|
-
function
|
|
161
|
+
function generateCandidates(queryDescription: QueryDescription, offset: number, schema: string): string {
|
|
162
|
+
const columns = queryDescription.outputs
|
|
163
|
+
.map((label, index) => `f${label.factIndex}.fact_id as trigger${index + 1}`)
|
|
164
|
+
.join(",\n ");
|
|
165
|
+
const firstEdge = queryDescription.edges[0];
|
|
166
|
+
const predecessorFact = queryDescription.inputs.find(i => i.factIndex === firstEdge.predecessorFactIndex);
|
|
167
|
+
const successorFact = queryDescription.inputs.find(i => i.factIndex === firstEdge.successorFactIndex);
|
|
168
|
+
const firstFactIndex = predecessorFact ? predecessorFact.factIndex : successorFact!.factIndex;
|
|
169
|
+
const writtenFactIndexes = new Set<number>().add(firstFactIndex);
|
|
170
|
+
const joins: string[] = generateJoins(queryDescription.edges, writtenFactIndexes, offset, schema);
|
|
171
|
+
const inputWhereClauses = queryDescription.inputs
|
|
172
|
+
.map(input => `f${input.factIndex}.fact_type_id = $${input.factTypeParameter + offset}`)
|
|
173
|
+
.join(" AND ");
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
` SELECT\n` +
|
|
177
|
+
` f${firstFactIndex}.fact_id as purge_root,\n` +
|
|
178
|
+
` ${columns}\n` +
|
|
179
|
+
` FROM ${schema}.fact f${firstFactIndex}\n${joins.join("")}` +
|
|
180
|
+
` WHERE ${inputWhereClauses}\n`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function generateJoins(edges: EdgeDescription[], writtenFactIndexes: Set<number>, offset: number, schema: string) {
|
|
151
185
|
const joins: string[] = [];
|
|
152
186
|
edges.forEach(edge => {
|
|
153
187
|
if (writtenFactIndexes.has(edge.predecessorFactIndex)) {
|
|
@@ -156,14 +190,14 @@ function generateJoins(edges: EdgeDescription[], writtenFactIndexes: Set<number>
|
|
|
156
190
|
` JOIN ${schema}.edge e${edge.edgeIndex}\n` +
|
|
157
191
|
` ON e${edge.edgeIndex}.predecessor_fact_id = f${edge.predecessorFactIndex}.fact_id\n` +
|
|
158
192
|
` AND e${edge.edgeIndex}.successor_fact_id = f${edge.successorFactIndex}.fact_id\n` +
|
|
159
|
-
` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1}\n`
|
|
193
|
+
` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1 + offset}\n`
|
|
160
194
|
);
|
|
161
195
|
}
|
|
162
196
|
else {
|
|
163
197
|
joins.push(
|
|
164
198
|
` JOIN ${schema}.edge e${edge.edgeIndex}\n` +
|
|
165
199
|
` ON e${edge.edgeIndex}.predecessor_fact_id = f${edge.predecessorFactIndex}.fact_id\n` +
|
|
166
|
-
` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1}\n`
|
|
200
|
+
` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1 + offset}\n`
|
|
167
201
|
);
|
|
168
202
|
joins.push(
|
|
169
203
|
` JOIN ${schema}.fact f${edge.successorFactIndex}\n` +
|
|
@@ -176,7 +210,7 @@ function generateJoins(edges: EdgeDescription[], writtenFactIndexes: Set<number>
|
|
|
176
210
|
joins.push(
|
|
177
211
|
` JOIN ${schema}.edge e${edge.edgeIndex}\n` +
|
|
178
212
|
` ON e${edge.edgeIndex}.successor_fact_id = f${edge.successorFactIndex}.fact_id\n` +
|
|
179
|
-
` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1}\n`
|
|
213
|
+
` AND e${edge.edgeIndex}.role_id = $${edge.roleParameter - 1 + offset}\n`
|
|
180
214
|
);
|
|
181
215
|
joins.push(
|
|
182
216
|
` JOIN ${schema}.fact f${edge.predecessorFactIndex}\n` +
|