@effectstream/db 0.100.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.
- package/assets-config.json +28 -0
- package/migrations/assets.ts +35 -0
- package/migrations/readme.md +37 -0
- package/migrations/system-down-v-0.0.0.sql +2 -0
- package/migrations/system-down-v-0.0.1.sql +14 -0
- package/migrations/system-up-v-0.0.0.sql +40 -0
- package/migrations/system-up-v-0.0.1.sql +117 -0
- package/migrations/system-up-v-0.0.2.sql +22 -0
- package/migrations/system-up-v-0.8.1.sql +5 -0
- package/migrations/system-version.ts +147 -0
- package/package.json +33 -0
- package/pgtypedconfig.json +21 -0
- package/scripts/Dockerfile +13 -0
- package/scripts/apply-migrations.ts +49 -0
- package/scripts/pgtyped-internal.ts +43 -0
- package/scripts/pgtyped-update.ts +119 -0
- package/scripts/start-pglite.ts +71 -0
- package/scripts/wait-for-db.ts +43 -0
- package/src/dynamic-tables.ts +110 -0
- package/src/event-indexing.ts +31 -0
- package/src/migrations.ts +28 -0
- package/src/mod.ts +65 -0
- package/src/pg-connection.test.ts +99 -0
- package/src/pg-connection.ts +211 -0
- package/src/register-events.ts +40 -0
- package/src/scheduled-constructors.ts +117 -0
- package/src/snapshot-handler.ts +164 -0
- package/src/sql/accounts.queries.ts +350 -0
- package/src/sql/accounts.sql +77 -0
- package/src/sql/achievements.queries.ts +74 -0
- package/src/sql/achievements.sql +17 -0
- package/src/sql/block-heights.queries.ts +205 -0
- package/src/sql/block-heights.sql +45 -0
- package/src/sql/events.queries.ts +201 -0
- package/src/sql/events.sql +41 -0
- package/src/sql/nonces.queries.ts +85 -0
- package/src/sql/nonces.sql +11 -0
- package/src/sql/primitives.queries.ts +35 -0
- package/src/sql/primitives.sql +4 -0
- package/src/sql/rollup_inputs.queries.ts +654 -0
- package/src/sql/rollup_inputs.sql +227 -0
- package/src/sql/statistics.queries.ts +160 -0
- package/src/sql/statistics.sql +55 -0
- package/src/sql/sync-protocols/page.queries.ts +140 -0
- package/src/sql/sync-protocols/page.sql +39 -0
- package/src/sql/system.queries.ts +356 -0
- package/src/sql/system.sql +75 -0
- package/src/sql/tables.queries.ts +89 -0
- package/src/sql/tables.sql +17 -0
- package/src/system.ts +41 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Client, PoolClient, PoolConfig } from "pg";
|
|
2
|
+
import pg from "pg";
|
|
3
|
+
import { ComponentNames, log, SeverityNumber } from "@effectstream/log";
|
|
4
|
+
import { type Operation, run, sleep } from "effection";
|
|
5
|
+
import { ENV } from "@effectstream/utils/node-env";
|
|
6
|
+
|
|
7
|
+
let readonlyDBConn: pg.Pool | null;
|
|
8
|
+
|
|
9
|
+
// PGLite does not support multiple connections, so we need to use a mutex to ensure that only one query is executed at a time.
|
|
10
|
+
// * For transactions use yield* acquireDBMutex(); ...Your Operations... releaseDBMutex();
|
|
11
|
+
// * For single queries use await runPreparedQuery(myQuery.run(params, dbConn));
|
|
12
|
+
// IMPORTANT: This is only for PGLite instances, for full pgsql servers, this is not needed.0
|
|
13
|
+
// TODO This is a very simple mutex implementation.
|
|
14
|
+
// It releases the mutex randomly, and not in order.
|
|
15
|
+
// It only works for deno's single-threaded runtime.
|
|
16
|
+
// pglite exports async-mutex, so we can use that for a proper solution.
|
|
17
|
+
let db_mutex: "free" | "locked" = "free";
|
|
18
|
+
/**
|
|
19
|
+
* Acquires a mutex to ensure that only one query is executed at a time.
|
|
20
|
+
* If running in PGLite, it will acquire a mutex to ensure that only one query is executed at a time.
|
|
21
|
+
* If running in a full pgsql server, it will do nothing.
|
|
22
|
+
* Always run run `releaseDBMutex()` after the query locks no longer needed, other threads are blocked until the mutex is released.
|
|
23
|
+
*/
|
|
24
|
+
const _waitUntilFree = {
|
|
25
|
+
waiting: [] as {
|
|
26
|
+
priority: 'high' | 'low';
|
|
27
|
+
name: string;
|
|
28
|
+
date: string;
|
|
29
|
+
}[],
|
|
30
|
+
running: {
|
|
31
|
+
name: "",
|
|
32
|
+
date: "",
|
|
33
|
+
},
|
|
34
|
+
db_mutex,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Get data for database.
|
|
38
|
+
export const waitUntilFree = () => {
|
|
39
|
+
return {
|
|
40
|
+
..._waitUntilFree,
|
|
41
|
+
db_mutex,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function* acquireDBMutex(lockName: string, priority: 'high' | 'low' = 'low'): Operation<void> {
|
|
46
|
+
if (!ENV.PGLITE) return;
|
|
47
|
+
_waitUntilFree.waiting.push({
|
|
48
|
+
priority,
|
|
49
|
+
name: lockName,
|
|
50
|
+
date: new Date().toISOString(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
while (true) {
|
|
54
|
+
// If there is a high priority lock, wait for it to be released.
|
|
55
|
+
if (priority === 'low' && _waitUntilFree.waiting.find((w) => w.priority === 'high')) {
|
|
56
|
+
// do nothing
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If there is no lock, acquire it.
|
|
60
|
+
else if (db_mutex === "free") {
|
|
61
|
+
db_mutex = "locked";
|
|
62
|
+
_waitUntilFree.waiting.splice(
|
|
63
|
+
_waitUntilFree.waiting.findIndex((w) => w.name === lockName),
|
|
64
|
+
1,
|
|
65
|
+
);
|
|
66
|
+
_waitUntilFree.running = {
|
|
67
|
+
name: lockName,
|
|
68
|
+
date: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// This is only for debugging purposes.
|
|
74
|
+
const i = _waitUntilFree.waiting.findIndex((w) => w.name === lockName);
|
|
75
|
+
if (i !== -1) {
|
|
76
|
+
const now = new Date().getTime();
|
|
77
|
+
const waitingSince = new Date(_waitUntilFree.waiting[i].date).getTime();
|
|
78
|
+
const waitingFor = now - waitingSince;
|
|
79
|
+
if (waitingFor > 2500) {
|
|
80
|
+
console.error(
|
|
81
|
+
`[DB Mutex] Waiting for ${waitingFor}[ms] for ${lockName}.
|
|
82
|
+
Locked by ${_waitUntilFree.running.name}. This is a critical error, please restart the sync service.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
yield* sleep(10);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Releases a mutex to allow other queries to be executed.
|
|
93
|
+
* If running in PGLite, it will release a mutex to allow other queries to be executed.
|
|
94
|
+
* If running in a full pgsql server, it will do nothing.
|
|
95
|
+
* Do not call this function if you did not call `acquireDBMutex()` before.
|
|
96
|
+
*/
|
|
97
|
+
export function releaseDBMutex(lockName: string) {
|
|
98
|
+
if (!ENV.PGLITE) return;
|
|
99
|
+
|
|
100
|
+
if (_waitUntilFree.running.name !== lockName) {
|
|
101
|
+
console.error(
|
|
102
|
+
"releasing mutex",
|
|
103
|
+
_waitUntilFree.running,
|
|
104
|
+
"but the lock name does not match",
|
|
105
|
+
lockName,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
_waitUntilFree.running = { name: "", date: "" };
|
|
109
|
+
db_mutex = "free";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Helper function to run a db query in a thread-safe manner.
|
|
114
|
+
* If running in PGLite, it will acquire a mutex to ensure that only one query is executed at a time.
|
|
115
|
+
* If running in a full pgsql server, it will run normally.
|
|
116
|
+
*/
|
|
117
|
+
export async function runPreparedQuery<T>(
|
|
118
|
+
p: Promise<T[]>,
|
|
119
|
+
name: string,
|
|
120
|
+
): Promise<T[]> {
|
|
121
|
+
let result: T[];
|
|
122
|
+
try {
|
|
123
|
+
if (ENV.PGLITE) {
|
|
124
|
+
await run(() => acquireDBMutex(`pq:${name}`));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let t;
|
|
128
|
+
const timeout: Promise<T[]> = new Promise((resolve) => {
|
|
129
|
+
t = setTimeout(() => {
|
|
130
|
+
console.error("[CRITICAL ERROR] Database query timed out", name);
|
|
131
|
+
if (ENV.PGLITE) {
|
|
132
|
+
// TODO: This is a temporary fix to allow the query to continue.
|
|
133
|
+
// Only allow to continue in PGLITE.
|
|
134
|
+
// We suspect this is PGLITE specific error.
|
|
135
|
+
resolve([]);
|
|
136
|
+
}
|
|
137
|
+
}, 2500);
|
|
138
|
+
});
|
|
139
|
+
result = await Promise.race([p, timeout]);
|
|
140
|
+
clearTimeout(t);
|
|
141
|
+
} finally {
|
|
142
|
+
releaseDBMutex(`pq:${name}`);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const getConnection = (
|
|
148
|
+
creds?: PoolConfig,
|
|
149
|
+
readonly = false,
|
|
150
|
+
): pg.Pool => {
|
|
151
|
+
if (!creds) {
|
|
152
|
+
creds = {
|
|
153
|
+
host: ENV.DB_HOST,
|
|
154
|
+
user: ENV.DB_USER,
|
|
155
|
+
password: ENV.PGLITE ? undefined : ENV.DB_PW,
|
|
156
|
+
database: ENV.DB_NAME,
|
|
157
|
+
port: ENV.DB_PORT,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (readonly && readonlyDBConn) return readonlyDBConn;
|
|
161
|
+
|
|
162
|
+
// TODO: make this configurable for non pglite instances
|
|
163
|
+
const MAX_CONNECTIONS = ENV.PGLITE ? 1 : 10;
|
|
164
|
+
|
|
165
|
+
const pool = new pg.Pool({ ...creds, max: MAX_CONNECTIONS });
|
|
166
|
+
pool.on("error", (err: unknown) =>
|
|
167
|
+
log.remote(
|
|
168
|
+
ComponentNames.EFFECTSTREAM_PGLITE,
|
|
169
|
+
["query"],
|
|
170
|
+
SeverityNumber.ERROR,
|
|
171
|
+
(log) => log(err),
|
|
172
|
+
));
|
|
173
|
+
pool.on("connect", (_client: PoolClient) => {
|
|
174
|
+
// On each new client initiated, need to register for error(this is a serious bug on pg, the client throw errors although it should not)
|
|
175
|
+
// https://github.com/brianc/node-postgres/issues/2499#issuecomment-805477725
|
|
176
|
+
_client.on("error", (err: Error) => {
|
|
177
|
+
log.remote(
|
|
178
|
+
ComponentNames.EFFECTSTREAM_PGLITE,
|
|
179
|
+
["connect"],
|
|
180
|
+
SeverityNumber.ERROR,
|
|
181
|
+
(log) => log(err),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (readonly) {
|
|
187
|
+
const ensureReadOnly =
|
|
188
|
+
`SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`;
|
|
189
|
+
void pool.query(ensureReadOnly); // note: this query modifies the DB state
|
|
190
|
+
readonlyDBConn = pool;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return pool;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// For notifications use a direct (non-pooled) persistent connection.
|
|
197
|
+
export const getPersistentConnection = (creds: PoolConfig): Client => {
|
|
198
|
+
const client = new pg.Client(creds);
|
|
199
|
+
client.connect(() => {});
|
|
200
|
+
// https://github.com/brianc/node-postgres/issues/2499#issuecomment-805477725
|
|
201
|
+
// On each new client initiated, need to register for error(this is a serious bug on pg, the client throw errors although it should not)
|
|
202
|
+
client.on("error", (err: Error) => {
|
|
203
|
+
log.remote(
|
|
204
|
+
ComponentNames.EFFECTSTREAM_PGLITE,
|
|
205
|
+
["query"],
|
|
206
|
+
SeverityNumber.ERROR,
|
|
207
|
+
(log) => log(err),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
return client;
|
|
211
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PoolClient } from "pg";
|
|
2
|
+
import {
|
|
3
|
+
getEventByTopic,
|
|
4
|
+
getTopics,
|
|
5
|
+
registerEventType,
|
|
6
|
+
} from "./sql/events.queries.ts";
|
|
7
|
+
|
|
8
|
+
export async function registerEventTypes(
|
|
9
|
+
pool: PoolClient,
|
|
10
|
+
eventDescriptions: { topic: string; name: string }[],
|
|
11
|
+
): Promise<boolean> {
|
|
12
|
+
const allStoredTopics = await getTopics.run(undefined, pool);
|
|
13
|
+
|
|
14
|
+
// check that no events were deleted.
|
|
15
|
+
for (const storedTopic of allStoredTopics) {
|
|
16
|
+
const found = eventDescriptions.find((ed) =>
|
|
17
|
+
ed.topic === storedTopic.topic
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
if (found === undefined) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const event of eventDescriptions) {
|
|
26
|
+
const registeredTopic = await getEventByTopic.run(
|
|
27
|
+
{ topic: event.topic },
|
|
28
|
+
pool,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (registeredTopic.length === 0) {
|
|
32
|
+
await registerEventType.run(
|
|
33
|
+
{ name: event.name, topic: event.topic },
|
|
34
|
+
pool,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { StateUpdateStream } from "@effectstream/coroutine";
|
|
2
|
+
import { World } from "@effectstream/coroutine";
|
|
3
|
+
import {
|
|
4
|
+
newScheduledHeightData,
|
|
5
|
+
newScheduledTimestampData,
|
|
6
|
+
removeAllScheduledDataByInputData,
|
|
7
|
+
removeScheduledBlockData,
|
|
8
|
+
removeScheduledTimestampData,
|
|
9
|
+
} from "./sql/rollup_inputs.queries.ts";
|
|
10
|
+
import {
|
|
11
|
+
AddressType,
|
|
12
|
+
type Caip2,
|
|
13
|
+
type TxHash,
|
|
14
|
+
type UnknownFormat,
|
|
15
|
+
} from "@effectstream/utils";
|
|
16
|
+
import {
|
|
17
|
+
type BlockNumber,
|
|
18
|
+
strip0x,
|
|
19
|
+
type TimestampMs,
|
|
20
|
+
type WalletAddress,
|
|
21
|
+
} from "@effectstream/utils";
|
|
22
|
+
import { Buffer } from "node:buffer";
|
|
23
|
+
|
|
24
|
+
export type UpdateAtTrigger = { blockHeight: BlockNumber } | {
|
|
25
|
+
timestamp: TimestampMs;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create an SQL update which schedules a piece of data to be run through
|
|
30
|
+
* the STF at a future block height / timestamp.
|
|
31
|
+
*
|
|
32
|
+
* @param inputData The input to pass to the STF, generally in Paima Concise format.
|
|
33
|
+
* @param blockHeight The future block height at which to post the input to the STF.
|
|
34
|
+
* @returns
|
|
35
|
+
*/
|
|
36
|
+
export function* createScheduledData(
|
|
37
|
+
inputData: string,
|
|
38
|
+
trigger: UpdateAtTrigger,
|
|
39
|
+
source:
|
|
40
|
+
| {
|
|
41
|
+
primitiveName: string;
|
|
42
|
+
txHash: TxHash;
|
|
43
|
+
caip2: Caip2;
|
|
44
|
+
fromAddress: WalletAddress;
|
|
45
|
+
fromAddressType: AddressType;
|
|
46
|
+
contractAddress: undefined | WalletAddress;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
precompile: UnknownFormat;
|
|
50
|
+
},
|
|
51
|
+
): StateUpdateStream<void> {
|
|
52
|
+
if ("blockHeight" in trigger) {
|
|
53
|
+
const sourceParams = "precompile" in source
|
|
54
|
+
? {
|
|
55
|
+
from_address: source.precompile,
|
|
56
|
+
// TODO: Check this value. What is the precompile types?
|
|
57
|
+
from_address_type: AddressType.NONE,
|
|
58
|
+
primitive_name: null,
|
|
59
|
+
origin_tx_hash: null,
|
|
60
|
+
caip2: null,
|
|
61
|
+
origin_contract_address: null,
|
|
62
|
+
}
|
|
63
|
+
: {
|
|
64
|
+
from_address: source.fromAddress,
|
|
65
|
+
from_address_type: source.fromAddressType,
|
|
66
|
+
primitive_name: source.primitiveName,
|
|
67
|
+
origin_tx_hash: Buffer.from(strip0x(source.txHash), "hex"),
|
|
68
|
+
caip2: source.caip2,
|
|
69
|
+
origin_contract_address: source.contractAddress,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
yield* World.resolve(newScheduledHeightData, {
|
|
73
|
+
future_block_height: trigger.blockHeight,
|
|
74
|
+
input_data: inputData,
|
|
75
|
+
...sourceParams,
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
if (!("precompile" in source)) {
|
|
79
|
+
// in particular primitives only schedule data in the same block they trigger
|
|
80
|
+
throw new Error("primitives should not schedule timers by date");
|
|
81
|
+
}
|
|
82
|
+
yield* World.resolve(newScheduledTimestampData, {
|
|
83
|
+
future_ms_timestamp: new Date(trigger.timestamp),
|
|
84
|
+
input_data: inputData,
|
|
85
|
+
from_address: source.precompile,
|
|
86
|
+
from_address_type: AddressType.NONE,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create an SQL update which deletes an upcoming scheduled data
|
|
92
|
+
// NOTE: if trigger is null, then delete ALL schedules that match inputData.
|
|
93
|
+
export function* deleteScheduledData(
|
|
94
|
+
inputData: string,
|
|
95
|
+
trigger: UpdateAtTrigger | null,
|
|
96
|
+
): StateUpdateStream<void> {
|
|
97
|
+
if (trigger === null) {
|
|
98
|
+
yield* World.resolve(removeAllScheduledDataByInputData, {
|
|
99
|
+
input_data: inputData,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if ("blockHeight" in trigger) {
|
|
105
|
+
// Delete exact schedule by command and height
|
|
106
|
+
yield* World.resolve(removeScheduledBlockData, {
|
|
107
|
+
block_height: trigger.blockHeight,
|
|
108
|
+
input_data: inputData,
|
|
109
|
+
});
|
|
110
|
+
} else {
|
|
111
|
+
// Delete exact schedule by command and timestamp
|
|
112
|
+
yield* World.resolve(removeScheduledTimestampData, {
|
|
113
|
+
ms_timestamp: new Date(trigger.timestamp),
|
|
114
|
+
input_data: inputData,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { ENV } from "@effectstream/utils/node-env";
|
|
2
|
+
import { sleep, until, type Operation } from "effection";
|
|
3
|
+
|
|
4
|
+
export interface SnapshotRetentionConfig {
|
|
5
|
+
/** One per hour for last 24 h. Default: `true` */
|
|
6
|
+
lastDayHourly?: boolean;
|
|
7
|
+
/** One per 6 h for last 3 days. Default: `true` */
|
|
8
|
+
last3DaysSixHourly?: boolean;
|
|
9
|
+
/** Days of daily snapshots to keep. Default: `7` */
|
|
10
|
+
lastNDaysDaily?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SnapshotConfig {
|
|
14
|
+
/** Wall-clock seconds between snapshots. Default: `3600` (1 hour) */
|
|
15
|
+
intervalSeconds?: number;
|
|
16
|
+
/** Output directory. Default: `"./snapshots"` */
|
|
17
|
+
path?: string;
|
|
18
|
+
/** Time-based tiered retention (uses file mtime). */
|
|
19
|
+
retention?: SnapshotRetentionConfig;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates a database snapshot using `pg_dump` in custom format (`-F c`).
|
|
24
|
+
* Must be called AFTER releasing the DB mutex (pg_dump opens its own connection).
|
|
25
|
+
*
|
|
26
|
+
* @see docs/home/1000-effectstream-engine/1003-database-snapshots.md
|
|
27
|
+
*/
|
|
28
|
+
export async function createSnapshot(
|
|
29
|
+
config?: SnapshotConfig,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
// pg_dump queries pg_catalog directly; PGlite's catalog may not match the
|
|
32
|
+
// locally-installed pg_dump version, so skip snapshots under PGlite.
|
|
33
|
+
if (ENV.PGLITE) {
|
|
34
|
+
console.log(`[Snapshot] Skipping: pg_dump is not compatible with PGlite.`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const snapshotDir = config?.path ?? "./snapshots";
|
|
39
|
+
const timestamp = new Date().toISOString().replace(/:/g, "-").replace(/\.\d{3}/, "");
|
|
40
|
+
const snapshotPath = `${snapshotDir}/snapshot-${timestamp}.dump`;
|
|
41
|
+
|
|
42
|
+
await Deno.mkdir(snapshotDir, { recursive: true });
|
|
43
|
+
console.log(`[Snapshot] Creating snapshot at ${snapshotPath}...`);
|
|
44
|
+
|
|
45
|
+
const cmd = new Deno.Command("pg_dump", {
|
|
46
|
+
args: [
|
|
47
|
+
"-h", ENV.DB_HOST,
|
|
48
|
+
"-p", String(ENV.DB_PORT),
|
|
49
|
+
"-U", ENV.DB_USER,
|
|
50
|
+
"-d", ENV.DB_NAME,
|
|
51
|
+
"-F", "c",
|
|
52
|
+
"-f", snapshotPath,
|
|
53
|
+
],
|
|
54
|
+
env: { ...Deno.env.toObject(), PGPASSWORD: ENV.DB_PW ?? "" },
|
|
55
|
+
stdout: "inherit",
|
|
56
|
+
stderr: "inherit",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const status = await cmd.output();
|
|
60
|
+
if (!status.success) {
|
|
61
|
+
throw new Error(`[Snapshot] pg_dump failed with exit code ${status.code}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`[Snapshot] Snapshot created: ${snapshotPath}`);
|
|
65
|
+
await applyRetentionPolicy(snapshotDir, config?.retention);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Long-lived fiber that fires snapshots on a fixed cadence, independent of
|
|
70
|
+
* block production. Spawn once at startup; cadence is `intervalSeconds`.
|
|
71
|
+
* Blocking on `createSnapshot` makes overlapping dumps structurally impossible.
|
|
72
|
+
*/
|
|
73
|
+
export function* runSnapshotLoop(
|
|
74
|
+
snapshotConfig: SnapshotConfig,
|
|
75
|
+
): Operation<void> {
|
|
76
|
+
const intervalMs = (snapshotConfig.intervalSeconds ?? 3600) * 1000;
|
|
77
|
+
console.log(`[Snapshot] Loop started (interval ${intervalMs / 1000}s, path ${snapshotConfig.path ?? "./snapshots"}, cwd ${Deno.cwd()})`);
|
|
78
|
+
while (true) {
|
|
79
|
+
console.log(`[Snapshot] Firing`);
|
|
80
|
+
try {
|
|
81
|
+
yield* until(createSnapshot(snapshotConfig));
|
|
82
|
+
console.log(`[Snapshot] Done; sleeping ${intervalMs / 1000}s`);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error("[Snapshot] Failed:", e);
|
|
85
|
+
}
|
|
86
|
+
yield* sleep(intervalMs);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Time-based tiered retention: keeps one file per bucket (newest wins),
|
|
92
|
+
* deletes everything beyond `lastNDaysDaily` days.
|
|
93
|
+
*/
|
|
94
|
+
async function applyRetentionPolicy(
|
|
95
|
+
snapshotDir: string,
|
|
96
|
+
retention?: SnapshotRetentionConfig,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const lastDayHourly = retention?.lastDayHourly ?? true;
|
|
99
|
+
const last3DaysSixHourly = retention?.last3DaysSixHourly ?? true;
|
|
100
|
+
const lastNDaysDaily = retention?.lastNDaysDaily ?? 7;
|
|
101
|
+
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const MS_1H = 3_600_000;
|
|
104
|
+
const MS_24H = 24 * MS_1H;
|
|
105
|
+
const MS_3D = 3 * MS_24H;
|
|
106
|
+
const MS_ND = lastNDaysDaily * MS_24H;
|
|
107
|
+
|
|
108
|
+
const entries: { path: string; mtime: number }[] = [];
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
for await (const entry of Deno.readDir(snapshotDir)) {
|
|
112
|
+
if (!entry.isFile || !entry.name.startsWith("snapshot-") || !entry.name.endsWith(".dump")) continue;
|
|
113
|
+
const filePath = `${snapshotDir}/${entry.name}`;
|
|
114
|
+
try {
|
|
115
|
+
const stat = await Deno.stat(filePath);
|
|
116
|
+
entries.push({ path: filePath, mtime: stat.mtime?.getTime() ?? 0 });
|
|
117
|
+
} catch { /* file vanished between readDir and stat */ }
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.warn("[Snapshot] Could not read snapshot directory for retention:", e);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (entries.length === 0) return;
|
|
125
|
+
|
|
126
|
+
// Newest first so the first entry per bucket is always the one we keep.
|
|
127
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
128
|
+
|
|
129
|
+
const keepers = new Set<string>();
|
|
130
|
+
const seenBuckets = new Set<string>();
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
const age = now - entry.mtime;
|
|
134
|
+
if (age > MS_ND) continue;
|
|
135
|
+
|
|
136
|
+
let bucketKey: string;
|
|
137
|
+
|
|
138
|
+
if (age <= MS_24H) {
|
|
139
|
+
if (!lastDayHourly) { keepers.add(entry.path); continue; }
|
|
140
|
+
bucketKey = `h:${Math.floor(age / MS_1H)}`;
|
|
141
|
+
} else if (age <= MS_3D) {
|
|
142
|
+
if (!last3DaysSixHourly) { keepers.add(entry.path); continue; }
|
|
143
|
+
bucketKey = `6h:${Math.floor(age / (6 * MS_1H))}`;
|
|
144
|
+
} else {
|
|
145
|
+
bucketKey = `d:${Math.floor(age / MS_24H)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!seenBuckets.has(bucketKey)) {
|
|
149
|
+
seenBuckets.add(bucketKey);
|
|
150
|
+
keepers.add(entry.path);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!keepers.has(entry.path)) {
|
|
156
|
+
try {
|
|
157
|
+
await Deno.remove(entry.path);
|
|
158
|
+
console.log(`[Snapshot] Retention: deleted ${entry.path}`);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.warn(`[Snapshot] Failed to delete ${entry.path}:`, e);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|