@platformatic/sqlite-js-stream 0.0.0-stage → 0.1.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/CHANGELOG.md +40 -0
- package/LICENSE +202 -0
- package/NOTICE +22 -0
- package/README.md +482 -2
- package/dist/backends/file/index.d.ts +4 -0
- package/dist/backends/file/index.d.ts.map +1 -0
- package/dist/backends/file/index.js +3 -0
- package/dist/backends/file/leaser.d.ts +20 -0
- package/dist/backends/file/leaser.d.ts.map +1 -0
- package/dist/backends/file/leaser.js +123 -0
- package/dist/backends/file/replica-client.d.ts +38 -0
- package/dist/backends/file/replica-client.d.ts.map +1 -0
- package/dist/backends/file/replica-client.js +209 -0
- package/dist/db.d.ts +80 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +484 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +66 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/leaser.d.ts +55 -0
- package/dist/leaser.d.ts.map +1 -0
- package/dist/leaser.js +60 -0
- package/dist/ltx/byte-reader.d.ts +18 -0
- package/dist/ltx/byte-reader.d.ts.map +1 -0
- package/dist/ltx/byte-reader.js +38 -0
- package/dist/ltx/bytes.d.ts +20 -0
- package/dist/ltx/bytes.d.ts.map +1 -0
- package/dist/ltx/bytes.js +69 -0
- package/dist/ltx/checksum.d.ts +20 -0
- package/dist/ltx/checksum.d.ts.map +1 -0
- package/dist/ltx/checksum.js +42 -0
- package/dist/ltx/compaction-level.d.ts +58 -0
- package/dist/ltx/compaction-level.d.ts.map +1 -0
- package/dist/ltx/compaction-level.js +115 -0
- package/dist/ltx/compactor.d.ts +33 -0
- package/dist/ltx/compactor.d.ts.map +1 -0
- package/dist/ltx/compactor.js +137 -0
- package/dist/ltx/constants.d.ts +49 -0
- package/dist/ltx/constants.d.ts.map +1 -0
- package/dist/ltx/constants.js +58 -0
- package/dist/ltx/crc64.d.ts +29 -0
- package/dist/ltx/crc64.d.ts.map +1 -0
- package/dist/ltx/crc64.js +57 -0
- package/dist/ltx/decoder.d.ts +55 -0
- package/dist/ltx/decoder.d.ts.map +1 -0
- package/dist/ltx/decoder.js +236 -0
- package/dist/ltx/encoder.d.ts +49 -0
- package/dist/ltx/encoder.d.ts.map +1 -0
- package/dist/ltx/encoder.js +159 -0
- package/dist/ltx/file-info.d.ts +24 -0
- package/dist/ltx/file-info.d.ts.map +1 -0
- package/dist/ltx/file-info.js +74 -0
- package/dist/ltx/filename.d.ts +13 -0
- package/dist/ltx/filename.d.ts.map +1 -0
- package/dist/ltx/filename.js +23 -0
- package/dist/ltx/header.d.ts +17 -0
- package/dist/ltx/header.d.ts.map +1 -0
- package/dist/ltx/header.js +115 -0
- package/dist/ltx/index.d.ts +24 -0
- package/dist/ltx/index.d.ts.map +1 -0
- package/dist/ltx/index.js +25 -0
- package/dist/ltx/lz4.d.ts +41 -0
- package/dist/ltx/lz4.d.ts.map +1 -0
- package/dist/ltx/lz4.js +264 -0
- package/dist/ltx/page-index.d.ts +26 -0
- package/dist/ltx/page-index.d.ts.map +1 -0
- package/dist/ltx/page-index.js +118 -0
- package/dist/ltx/page.d.ts +27 -0
- package/dist/ltx/page.d.ts.map +1 -0
- package/dist/ltx/page.js +66 -0
- package/dist/ltx/sink.d.ts +17 -0
- package/dist/ltx/sink.d.ts.map +1 -0
- package/dist/ltx/sink.js +21 -0
- package/dist/ltx/stream.d.ts +64 -0
- package/dist/ltx/stream.d.ts.map +1 -0
- package/dist/ltx/stream.js +248 -0
- package/dist/ltx/streaming-compactor.d.ts +34 -0
- package/dist/ltx/streaming-compactor.d.ts.map +1 -0
- package/dist/ltx/streaming-compactor.js +140 -0
- package/dist/ltx/trailer.d.ts +12 -0
- package/dist/ltx/trailer.d.ts.map +1 -0
- package/dist/ltx/trailer.js +47 -0
- package/dist/ltx/types.d.ts +79 -0
- package/dist/ltx/types.d.ts.map +1 -0
- package/dist/ltx/types.js +70 -0
- package/dist/replica-client.d.ts +92 -0
- package/dist/replica-client.d.ts.map +1 -0
- package/dist/replica-client.js +122 -0
- package/dist/replica-url.d.ts +20 -0
- package/dist/replica-url.d.ts.map +1 -0
- package/dist/replica-url.js +49 -0
- package/dist/replica.d.ts +379 -0
- package/dist/replica.d.ts.map +1 -0
- package/dist/replica.js +1253 -0
- package/dist/store.d.ts +34 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +60 -0
- package/dist/util/fs.d.ts +28 -0
- package/dist/util/fs.d.ts.map +1 -0
- package/dist/util/fs.js +73 -0
- package/dist/util/logger.d.ts +10 -0
- package/dist/util/logger.d.ts.map +1 -0
- package/dist/util/logger.js +7 -0
- package/dist/util/stream.d.ts +11 -0
- package/dist/util/stream.d.ts.map +1 -0
- package/dist/util/stream.js +25 -0
- package/dist/wal/checksum.d.ts +17 -0
- package/dist/wal/checksum.d.ts.map +1 -0
- package/dist/wal/checksum.js +30 -0
- package/dist/wal/constants.d.ts +8 -0
- package/dist/wal/constants.d.ts.map +1 -0
- package/dist/wal/constants.js +7 -0
- package/dist/wal/index.d.ts +5 -0
- package/dist/wal/index.d.ts.map +1 -0
- package/dist/wal/index.js +4 -0
- package/dist/wal/reader.d.ts +61 -0
- package/dist/wal/reader.d.ts.map +1 -0
- package/dist/wal/reader.js +207 -0
- package/package.json +68 -3
- package/src/backends/file/index.ts +3 -0
- package/src/backends/file/leaser.ts +145 -0
- package/src/backends/file/replica-client.ts +246 -0
- package/src/db.ts +553 -0
- package/src/errors.ts +74 -0
- package/src/index.ts +26 -0
- package/src/leaser.ts +86 -0
- package/src/ltx/byte-reader.ts +46 -0
- package/src/ltx/bytes.ts +87 -0
- package/src/ltx/checksum.ts +46 -0
- package/src/ltx/compaction-level.ts +135 -0
- package/src/ltx/compactor.ts +167 -0
- package/src/ltx/constants.ts +71 -0
- package/src/ltx/crc64.ts +65 -0
- package/src/ltx/decoder.ts +282 -0
- package/src/ltx/encoder.ts +200 -0
- package/src/ltx/file-info.ts +81 -0
- package/src/ltx/filename.ts +27 -0
- package/src/ltx/header.ts +126 -0
- package/src/ltx/index.ts +27 -0
- package/src/ltx/lz4.ts +279 -0
- package/src/ltx/page-index.ts +140 -0
- package/src/ltx/page.ts +80 -0
- package/src/ltx/sink.ts +33 -0
- package/src/ltx/stream.ts +286 -0
- package/src/ltx/streaming-compactor.ts +169 -0
- package/src/ltx/trailer.ts +58 -0
- package/src/ltx/types.ts +152 -0
- package/src/lz4js.d.ts +27 -0
- package/src/replica-client.ts +222 -0
- package/src/replica-url.ts +72 -0
- package/src/replica.ts +1480 -0
- package/src/store.ts +71 -0
- package/src/util/fs.ts +82 -0
- package/src/util/logger.ts +15 -0
- package/src/util/stream.ts +28 -0
- package/src/wal/checksum.ts +39 -0
- package/src/wal/constants.ts +10 -0
- package/src/wal/index.ts +5 -0
- package/src/wal/reader.ts +264 -0
package/src/replica.ts
ADDED
|
@@ -0,0 +1,1480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replica manages replication of a single SQLite database (identified by its
|
|
3
|
+
* `path`) to a ReplicaClient.
|
|
4
|
+
*
|
|
5
|
+
* It reads the database + WAL directly from disk, writes level-0/level-9 LTX
|
|
6
|
+
* files into a local cache, uploads them to the client, tracks the replicated
|
|
7
|
+
* position, and can restore a database by merging LTX files. Mirrors
|
|
8
|
+
* litestream's `replica.go`, but is agnostic to the SQLite driver the
|
|
9
|
+
* application uses.
|
|
10
|
+
*
|
|
11
|
+
* For snapshot consistency it opens its own **independent read-only** SQLite
|
|
12
|
+
* connection (never the application's writable one) to pin the WAL while it
|
|
13
|
+
* reads. A single SQLite connection holds one transaction at a time, so
|
|
14
|
+
* reusing an application `DatabaseSync` handle would merge the read snapshot
|
|
15
|
+
* with the app's writes (and could read uncommitted rows). Keep the app's
|
|
16
|
+
* writer and the library's reader on separate connections to the same file.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createReadStream } from 'node:fs';
|
|
20
|
+
import { access, mkdir, open, readFile, rename, rm, stat, type FileHandle } from 'node:fs/promises';
|
|
21
|
+
import nodePath from 'node:path';
|
|
22
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
lockPgno,
|
|
26
|
+
StreamLtxReader,
|
|
27
|
+
StreamingCompactor,
|
|
28
|
+
CompactionLevels,
|
|
29
|
+
nextCompactionAt,
|
|
30
|
+
prevCompactionAt,
|
|
31
|
+
defaultCompactionLevels,
|
|
32
|
+
DefaultL0RetentionCheckIntervalMs,
|
|
33
|
+
type FileInfo,
|
|
34
|
+
type CompactionLevel,
|
|
35
|
+
} from './ltx/index.ts';
|
|
36
|
+
import { LocalDatabase } from './db.ts';
|
|
37
|
+
import type { ReplicaClient } from './replica-client.ts';
|
|
38
|
+
import { SnapshotLevel } from './replica-client.ts';
|
|
39
|
+
import {
|
|
40
|
+
DefaultLeaseRenewalBuffer,
|
|
41
|
+
ErrLeaseNotHeld,
|
|
42
|
+
Lease,
|
|
43
|
+
LeaseExistsError,
|
|
44
|
+
type Leaser,
|
|
45
|
+
} from './leaser.ts';
|
|
46
|
+
import { FileSink } from './util/fs.ts';
|
|
47
|
+
import type { Logger } from './util/logger.ts';
|
|
48
|
+
import { noopLogger } from './util/logger.ts';
|
|
49
|
+
import {
|
|
50
|
+
DatabaseError,
|
|
51
|
+
InvalidArgumentError,
|
|
52
|
+
LTXFormatError,
|
|
53
|
+
PageSizeMismatchError,
|
|
54
|
+
SqliteJsStreamError,
|
|
55
|
+
} from './errors.ts';
|
|
56
|
+
|
|
57
|
+
export interface RestoreOptions {
|
|
58
|
+
outputPath: string;
|
|
59
|
+
txID?: bigint;
|
|
60
|
+
timestamp?: Date;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Options for {@link Replica.follow}, litestream's `restore -f` continuous mode. */
|
|
64
|
+
export interface FollowOptions {
|
|
65
|
+
/** Output database path to continuously restore into. */
|
|
66
|
+
outputPath: string;
|
|
67
|
+
/** Milliseconds between polls for new LTX files. Defaults to 1000. */
|
|
68
|
+
intervalMs?: number;
|
|
69
|
+
/** Abort to stop the follow loop. */
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
/**
|
|
72
|
+
* Sidecar file recording the last applied TXID, so a later `follow()` can
|
|
73
|
+
* resume instead of re-restoring. Defaults to `${outputPath}-txid`. Written
|
|
74
|
+
* atomically (temp + rename) after each applied batch. `follow()` refuses to
|
|
75
|
+
* resume when the DB exists but this file is missing (it cannot prove the DB
|
|
76
|
+
* matches the replica and would otherwise corrupt it).
|
|
77
|
+
*/
|
|
78
|
+
txidFile?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Base class for errors thrown by {@link Replica.follow}. Carries a stable,
|
|
83
|
+
* Node-style `code` so callers can branch on the failure without matching
|
|
84
|
+
* message text, and can be caught via `instanceof`.
|
|
85
|
+
*/
|
|
86
|
+
export class FollowError extends SqliteJsStreamError {
|
|
87
|
+
constructor(code: string, message: string) {
|
|
88
|
+
super(code, message);
|
|
89
|
+
this.name = 'FollowError';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The database exists but its `-txid` sidecar is missing, so we can't resume. */
|
|
94
|
+
export class FollowMissingTxidError extends FollowError {
|
|
95
|
+
constructor(message: string) {
|
|
96
|
+
super('ERR_FOLLOW_DB_WITHOUT_TXID', message);
|
|
97
|
+
this.name = 'FollowMissingTxidError';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The `-txid` sidecar is present but does not parse as a TXID. */
|
|
102
|
+
export class FollowInvalidTxidError extends FollowError {
|
|
103
|
+
constructor(message: string) {
|
|
104
|
+
super('ERR_FOLLOW_INVALID_TXID', message);
|
|
105
|
+
this.name = 'FollowInvalidTxidError';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The saved TXID is ahead of anything the replica holds; re-restore. */
|
|
110
|
+
export class FollowAheadOfLatestError extends FollowError {
|
|
111
|
+
constructor(message: string) {
|
|
112
|
+
super('ERR_FOLLOW_AHEAD_OF_LATEST', message);
|
|
113
|
+
this.name = 'FollowAheadOfLatestError';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The saved TXID is below the retained window; retention pruned the next file. */
|
|
118
|
+
export class FollowDataGapError extends FollowError {
|
|
119
|
+
constructor(message: string) {
|
|
120
|
+
super('ERR_FOLLOW_OLDER_THAN_EARLIEST', message);
|
|
121
|
+
this.name = 'FollowDataGapError';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Follow was aborted before the initial restore could complete. */
|
|
126
|
+
export class FollowAbortedError extends FollowError {
|
|
127
|
+
constructor(message: string) {
|
|
128
|
+
super('ERR_FOLLOW_ABORTED', message);
|
|
129
|
+
this.name = 'FollowAbortedError';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SyncStatus {
|
|
134
|
+
localTXID: bigint;
|
|
135
|
+
remoteTXID: bigint;
|
|
136
|
+
inSync: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Default highest LTX compaction level (L0 -> L1 -> ... -> L{max}) for the
|
|
141
|
+
* legacy {@link CompactOptions.maxLevel} path. Prefer {@link CompactionLevels}.
|
|
142
|
+
*/
|
|
143
|
+
export const DefaultMaxCompactionLevel = 3;
|
|
144
|
+
|
|
145
|
+
export interface CompactOptions {
|
|
146
|
+
/**
|
|
147
|
+
* Destination level to compact into (1..maxLevel). When omitted, the first
|
|
148
|
+
* level (1..maxLevel) that has pending source files is compacted.
|
|
149
|
+
*/
|
|
150
|
+
dstLevel?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Retention/GC policy for pruning old snapshots and compacted files. */
|
|
154
|
+
export interface RetentionOptions {
|
|
155
|
+
/**
|
|
156
|
+
* Delete level-9 snapshots older than this many milliseconds. The newest
|
|
157
|
+
* snapshot is always kept. Defaults to 0 (keep all snapshots).
|
|
158
|
+
*/
|
|
159
|
+
snapshotRetentionMs?: number;
|
|
160
|
+
/**
|
|
161
|
+
* Keep at least this many newest level-9 snapshots even if they are older
|
|
162
|
+
* than {@link snapshotRetentionMs}. Defaults to 1 (always keep the newest).
|
|
163
|
+
*/
|
|
164
|
+
snapshotRetain?: number;
|
|
165
|
+
/**
|
|
166
|
+
* Minimum age (ms) before a level-0 file that has been compacted into level-1
|
|
167
|
+
* is deleted. Defaults to 0 (no L0 time-based GC).
|
|
168
|
+
*/
|
|
169
|
+
l0RetentionMs?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface RetentionResult {
|
|
173
|
+
snapshotsDeleted: number;
|
|
174
|
+
filesDeleted: number;
|
|
175
|
+
/** MaxTXID of the last snapshot that was deleted; files below it are removed. */
|
|
176
|
+
boundary: bigint;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Returned by compaction scheduling when there is nothing to compact. */
|
|
180
|
+
export class ErrNoCompactionError extends SqliteJsStreamError {
|
|
181
|
+
constructor() {
|
|
182
|
+
super('ERR_NO_COMPACTION', 'no files to compact');
|
|
183
|
+
this.name = 'ErrNoCompactionError';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export const ErrNoCompaction = new ErrNoCompactionError();
|
|
187
|
+
|
|
188
|
+
/** Returned when a level was already compacted within its current time bucket. */
|
|
189
|
+
export class ErrCompactionTooEarlyError extends SqliteJsStreamError {
|
|
190
|
+
constructor() {
|
|
191
|
+
super('ERR_COMPACTION_TOO_EARLY', 'compaction scheduled too early');
|
|
192
|
+
this.name = 'ErrCompactionTooEarlyError';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export const ErrCompactionTooEarly = new ErrCompactionTooEarlyError();
|
|
196
|
+
|
|
197
|
+
/** Raised when a restore/plan cannot find LTX files at or below the target. */
|
|
198
|
+
export class NoMatchingBackupError extends SqliteJsStreamError {
|
|
199
|
+
constructor(message = 'no matching backup files available') {
|
|
200
|
+
super('ERR_NO_MATCHING_BACKUP', message);
|
|
201
|
+
this.name = 'NoMatchingBackupError';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
export interface ReplicaRunOptions {
|
|
208
|
+
/** Milliseconds between syncs. Defaults to 1000. */
|
|
209
|
+
intervalMs?: number;
|
|
210
|
+
/** Abort to stop continuous syncing. */
|
|
211
|
+
signal?: AbortSignal;
|
|
212
|
+
/**
|
|
213
|
+
* Per-level compaction schedule. Defaults to {@link defaultCompactionLevels}.
|
|
214
|
+
* Levels are compacted at most once per wall-clock bucket.
|
|
215
|
+
*/
|
|
216
|
+
compactionLevels?: CompactionLevels;
|
|
217
|
+
/**
|
|
218
|
+
* Legacy: max compaction level when {@link compactionLevels} is not given.
|
|
219
|
+
* Defaults to {@link DefaultMaxCompactionLevel}.
|
|
220
|
+
*/
|
|
221
|
+
maxLevel?: number;
|
|
222
|
+
/**
|
|
223
|
+
* Legacy: cascade-compact all levels every N ms (disables per-level buckets
|
|
224
|
+
* when set). Defaults to 0 (disabled).
|
|
225
|
+
*/
|
|
226
|
+
compactionIntervalMs?: number;
|
|
227
|
+
/** Legacy: run {@link Replica.enforceRetention} every N ms. Defaults to 0. */
|
|
228
|
+
retentionIntervalMs?: number;
|
|
229
|
+
/** Retention policy used by the legacy retention loop. */
|
|
230
|
+
retention?: RetentionOptions;
|
|
231
|
+
/**
|
|
232
|
+
* Milliseconds between level-9 snapshots while running (0 disables).
|
|
233
|
+
* Defaults to 0.
|
|
234
|
+
*/
|
|
235
|
+
snapshotIntervalMs?: number;
|
|
236
|
+
/** Time-based snapshot retention (ms) applied after each snapshot. */
|
|
237
|
+
snapshotRetentionMs?: number;
|
|
238
|
+
/** Keep at least this many newest snapshots (default 1). */
|
|
239
|
+
snapshotRetain?: number;
|
|
240
|
+
/**
|
|
241
|
+
* Min age (ms) before a compacted level-0 file is deleted. When > 0 an L0
|
|
242
|
+
* retention pass runs on {@link l0RetentionCheckIntervalMs}. Defaults to 0.
|
|
243
|
+
*/
|
|
244
|
+
l0RetentionMs?: number;
|
|
245
|
+
/** How often (ms) to run the L0 time-retention pass. Defaults to 15s. */
|
|
246
|
+
l0RetentionCheckIntervalMs?: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Constant intervals for compaction levels beyond the 4-level default. */
|
|
250
|
+
const HIGHER_LEVEL_INTERVAL_MS = 3_600_000;
|
|
251
|
+
|
|
252
|
+
/** Default intervals for levels 1..3 (matches litestream's defaults). */
|
|
253
|
+
const DEFAULT_LEVEL_INTERVALS = [30_000, 300_000, 3_600_000];
|
|
254
|
+
|
|
255
|
+
/** Builds a CompactionLevels covering levels 0..max with sane intervals. */
|
|
256
|
+
function compactionLevelsForMax(max: number): CompactionLevels {
|
|
257
|
+
const arr: CompactionLevel[] = [];
|
|
258
|
+
for (let i = 0; i <= max; i++) {
|
|
259
|
+
const intervalMs =
|
|
260
|
+
i === 0 ? 0 : (DEFAULT_LEVEL_INTERVALS[i - 1] ?? HIGHER_LEVEL_INTERVAL_MS);
|
|
261
|
+
arr.push({ level: i, intervalMs });
|
|
262
|
+
}
|
|
263
|
+
return new CompactionLevels(arr);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Replica replicates a single database (by path) to a ReplicaClient. */
|
|
267
|
+
export class Replica {
|
|
268
|
+
readonly path: string;
|
|
269
|
+
readonly client: ReplicaClient;
|
|
270
|
+
private logger: Logger;
|
|
271
|
+
private posValue: bigint | null = null;
|
|
272
|
+
private db: LocalDatabase | null = null;
|
|
273
|
+
private compactionLevelsValue: CompactionLevels = defaultCompactionLevels();
|
|
274
|
+
private leaserValue: Leaser | null = null;
|
|
275
|
+
private leasePrimary = true;
|
|
276
|
+
private leaseCurrent: Lease | null = null;
|
|
277
|
+
private leaseTimer: NodeJS.Timeout | null = null;
|
|
278
|
+
private runActive = false;
|
|
279
|
+
|
|
280
|
+
constructor(
|
|
281
|
+
path: string,
|
|
282
|
+
client: ReplicaClient,
|
|
283
|
+
logger: Logger = noopLogger,
|
|
284
|
+
options: { maxLevel?: number; compactionLevels?: CompactionLevels; leaser?: Leaser } = {},
|
|
285
|
+
) {
|
|
286
|
+
this.path = path;
|
|
287
|
+
this.client = client;
|
|
288
|
+
this.logger = logger;
|
|
289
|
+
if (options.compactionLevels) {
|
|
290
|
+
this.compactionLevelsValue = options.compactionLevels;
|
|
291
|
+
} else if (options.maxLevel !== undefined) {
|
|
292
|
+
this.maxCompactionLevel = options.maxLevel;
|
|
293
|
+
}
|
|
294
|
+
if (options.leaser) {
|
|
295
|
+
this.leaserValue = options.leaser;
|
|
296
|
+
// A leaser is not held until acquired, so a fresh replica starts as a
|
|
297
|
+
// follower until {@link run} (or an explicit acquire) makes it primary.
|
|
298
|
+
this.leasePrimary = false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Leaser optionally bounds this replica to a single primary across hosts. */
|
|
303
|
+
get leaser(): Leaser | null {
|
|
304
|
+
return this.leaserValue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
set leaser(leaser: Leaser | null) {
|
|
308
|
+
this.leaserValue = leaser;
|
|
309
|
+
// Mirror the constructor: a newly set leaser is not held until acquired.
|
|
310
|
+
// While running, {@link run} owns the primary state.
|
|
311
|
+
if (!this.runActive) this.leasePrimary = leaser === null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* IsPrimary reports whether this replica may currently write. Always true
|
|
316
|
+
* when no {@link Leaser} is configured; false while another host holds the
|
|
317
|
+
* lease. Updated by {@link run}.
|
|
318
|
+
*/
|
|
319
|
+
isPrimary(): boolean {
|
|
320
|
+
return this.leasePrimary;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @internal Throws if a leaser is set but this replica does not hold it. */
|
|
324
|
+
private assertCanWrite(): void {
|
|
325
|
+
if (this.leaserValue !== null && !this.leasePrimary) {
|
|
326
|
+
throw new ErrLeaseNotHeld();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Per-level compaction schedule. Defaults to {@link defaultCompactionLevels}. */
|
|
331
|
+
get compactionLevels(): CompactionLevels {
|
|
332
|
+
return this.compactionLevelsValue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
set compactionLevels(levels: CompactionLevels) {
|
|
336
|
+
this.compactionLevelsValue = levels;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Highest non-snapshot compaction level this replica will produce. */
|
|
340
|
+
get maxCompactionLevel(): number {
|
|
341
|
+
return this.compactionLevelsValue.maxLevel();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
set maxCompactionLevel(level: number) {
|
|
345
|
+
this.compactionLevelsValue = compactionLevelsForMax(level);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Lazily opens the local database reader (cached). */
|
|
349
|
+
private async localDb(): Promise<LocalDatabase> {
|
|
350
|
+
if (this.db === null) {
|
|
351
|
+
this.db = await LocalDatabase.open(this.path);
|
|
352
|
+
}
|
|
353
|
+
return this.db;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Pos returns the current replicated position (max level-0 maxTXID). */
|
|
357
|
+
async pos(): Promise<bigint> {
|
|
358
|
+
if (this.posValue === null) {
|
|
359
|
+
this.posValue = await this.calcPos();
|
|
360
|
+
}
|
|
361
|
+
return this.posValue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* CalcPos finds the maximum TXID on the replica across all levels. Since
|
|
366
|
+
* compaction moves the newest data to higher levels and prunes level-0, the
|
|
367
|
+
* position must be derived from every level (and any snapshot).
|
|
368
|
+
*/
|
|
369
|
+
async calcPos(): Promise<bigint> {
|
|
370
|
+
let max = 0n;
|
|
371
|
+
for (let level = 0; level <= SnapshotLevel; level++) {
|
|
372
|
+
for await (const info of this.client.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
373
|
+
if (info.maxTXID > max) max = info.maxTXID;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return max;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** LocalPos returns the local cached position (max local level-0 maxTXID). */
|
|
380
|
+
async localPos(): Promise<bigint> {
|
|
381
|
+
return (await this.localDb()).pos();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Sync builds a level-0 LTX file locally (if there are new WAL changes) and
|
|
386
|
+
* uploads any pending local files to the client. Returns the file that was
|
|
387
|
+
* written locally, or null if there was nothing new to do.
|
|
388
|
+
*/
|
|
389
|
+
async sync(): Promise<FileInfo | null> {
|
|
390
|
+
this.assertCanWrite();
|
|
391
|
+
await this.client.init();
|
|
392
|
+
const db = await this.localDb();
|
|
393
|
+
const info = await db.sync();
|
|
394
|
+
const dbPos = await db.pos();
|
|
395
|
+
const remotePos = await this.pos();
|
|
396
|
+
|
|
397
|
+
for (let txID = remotePos + 1n; txID <= dbPos; txID = (await this.pos()) + 1n) {
|
|
398
|
+
await this.uploadLtxFile(0, txID, txID);
|
|
399
|
+
this.posValue = txID;
|
|
400
|
+
}
|
|
401
|
+
return info;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** SyncAndWait uploads all pending LTX files; harmless if already synced. */
|
|
405
|
+
async syncAndWait(): Promise<void> {
|
|
406
|
+
await this.sync();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Run continuously drives the replica until `signal` is aborted. It syncs on
|
|
411
|
+
* `intervalMs`, compacts per level on each level's wall-clock bucket, takes
|
|
412
|
+
* snapshots on `snapshotIntervalMs`, and enforces retention. Errors are logged
|
|
413
|
+
* and the loop keeps going, so a transient failure does not stop replication.
|
|
414
|
+
*
|
|
415
|
+
* Compaction mirrors litestream: each level is compacted at most once per
|
|
416
|
+
* bucket (see {@link CompactionLevels}); the legacy `compactionIntervalMs`
|
|
417
|
+
* cascades all levels on a fixed interval instead.
|
|
418
|
+
*/
|
|
419
|
+
async run(opt: ReplicaRunOptions = {}): Promise<void> {
|
|
420
|
+
const { intervalMs = 1000, signal } = opt;
|
|
421
|
+
if (opt.maxLevel !== undefined) this.maxCompactionLevel = opt.maxLevel;
|
|
422
|
+
|
|
423
|
+
const levels = opt.compactionLevels ?? this.compactionLevels;
|
|
424
|
+
const useLegacyCompaction = (opt.compactionIntervalMs ?? 0) > 0;
|
|
425
|
+
const compactionIntervalMs = opt.compactionIntervalMs ?? 0;
|
|
426
|
+
const retentionIntervalMs = opt.retentionIntervalMs ?? 0;
|
|
427
|
+
const snapshotIntervalMs = opt.snapshotIntervalMs ?? 0;
|
|
428
|
+
const l0RetentionMs = opt.l0RetentionMs ?? 0;
|
|
429
|
+
const l0RetentionCheckIntervalMs =
|
|
430
|
+
opt.l0RetentionCheckIntervalMs ?? DefaultL0RetentionCheckIntervalMs;
|
|
431
|
+
|
|
432
|
+
// Try to acquire the lease up front. If held by another host we become a
|
|
433
|
+
// follower and poll until it is available. Without a leaser we are primary.
|
|
434
|
+
this.runActive = true;
|
|
435
|
+
const leaser = this.leaserValue;
|
|
436
|
+
let primary = leaser === null;
|
|
437
|
+
if (leaser !== null) {
|
|
438
|
+
try {
|
|
439
|
+
this.leaseCurrent = await leaser.acquireLease();
|
|
440
|
+
primary = true;
|
|
441
|
+
this.scheduleLeaseRenewal();
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (err instanceof LeaseExistsError) {
|
|
444
|
+
this.logger.debug('lease held elsewhere; running as follower', err);
|
|
445
|
+
primary = false;
|
|
446
|
+
} else {
|
|
447
|
+
this.runActive = false;
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
this.leasePrimary = primary;
|
|
453
|
+
|
|
454
|
+
let lastCompaction = 0;
|
|
455
|
+
let lastRetention = 0;
|
|
456
|
+
let snapshotNext =
|
|
457
|
+
snapshotIntervalMs > 0 ? nextCompactionAt(Date.now(), snapshotIntervalMs) : Infinity;
|
|
458
|
+
let l0RetentionNext = l0RetentionMs > 0 ? Date.now() + l0RetentionCheckIntervalMs : Infinity;
|
|
459
|
+
|
|
460
|
+
// Per-level next-bucket times (litestream-style scheduling).
|
|
461
|
+
const levelNext = new Map<number, number>();
|
|
462
|
+
if (!useLegacyCompaction) {
|
|
463
|
+
for (const lvl of levels.toArray()) {
|
|
464
|
+
if (lvl.level > 0 && lvl.intervalMs > 0) {
|
|
465
|
+
levelNext.set(lvl.level, nextCompactionAt(Date.now(), lvl.intervalMs));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
while (!signal?.aborted) {
|
|
471
|
+
const now = Date.now();
|
|
472
|
+
|
|
473
|
+
// Follower management: poll to acquire the lease. The primary never
|
|
474
|
+
// renews here — a dedicated timer ({@link scheduleLeaseRenewal}) renews it
|
|
475
|
+
// independently of the loop cadence, so a large `intervalMs` cannot let the
|
|
476
|
+
// lease expire.
|
|
477
|
+
if (leaser !== null && !primary) {
|
|
478
|
+
try {
|
|
479
|
+
this.leaseCurrent = await leaser.acquireLease();
|
|
480
|
+
primary = true;
|
|
481
|
+
this.leasePrimary = true;
|
|
482
|
+
this.scheduleLeaseRenewal();
|
|
483
|
+
} catch (err) {
|
|
484
|
+
if (!(err instanceof LeaseExistsError)) {
|
|
485
|
+
this.logger.error('lease acquire error', err);
|
|
486
|
+
}
|
|
487
|
+
await sleep(intervalMs, undefined, { signal });
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Only the primary (or a leaser-less replica) performs writes.
|
|
493
|
+
if (leaser === null || primary) {
|
|
494
|
+
try {
|
|
495
|
+
await this.sync();
|
|
496
|
+
} catch (err) {
|
|
497
|
+
this.logger.error('replica sync error', err);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (signal?.aborted) break;
|
|
501
|
+
|
|
502
|
+
if (leaser !== null && !primary) {
|
|
503
|
+
await sleep(intervalMs, undefined, { signal });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Compaction.
|
|
508
|
+
if (useLegacyCompaction) {
|
|
509
|
+
if (compactionIntervalMs > 0 && now - lastCompaction >= compactionIntervalMs) {
|
|
510
|
+
try {
|
|
511
|
+
await this.compact();
|
|
512
|
+
} catch (err) {
|
|
513
|
+
this.logger.error('replica compaction error', err);
|
|
514
|
+
}
|
|
515
|
+
lastCompaction = Date.now();
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
for (const [level, next] of levelNext) {
|
|
519
|
+
if (now >= next) {
|
|
520
|
+
try {
|
|
521
|
+
await this.compactDB(level, now);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
if (err !== ErrNoCompaction && err !== ErrCompactionTooEarly) {
|
|
524
|
+
this.logger.error('replica compaction error', err);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
levelNext.set(level, nextCompactionAt(now, levels.level(level).intervalMs));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Snapshot + snapshot retention.
|
|
533
|
+
if (snapshotIntervalMs > 0 && now >= snapshotNext) {
|
|
534
|
+
try {
|
|
535
|
+
await this.compactSnapshot(now, snapshotIntervalMs);
|
|
536
|
+
const retention = opt.snapshotRetentionMs ?? 0;
|
|
537
|
+
if (retention > 0 || (opt.snapshotRetain ?? 0) > 0) {
|
|
538
|
+
await this.enforceSnapshotRetention(now, retention, opt.snapshotRetain ?? 0);
|
|
539
|
+
}
|
|
540
|
+
} catch (err) {
|
|
541
|
+
this.logger.error('replica snapshot error', err);
|
|
542
|
+
}
|
|
543
|
+
snapshotNext = nextCompactionAt(now, snapshotIntervalMs);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Retention (legacy interval-driven or L0 time-based).
|
|
547
|
+
if (retentionIntervalMs > 0 && now - lastRetention >= retentionIntervalMs) {
|
|
548
|
+
try {
|
|
549
|
+
await this.enforceRetention(opt.retention ?? {});
|
|
550
|
+
} catch (err) {
|
|
551
|
+
this.logger.error('replica retention error', err);
|
|
552
|
+
}
|
|
553
|
+
lastRetention = Date.now();
|
|
554
|
+
}
|
|
555
|
+
if (l0RetentionMs > 0 && now >= l0RetentionNext) {
|
|
556
|
+
try {
|
|
557
|
+
await this.enforceL0RetentionByTime(now, l0RetentionMs);
|
|
558
|
+
} catch (err) {
|
|
559
|
+
this.logger.error('replica l0 retention error', err);
|
|
560
|
+
}
|
|
561
|
+
l0RetentionNext = now + l0RetentionCheckIntervalMs;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (signal?.aborted) break;
|
|
565
|
+
try {
|
|
566
|
+
await sleep(intervalMs, undefined, { signal });
|
|
567
|
+
} catch {
|
|
568
|
+
break; // aborted during the sleep
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Stop the renewal timer and release the lease so another host can take
|
|
573
|
+
// over promptly.
|
|
574
|
+
this.stopLeaseRenewal();
|
|
575
|
+
if (leaser !== null && this.leaseCurrent !== null) {
|
|
576
|
+
try {
|
|
577
|
+
await leaser.releaseLease(this.leaseCurrent);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
this.logger.debug('lease release error', err);
|
|
580
|
+
}
|
|
581
|
+
this.leaseCurrent = null;
|
|
582
|
+
this.leasePrimary = false;
|
|
583
|
+
}
|
|
584
|
+
this.runActive = false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Schedules the next lease renewal to fire just before expiry, independent of
|
|
589
|
+
* the run-loop cadence. Reschedules after every successful renewal.
|
|
590
|
+
*/
|
|
591
|
+
private scheduleLeaseRenewal(): void {
|
|
592
|
+
this.stopLeaseRenewal();
|
|
593
|
+
if (this.leaserValue === null || this.leaseCurrent === null) return;
|
|
594
|
+
const delay = Math.max(
|
|
595
|
+
100,
|
|
596
|
+
this.leaseCurrent.expiresAt - DefaultLeaseRenewalBuffer - Date.now(),
|
|
597
|
+
);
|
|
598
|
+
this.leaseTimer = setTimeout(() => {
|
|
599
|
+
this.leaseTimer = null;
|
|
600
|
+
void this.renewLeaseLoop();
|
|
601
|
+
}, delay);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Renews the lease and reschedules; reverts to follower if we lost it. */
|
|
605
|
+
private async renewLeaseLoop(): Promise<void> {
|
|
606
|
+
if (!this.runActive || this.leaserValue === null || this.leaseCurrent === null) return;
|
|
607
|
+
try {
|
|
608
|
+
this.leaseCurrent = await this.leaserValue.renewLease(this.leaseCurrent);
|
|
609
|
+
this.leasePrimary = true;
|
|
610
|
+
this.scheduleLeaseRenewal();
|
|
611
|
+
} catch (err) {
|
|
612
|
+
if (err instanceof ErrLeaseNotHeld) {
|
|
613
|
+
this.logger.debug('lost lease; reverting to follower', err);
|
|
614
|
+
this.leasePrimary = false;
|
|
615
|
+
this.leaseCurrent = null;
|
|
616
|
+
this.stopLeaseRenewal();
|
|
617
|
+
} else {
|
|
618
|
+
// Transient error: retry shortly rather than letting the lease expire.
|
|
619
|
+
this.logger.error('lease renew error', err);
|
|
620
|
+
this.scheduleLeaseRenewal();
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Cancels any pending lease renewal. */
|
|
626
|
+
private stopLeaseRenewal(): void {
|
|
627
|
+
if (this.leaseTimer !== null) {
|
|
628
|
+
clearTimeout(this.leaseTimer);
|
|
629
|
+
this.leaseTimer = null;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Snapshot writes and uploads a level-9 full-database LTX file. */
|
|
634
|
+
async snapshot(): Promise<FileInfo | null> {
|
|
635
|
+
this.assertCanWrite();
|
|
636
|
+
const info = await (await this.localDb()).snapshot();
|
|
637
|
+
if (info) {
|
|
638
|
+
await this.uploadLtxFile(9, info.minTXID, info.maxTXID);
|
|
639
|
+
}
|
|
640
|
+
return info;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** SyncStatus reports the local and remote positions. */
|
|
644
|
+
async syncStatus(): Promise<SyncStatus> {
|
|
645
|
+
const localTXID = await this.localPos();
|
|
646
|
+
const remoteTXID = await this.calcPos();
|
|
647
|
+
return { localTXID, remoteTXID, inSync: localTXID === remoteTXID };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Uploads a single local LTX file to the client. */
|
|
651
|
+
private async uploadLtxFile(level: number, minTXID: bigint, maxTXID: bigint): Promise<FileInfo> {
|
|
652
|
+
const p = (await this.localDb()).ltxPath(level, minTXID, maxTXID);
|
|
653
|
+
// Stream the local LTX file straight to the client (no whole-file buffer).
|
|
654
|
+
const body = createReadStream(p) as unknown as AsyncIterable<Uint8Array>;
|
|
655
|
+
const info = await this.client.writeLtxFile({ level, minTXID, maxTXID }, body);
|
|
656
|
+
this.logger.debug('ltx file uploaded', { level, minTXID, maxTXID, size: info.size });
|
|
657
|
+
return info;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** EnsureExists restores the local database if it is missing. */
|
|
661
|
+
async ensureExists(): Promise<void> {
|
|
662
|
+
try {
|
|
663
|
+
await access(this.path);
|
|
664
|
+
return;
|
|
665
|
+
} catch {
|
|
666
|
+
/* missing; restore below */
|
|
667
|
+
}
|
|
668
|
+
await this.restore({ outputPath: this.path });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Restore reconstructs a SQLite database at `outputPath` from LTX files. */
|
|
672
|
+
async restore(opt: RestoreOptions): Promise<void> {
|
|
673
|
+
if (!opt.outputPath) throw new InvalidArgumentError('output path required');
|
|
674
|
+
|
|
675
|
+
const plan = await calcRestorePlan(this.client, opt.txID ?? 0n);
|
|
676
|
+
if (plan.length === 0) throw new NoMatchingBackupError();
|
|
677
|
+
|
|
678
|
+
await mkdir(nodePath.dirname(opt.outputPath), { recursive: true });
|
|
679
|
+
const tmp = `${opt.outputPath}.tmp`;
|
|
680
|
+
|
|
681
|
+
let fh: FileHandle | null = null;
|
|
682
|
+
const sources: Array<{ destroy?: () => void }> = [];
|
|
683
|
+
try {
|
|
684
|
+
fh = await open(tmp, 'w');
|
|
685
|
+
|
|
686
|
+
// The first plan file is the base (a snapshot). Stream its pages
|
|
687
|
+
// sequentially into the output, then overlay each later file's pages by
|
|
688
|
+
// offset, so a later version of a page wins. Peak memory stays bounded by
|
|
689
|
+
// one page buffer + the (small) page index, never the whole DB.
|
|
690
|
+
const openReader = (info: FileInfo): Promise<StreamLtxReader> => this.openStreamReader(info, sources);
|
|
691
|
+
|
|
692
|
+
const base = await openReader(plan[0]);
|
|
693
|
+
await base.readHeader();
|
|
694
|
+
const pageSize = base.Header().pageSize;
|
|
695
|
+
let commit = base.Header().commit;
|
|
696
|
+
await writeSnapshotToFile(fh, base, pageSize, commit);
|
|
697
|
+
await base.verify();
|
|
698
|
+
|
|
699
|
+
for (let i = 1; i < plan.length; i++) {
|
|
700
|
+
const reader = await openReader(plan[i]);
|
|
701
|
+
await reader.readHeader();
|
|
702
|
+
const hdr = reader.Header();
|
|
703
|
+
if (hdr.pageSize !== pageSize) {
|
|
704
|
+
throw new PageSizeMismatchError(`mismatched page sizes: ${pageSize} != ${hdr.pageSize}`);
|
|
705
|
+
}
|
|
706
|
+
commit = hdr.commit;
|
|
707
|
+
await overlayPagesToFile(fh, reader, pageSize);
|
|
708
|
+
await reader.verify();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// The final database size is the last file's committed page count.
|
|
712
|
+
await fh.truncate(commit * pageSize);
|
|
713
|
+
await fh.sync();
|
|
714
|
+
await fh.close();
|
|
715
|
+
fh = null;
|
|
716
|
+
|
|
717
|
+
await rename(tmp, opt.outputPath);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
if (fh !== null) await fh.close().catch(() => {});
|
|
720
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
721
|
+
throw err;
|
|
722
|
+
} finally {
|
|
723
|
+
// Release any not-yet-drained input streams (avoids leaking fds/connections
|
|
724
|
+
// on a corrupt/truncated backup). Destroying an ended stream is a no-op.
|
|
725
|
+
for (const s of sources) {
|
|
726
|
+
try {
|
|
727
|
+
s.destroy?.();
|
|
728
|
+
} catch {
|
|
729
|
+
/* ignore */
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Follow continuously restores new LTX files into {@link FollowOptions.outputPath},
|
|
737
|
+
* keeping a read-only replica of the database up to date with the primary.
|
|
738
|
+
*
|
|
739
|
+
* On the first call (no output DB yet, or the replica is empty) it restores the
|
|
740
|
+
* latest backup as the base, then polls (every {@link FollowOptions.intervalMs})
|
|
741
|
+
* and applies any newly replicated LTX files as they appear. It records the
|
|
742
|
+
* applied TXID in a `-txid` sidecar after each batch so a later `follow()` can
|
|
743
|
+
* resume, and the loop runs until {@link FollowOptions.signal} is aborted.
|
|
744
|
+
*
|
|
745
|
+
* The restored database must only be opened **read-only**; writing to it while
|
|
746
|
+
* follow runs will conflict with the pages we overlay.
|
|
747
|
+
*/
|
|
748
|
+
async follow(opt: FollowOptions): Promise<void> {
|
|
749
|
+
const { outputPath, signal } = opt;
|
|
750
|
+
if (!outputPath) throw new InvalidArgumentError('output path required');
|
|
751
|
+
const intervalMs = opt.intervalMs ?? 1000;
|
|
752
|
+
const txidPath = opt.txidFile ?? `${outputPath}-txid`;
|
|
753
|
+
if (intervalMs < 1) throw new InvalidArgumentError('intervalMs must be >= 1');
|
|
754
|
+
|
|
755
|
+
// Determine the resume point. We must know the last applied TXID so we only
|
|
756
|
+
// apply files that come strictly after it. If the DB is missing we do a
|
|
757
|
+
// fresh latest restore; if it exists we require the -txid sidecar to know
|
|
758
|
+
// where to resume (otherwise we cannot prove the file matches the replica).
|
|
759
|
+
let currentMax: bigint;
|
|
760
|
+
let pageSize: number;
|
|
761
|
+
|
|
762
|
+
const dbExists = await statSafe(outputPath);
|
|
763
|
+
const txidExists = await statSafe(txidPath);
|
|
764
|
+
|
|
765
|
+
if (!dbExists) {
|
|
766
|
+
const plan = await this.waitForInitialRestore(outputPath, intervalMs, signal);
|
|
767
|
+
currentMax = plan[plan.length - 1].maxTXID;
|
|
768
|
+
await writeTxidFile(txidPath, currentMax);
|
|
769
|
+
pageSize = await readDbPageSize(outputPath);
|
|
770
|
+
} else {
|
|
771
|
+
if (!txidExists) {
|
|
772
|
+
throw new FollowMissingTxidError(
|
|
773
|
+
`database ${outputPath} exists but ${txidPath} is missing; ` +
|
|
774
|
+
'delete the database and re-run follow',
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
const saved = await readTxidFile(txidPath);
|
|
778
|
+
if (saved === null) throw new FollowInvalidTxidError(`invalid TXID in ${txidPath}`);
|
|
779
|
+
currentMax = saved;
|
|
780
|
+
pageSize = await readDbPageSize(outputPath);
|
|
781
|
+
await this.validateResumePoint(currentMax, outputPath, txidPath);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Poll and apply new files until aborted. Batches are applied atomically-ish:
|
|
785
|
+
// overlay pages, truncate, fsync, THEN advance the -txid sidecar. If we crash
|
|
786
|
+
// mid-batch the sidecar is stale and the next run re-applies the same files
|
|
787
|
+
// (LTX files are immutable and page content is deterministic, so this is safe).
|
|
788
|
+
while (!signal?.aborted) {
|
|
789
|
+
const batch = await this.collectFollowBatch(currentMax);
|
|
790
|
+
if (batch.length > 0) {
|
|
791
|
+
currentMax = await this.applyFollowBatch(outputPath, batch, currentMax, pageSize);
|
|
792
|
+
await writeTxidFile(txidPath, currentMax);
|
|
793
|
+
}
|
|
794
|
+
if (signal?.aborted) break;
|
|
795
|
+
try {
|
|
796
|
+
await sleep(intervalMs, undefined, { signal });
|
|
797
|
+
} catch {
|
|
798
|
+
break; // aborted during the sleep
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Guards a follow() resume against an inconsistent state: if the saved TXID is
|
|
805
|
+
* ahead of every TXID the replica holds, the DB/sidecar cannot be trusted and
|
|
806
|
+
* must be re-restored. (A saved TXID below the retained window is caught later
|
|
807
|
+
* as a data gap in {@link collectFollowBatch}.)
|
|
808
|
+
*/
|
|
809
|
+
private async validateResumePoint(saved: bigint, outputPath: string, txidPath: string): Promise<void> {
|
|
810
|
+
let latestMax = 0n;
|
|
811
|
+
for (let level = 0; level <= SnapshotLevel; level++) {
|
|
812
|
+
for await (const info of this.client.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
813
|
+
if (info.maxTXID > latestMax) latestMax = info.maxTXID;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (latestMax !== 0n && saved > latestMax) {
|
|
817
|
+
throw new FollowAheadOfLatestError(
|
|
818
|
+
`saved TXID ${hexTxid(saved)} is ahead of the latest snapshot ` +
|
|
819
|
+
`(${hexTxid(latestMax)}); delete ${outputPath} and ${txidPath} then re-restore`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Waits for the first backup to appear on an empty replica, then restores it
|
|
826
|
+
* as the follow base. Returns the plan that was applied.
|
|
827
|
+
*/
|
|
828
|
+
private async waitForInitialRestore(
|
|
829
|
+
outputPath: string,
|
|
830
|
+
intervalMs: number,
|
|
831
|
+
signal?: AbortSignal,
|
|
832
|
+
): Promise<FileInfo[]> {
|
|
833
|
+
for (;;) {
|
|
834
|
+
if (signal?.aborted) throw new FollowAbortedError('aborted before the initial restore');
|
|
835
|
+
try {
|
|
836
|
+
const plan = await calcRestorePlan(this.client, 0n);
|
|
837
|
+
await this.restore({ outputPath });
|
|
838
|
+
return plan;
|
|
839
|
+
} catch (err) {
|
|
840
|
+
if (signal?.aborted) throw err;
|
|
841
|
+
// An empty replica is the only case we retry; anything else is a real error.
|
|
842
|
+
if (!(err instanceof NoMatchingBackupError)) {
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
845
|
+
await sleep(intervalMs, undefined, { signal });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Returns the contiguous run of LTX files (across all compaction levels) that
|
|
852
|
+
* extend the applied position `currentMax`, in application order. Prefers the
|
|
853
|
+
* highest (most compacted) level for a range and stops at the first gap. Throws
|
|
854
|
+
* if the very next file (currentMax + 1) is missing, e.g. retention pruned it.
|
|
855
|
+
*/
|
|
856
|
+
private async collectFollowBatch(currentMax: bigint): Promise<FileInfo[]> {
|
|
857
|
+
const candidates: FileInfo[] = [];
|
|
858
|
+
for (let level = 0; level <= SnapshotLevel; level++) {
|
|
859
|
+
for await (const info of this.client.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
860
|
+
if (info.maxTXID <= currentMax) continue;
|
|
861
|
+
candidates.push(info);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
candidates.sort((a, b) =>
|
|
865
|
+
a.minTXID !== b.minTXID ? (a.minTXID < b.minTXID ? -1 : 1) : b.level - a.level,
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const batch: FileInfo[] = [];
|
|
869
|
+
let cursor = currentMax;
|
|
870
|
+
const seen: Array<[bigint, bigint]> = [];
|
|
871
|
+
for (const info of candidates) {
|
|
872
|
+
if (info.maxTXID <= cursor) continue;
|
|
873
|
+
if (info.minTXID > cursor + 1n) {
|
|
874
|
+
if (batch.length === 0) {
|
|
875
|
+
throw new FollowDataGapError(
|
|
876
|
+
`saved TXID ${hexTxid(currentMax)} is older than the earliest ` +
|
|
877
|
+
'retained snapshot; retention has pruned the required history; delete the ' +
|
|
878
|
+
'database and re-restore',
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
if (seen.some(([lo, hi]) => lo <= info.minTXID && info.maxTXID <= hi)) continue;
|
|
884
|
+
batch.push(info);
|
|
885
|
+
cursor = info.maxTXID;
|
|
886
|
+
seen.push([info.minTXID, info.maxTXID]);
|
|
887
|
+
}
|
|
888
|
+
return batch;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Applies a contiguous batch of LTX files to the existing `outputPath` DB by
|
|
893
|
+
* overlaying each file's pages (a later page wins) and truncating to the final
|
|
894
|
+
* commit size. Returns the TXID now applied.
|
|
895
|
+
*/
|
|
896
|
+
private async applyFollowBatch(
|
|
897
|
+
outputPath: string,
|
|
898
|
+
batch: FileInfo[],
|
|
899
|
+
currentMax: bigint,
|
|
900
|
+
pageSize: number,
|
|
901
|
+
): Promise<bigint> {
|
|
902
|
+
const sources: Array<{ destroy?: () => void }> = [];
|
|
903
|
+
let fh: FileHandle | null = null;
|
|
904
|
+
let commit = 0;
|
|
905
|
+
let appliedMax = currentMax;
|
|
906
|
+
try {
|
|
907
|
+
fh = await open(outputPath, 'r+');
|
|
908
|
+
for (const info of batch) {
|
|
909
|
+
const reader = await this.openStreamReader(info, sources);
|
|
910
|
+
await reader.readHeader();
|
|
911
|
+
const hdr = reader.Header();
|
|
912
|
+
if (hdr.pageSize !== pageSize) {
|
|
913
|
+
throw new PageSizeMismatchError(`mismatched page sizes: ${pageSize} != ${hdr.pageSize}`);
|
|
914
|
+
}
|
|
915
|
+
commit = hdr.commit;
|
|
916
|
+
await overlayPagesToFile(fh, reader, pageSize);
|
|
917
|
+
await reader.verify();
|
|
918
|
+
appliedMax = info.maxTXID;
|
|
919
|
+
}
|
|
920
|
+
await fh.truncate(commit * pageSize);
|
|
921
|
+
await fh.sync();
|
|
922
|
+
await fh.close();
|
|
923
|
+
fh = null;
|
|
924
|
+
} catch (err) {
|
|
925
|
+
if (fh !== null) await fh.close().catch(() => {});
|
|
926
|
+
throw err;
|
|
927
|
+
} finally {
|
|
928
|
+
for (const s of sources) {
|
|
929
|
+
try {
|
|
930
|
+
s.destroy?.();
|
|
931
|
+
} catch {
|
|
932
|
+
/* ignore */
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return appliedMax;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Opens a streaming LTX reader over a plan file from the client. */
|
|
940
|
+
private async openStreamReader(
|
|
941
|
+
info: FileInfo,
|
|
942
|
+
sources?: Array<{ destroy?: () => void }>,
|
|
943
|
+
): Promise<StreamLtxReader> {
|
|
944
|
+
const rc = await this.client.openLtxFile({
|
|
945
|
+
level: info.level,
|
|
946
|
+
minTXID: info.minTXID,
|
|
947
|
+
maxTXID: info.maxTXID,
|
|
948
|
+
offset: 0,
|
|
949
|
+
size: 0,
|
|
950
|
+
});
|
|
951
|
+
if (sources && typeof (rc as { destroy?: unknown }).destroy === 'function') {
|
|
952
|
+
sources.push({ destroy: () => (rc as { destroy: () => void }).destroy() });
|
|
953
|
+
}
|
|
954
|
+
return new StreamLtxReader(rc as unknown as AsyncIterable<Uint8Array>);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Close releases any internal database connection. */
|
|
958
|
+
close(): void {
|
|
959
|
+
if (this.db !== null) {
|
|
960
|
+
this.db.close();
|
|
961
|
+
this.db = null;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Compact merges source LTX files into the next level. If `dstLevel` is
|
|
967
|
+
* given, compacts that level; otherwise compacts the first level (1..max)
|
|
968
|
+
* that has pending source files. Returns the written file, or null if there
|
|
969
|
+
* was nothing to compact.
|
|
970
|
+
*/
|
|
971
|
+
async compact(options: CompactOptions = {}): Promise<FileInfo | null> {
|
|
972
|
+
this.assertCanWrite();
|
|
973
|
+
if (options.dstLevel !== undefined) {
|
|
974
|
+
if (options.dstLevel < 1 || options.dstLevel > this.maxCompactionLevel) return null;
|
|
975
|
+
return this.compactLevel(options.dstLevel);
|
|
976
|
+
}
|
|
977
|
+
for (let dst = 1; dst <= this.maxCompactionLevel; dst++) {
|
|
978
|
+
const info = await this.compactLevel(dst);
|
|
979
|
+
if (info) return info;
|
|
980
|
+
}
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* CompactLevel compacts pending files at `dstLevel - 1` into a single file at
|
|
986
|
+
* `dstLevel`. It streams readers (local cache preferred, client fallback),
|
|
987
|
+
* writes the result locally via a file-backed sink, uploads it to the client,
|
|
988
|
+
* and returns the new file's metadata. Source files are left in place; GC is
|
|
989
|
+
* performed by {@link enforceRetention}.
|
|
990
|
+
*/
|
|
991
|
+
async compactLevel(dstLevel: number): Promise<FileInfo | null> {
|
|
992
|
+
this.assertCanWrite();
|
|
993
|
+
if (dstLevel < 1 || dstLevel > this.maxCompactionLevel) return null;
|
|
994
|
+
const srcLevel = dstLevel - 1;
|
|
995
|
+
|
|
996
|
+
// Determine the max TXID already present at the destination level; only
|
|
997
|
+
// source files after it remain to be compacted.
|
|
998
|
+
let dstMax = 0n;
|
|
999
|
+
for await (const info of this.client.ltxFiles({ level: dstLevel, seek: 0n, useMetadata: false })) {
|
|
1000
|
+
if (info.maxTXID > dstMax) dstMax = info.maxTXID;
|
|
1001
|
+
}
|
|
1002
|
+
const seek = dstMax + 1n;
|
|
1003
|
+
|
|
1004
|
+
const sources: FileInfo[] = [];
|
|
1005
|
+
for await (const info of this.client.ltxFiles({ level: srcLevel, seek, useMetadata: false })) {
|
|
1006
|
+
sources.push(info);
|
|
1007
|
+
}
|
|
1008
|
+
if (sources.length === 0) return null;
|
|
1009
|
+
|
|
1010
|
+
let minTXID = sources[0].minTXID;
|
|
1011
|
+
let maxTXID = sources[0].maxTXID;
|
|
1012
|
+
for (const info of sources) {
|
|
1013
|
+
if (info.minTXID < minTXID) minTXID = info.minTXID;
|
|
1014
|
+
if (info.maxTXID > maxTXID) maxTXID = info.maxTXID;
|
|
1015
|
+
}
|
|
1016
|
+
if (minTXID < seek) {
|
|
1017
|
+
throw new LTXFormatError(
|
|
1018
|
+
`compaction source ${minTXID}..${maxTXID} overlaps the destination max ${dstMax}`,
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const cleanup: Array<{ destroy?: () => void }> = [];
|
|
1023
|
+
const readers: StreamLtxReader[] = [];
|
|
1024
|
+
const db = await this.localDb();
|
|
1025
|
+
const outPath = db.ltxPath(dstLevel, minTXID, maxTXID);
|
|
1026
|
+
const tmpPath = `${outPath}.tmp`;
|
|
1027
|
+
await mkdir(nodePath.dirname(outPath), { recursive: true });
|
|
1028
|
+
const fh = await open(tmpPath, 'w');
|
|
1029
|
+
try {
|
|
1030
|
+
for (const info of sources) {
|
|
1031
|
+
readers.push(await this.openSourceReader(info, cleanup));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const sink = new FileSink(fh);
|
|
1035
|
+
const compactor = new StreamingCompactor(sink, readers);
|
|
1036
|
+
await compactor.compact();
|
|
1037
|
+
const cHeader = compactor.Header();
|
|
1038
|
+
if (cHeader.pageSize !== (await this.localDb()).pageSize) {
|
|
1039
|
+
throw new PageSizeMismatchError(
|
|
1040
|
+
`compacted page size ${cHeader.pageSize} does not match database ${(await this.localDb()).pageSize}`,
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
await sink.flush();
|
|
1044
|
+
await fh.close();
|
|
1045
|
+
|
|
1046
|
+
await rename(tmpPath, outPath);
|
|
1047
|
+
|
|
1048
|
+
const size = (await stat(outPath)).size;
|
|
1049
|
+
await this.uploadLtxFile(dstLevel, minTXID, maxTXID);
|
|
1050
|
+
return { level: dstLevel, minTXID, maxTXID, size, createdAt: Date.now() };
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
await fh.close().catch(() => {});
|
|
1053
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
1054
|
+
throw err;
|
|
1055
|
+
} finally {
|
|
1056
|
+
for (const s of cleanup) {
|
|
1057
|
+
try {
|
|
1058
|
+
s.destroy?.();
|
|
1059
|
+
} catch {
|
|
1060
|
+
/* ignore */
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* CompactDB compacts `dstLevel` if it is time to do so and there is pending
|
|
1068
|
+
* source data. Mirrors litestream's `Store.CompactDB`: it refuses to re-compact
|
|
1069
|
+
* within the level's current time bucket (throws {@link ErrCompactionTooEarly})
|
|
1070
|
+
* and skips when there is nothing to do (throws {@link ErrNoCompaction}).
|
|
1071
|
+
*/
|
|
1072
|
+
async compactDB(dstLevel: number, now: number): Promise<FileInfo | null> {
|
|
1073
|
+
this.assertCanWrite();
|
|
1074
|
+
if (dstLevel === SnapshotLevel) return this.compactSnapshot(now, 0);
|
|
1075
|
+
const lvl = this.compactionLevels.level(dstLevel);
|
|
1076
|
+
const prevAt = prevCompactionAt(now, lvl.intervalMs);
|
|
1077
|
+
const dstInfo = await this.maxLTXFileInfo(dstLevel);
|
|
1078
|
+
if (dstInfo && dstInfo.createdAt > prevAt) throw ErrCompactionTooEarly;
|
|
1079
|
+
|
|
1080
|
+
const srcLevel = this.compactionLevels.prevLevel(dstLevel);
|
|
1081
|
+
const srcInfo = await this.maxLTXFileInfo(srcLevel);
|
|
1082
|
+
if (srcInfo && dstInfo && srcInfo.maxTXID <= dstInfo.minTXID) throw ErrNoCompaction;
|
|
1083
|
+
|
|
1084
|
+
const info = await this.compactLevel(dstLevel);
|
|
1085
|
+
if (info === null) throw ErrNoCompaction;
|
|
1086
|
+
return info;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* CompactSnapshot takes a level-9 snapshot if it is time to do so and the
|
|
1091
|
+
* database has advanced past the current snapshot. Mirrors `Store.CompactDB`
|
|
1092
|
+
* for the snapshot level.
|
|
1093
|
+
*/
|
|
1094
|
+
async compactSnapshot(now: number, intervalMs: number): Promise<FileInfo | null> {
|
|
1095
|
+
this.assertCanWrite();
|
|
1096
|
+
const prevAt = prevCompactionAt(now, intervalMs);
|
|
1097
|
+
const dstInfo = await this.maxLTXFileInfo(SnapshotLevel);
|
|
1098
|
+
if (dstInfo && dstInfo.createdAt > prevAt) throw ErrCompactionTooEarly;
|
|
1099
|
+
const pos = await (await this.localDb()).pos();
|
|
1100
|
+
if (dstInfo && dstInfo.maxTXID !== 0n && dstInfo.maxTXID >= pos) throw ErrNoCompaction;
|
|
1101
|
+
return this.snapshot();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** MaxLTXFileInfo returns the highest-TXID file at a level, or null. */
|
|
1105
|
+
async maxLTXFileInfo(level: number): Promise<FileInfo | null> {
|
|
1106
|
+
let max: FileInfo | null = null;
|
|
1107
|
+
for await (const info of this.client.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
1108
|
+
if (max === null || info.maxTXID > max.maxTXID) max = info;
|
|
1109
|
+
}
|
|
1110
|
+
return max;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* EnforceRetention prunes old snapshots and superseded files.
|
|
1115
|
+
*
|
|
1116
|
+
* 1. Snapshots older than `snapshotRetentionMs` are deleted (the newest is
|
|
1117
|
+
* always kept; `snapshotRetain` may cap how many newest are kept).
|
|
1118
|
+
* 2. The resulting snapshot boundary is cascaded down through levels 0..max:
|
|
1119
|
+
* any file whose maxTXID is below the oldest retained snapshot's maxTXID
|
|
1120
|
+
* is deleted (it is superseded by that snapshot).
|
|
1121
|
+
* 3. Level-0 files that have been compacted into a higher level and are older
|
|
1122
|
+
* than `l0RetentionMs` are deleted (except the newest, the live head).
|
|
1123
|
+
*/
|
|
1124
|
+
async enforceRetention(options: RetentionOptions = {}): Promise<RetentionResult> {
|
|
1125
|
+
this.assertCanWrite();
|
|
1126
|
+
const now = Date.now();
|
|
1127
|
+
const snapshotRetentionMs = options.snapshotRetentionMs ?? 0;
|
|
1128
|
+
const snapshotRetain = options.snapshotRetain ?? 0;
|
|
1129
|
+
const l0RetentionMs = options.l0RetentionMs ?? 0;
|
|
1130
|
+
|
|
1131
|
+
const { deleted: snapshotsDeleted, boundary } = await this.enforceSnapshotRetention(
|
|
1132
|
+
now,
|
|
1133
|
+
snapshotRetentionMs,
|
|
1134
|
+
snapshotRetain,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
let filesDeleted = 0;
|
|
1138
|
+
if (boundary > 0n) {
|
|
1139
|
+
for (let level = 0; level <= this.maxCompactionLevel; level++) {
|
|
1140
|
+
filesDeleted += await this.enforceRetentionByTXID(level, boundary);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
filesDeleted += await this.enforceL0RetentionByTime(now, l0RetentionMs);
|
|
1144
|
+
|
|
1145
|
+
return { snapshotsDeleted, filesDeleted, boundary };
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* EnforceSnapshotRetention deletes snapshots older than `retentionMs` and
|
|
1150
|
+
* returns the boundary (max maxTXID of the deleted snapshots). The newest
|
|
1151
|
+
* snapshot is always kept; `retain` may keep additional newest snapshots.
|
|
1152
|
+
*/
|
|
1153
|
+
async enforceSnapshotRetention(
|
|
1154
|
+
now: number,
|
|
1155
|
+
retentionMs: number,
|
|
1156
|
+
retain = 0,
|
|
1157
|
+
): Promise<{ deleted: number; boundary: bigint }> {
|
|
1158
|
+
this.assertCanWrite();
|
|
1159
|
+
const snapshots = await this.listRemoteLevel(SnapshotLevel);
|
|
1160
|
+
snapshots.sort((a, b) => (a.maxTXID < b.maxTXID ? -1 : a.maxTXID > b.maxTXID ? 1 : 0));
|
|
1161
|
+
if (snapshots.length === 0) return { deleted: 0, boundary: 0n };
|
|
1162
|
+
|
|
1163
|
+
const cutoff = retentionMs > 0 ? now - retentionMs : 0;
|
|
1164
|
+
let retained = snapshots.filter((s) => !(retentionMs > 0 && s.createdAt < cutoff));
|
|
1165
|
+
const newest = snapshots[snapshots.length - 1];
|
|
1166
|
+
if (!retained.includes(newest)) retained.push(newest);
|
|
1167
|
+
retained.sort((a, b) => (a.maxTXID < b.maxTXID ? -1 : a.maxTXID > b.maxTXID ? 1 : 0));
|
|
1168
|
+
if (retain > 0 && retained.length > retain) retained = retained.slice(retained.length - retain);
|
|
1169
|
+
|
|
1170
|
+
const retainedSet = new Set(retained);
|
|
1171
|
+
const deletedFiles = snapshots.filter((s) => !retainedSet.has(s));
|
|
1172
|
+
await this.deleteFiles(deletedFiles);
|
|
1173
|
+
|
|
1174
|
+
let boundary = 0n;
|
|
1175
|
+
for (const d of deletedFiles) if (d.maxTXID > boundary) boundary = d.maxTXID;
|
|
1176
|
+
return { deleted: deletedFiles.length, boundary };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* EnforceRetentionByTXID deletes files at `level` with maxTXID below `txID`,
|
|
1181
|
+
* always keeping at least one file. Returns the number deleted.
|
|
1182
|
+
*/
|
|
1183
|
+
async enforceRetentionByTXID(level: number, txID: bigint): Promise<number> {
|
|
1184
|
+
this.assertCanWrite();
|
|
1185
|
+
if (txID <= 0n) return 0;
|
|
1186
|
+
const files = await this.listRemoteLevel(level);
|
|
1187
|
+
files.sort((a, b) => (a.maxTXID < b.maxTXID ? -1 : a.maxTXID > b.maxTXID ? 1 : 0));
|
|
1188
|
+
if (files.length === 0) return 0;
|
|
1189
|
+
|
|
1190
|
+
const last = files[files.length - 1];
|
|
1191
|
+
const deletedFiles = files.filter((f) => f.maxTXID < txID);
|
|
1192
|
+
if (deletedFiles.length > 0 && deletedFiles[deletedFiles.length - 1] === last) {
|
|
1193
|
+
deletedFiles.pop(); // always keep at least one file
|
|
1194
|
+
}
|
|
1195
|
+
await this.deleteFiles(deletedFiles);
|
|
1196
|
+
return deletedFiles.length;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* EnforceL0RetentionByTime deletes level-0 files that have been compacted into
|
|
1201
|
+
* a higher level and are older than `l0RetentionMs`. It never deletes the
|
|
1202
|
+
* newest level-0 file (the live head) and preserves contiguous L0 coverage by
|
|
1203
|
+
* stopping at the first file that is not old enough.
|
|
1204
|
+
*/
|
|
1205
|
+
async enforceL0RetentionByTime(now: number, l0RetentionMs: number): Promise<number> {
|
|
1206
|
+
this.assertCanWrite();
|
|
1207
|
+
const l0 = await this.listRemoteLevel(0);
|
|
1208
|
+
l0.sort((a, b) => (a.maxTXID < b.maxTXID ? -1 : a.maxTXID > b.maxTXID ? 1 : 0));
|
|
1209
|
+
if (l0.length === 0) return 0;
|
|
1210
|
+
|
|
1211
|
+
const newest = l0[l0.length - 1];
|
|
1212
|
+
const coverers: FileInfo[] = [];
|
|
1213
|
+
for (let level = 1; level <= this.maxCompactionLevel; level++) {
|
|
1214
|
+
for await (const info of this.client.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
1215
|
+
coverers.push(info);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const threshold = now - l0RetentionMs;
|
|
1220
|
+
const deletedFiles: FileInfo[] = [];
|
|
1221
|
+
for (const info of l0) {
|
|
1222
|
+
if (info === newest) continue; // never delete the live head
|
|
1223
|
+
if (info.createdAt > threshold) break; // preserve contiguous L0 coverage
|
|
1224
|
+
if (this.isCoveredByHigherLevel(info, coverers)) deletedFiles.push(info);
|
|
1225
|
+
}
|
|
1226
|
+
await this.deleteFiles(deletedFiles);
|
|
1227
|
+
return deletedFiles.length;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** Returns true if `info` is fully contained in a file at a higher level. */
|
|
1231
|
+
private isCoveredByHigherLevel(info: FileInfo, coverers: FileInfo[]): boolean {
|
|
1232
|
+
for (const g of coverers) {
|
|
1233
|
+
if (g.level <= info.level) continue;
|
|
1234
|
+
if (g.minTXID <= info.minTXID && info.maxTXID <= g.maxTXID) return true;
|
|
1235
|
+
}
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** Lists LTX files at a remote level. */
|
|
1240
|
+
private async listRemoteLevel(level: number): Promise<FileInfo[]> {
|
|
1241
|
+
const out: FileInfo[] = [];
|
|
1242
|
+
for await (const info of this.client.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
1243
|
+
out.push(info);
|
|
1244
|
+
}
|
|
1245
|
+
return out;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** Deletes files from the client and the local cache. */
|
|
1249
|
+
private async deleteFiles(files: FileInfo[]): Promise<void> {
|
|
1250
|
+
if (files.length === 0) return;
|
|
1251
|
+
this.logger.debug('deleting ltx files', { count: files.length });
|
|
1252
|
+
await this.client.deleteLtxFiles(files);
|
|
1253
|
+
const db = await this.localDb();
|
|
1254
|
+
for (const f of files) {
|
|
1255
|
+
const p = db.ltxPath(f.level, f.minTXID, f.maxTXID);
|
|
1256
|
+
await rm(p, { force: true }).catch(() => {});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Opens a streaming LTX reader for a source file, preferring the local cache
|
|
1262
|
+
* and falling back to the client. Collects cleanup callbacks for the streams.
|
|
1263
|
+
*/
|
|
1264
|
+
private async openSourceReader(
|
|
1265
|
+
info: FileInfo,
|
|
1266
|
+
cleanup: Array<{ destroy?: () => void }>,
|
|
1267
|
+
): Promise<StreamLtxReader> {
|
|
1268
|
+
const db = await this.localDb();
|
|
1269
|
+
const localPath = db.ltxPath(info.level, info.minTXID, info.maxTXID);
|
|
1270
|
+
try {
|
|
1271
|
+
await access(localPath);
|
|
1272
|
+
const s = createReadStream(localPath);
|
|
1273
|
+
cleanup.push({ destroy: () => s.destroy() });
|
|
1274
|
+
return new StreamLtxReader(s as unknown as AsyncIterable<Uint8Array>);
|
|
1275
|
+
} catch {
|
|
1276
|
+
/* fall back to the client */
|
|
1277
|
+
}
|
|
1278
|
+
const rc = await this.client.openLtxFile({
|
|
1279
|
+
level: info.level,
|
|
1280
|
+
minTXID: info.minTXID,
|
|
1281
|
+
maxTXID: info.maxTXID,
|
|
1282
|
+
offset: 0,
|
|
1283
|
+
size: 0,
|
|
1284
|
+
});
|
|
1285
|
+
if (typeof (rc as { destroy?: unknown }).destroy === 'function') {
|
|
1286
|
+
cleanup.push({ destroy: () => (rc as { destroy: () => void }).destroy() });
|
|
1287
|
+
}
|
|
1288
|
+
return new StreamLtxReader(rc as unknown as AsyncIterable<Uint8Array>);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* CalcRestorePlan returns LTX files to apply, in order, to reconstruct a
|
|
1294
|
+
* database up to `txID` (0 = latest). It prefers the latest level-9 snapshot
|
|
1295
|
+
* before the target as the base, then appends contiguous files across all
|
|
1296
|
+
* compaction levels (0..maxLevel) that extend the range.
|
|
1297
|
+
*/
|
|
1298
|
+
export async function calcRestorePlan(
|
|
1299
|
+
client: ReplicaClient,
|
|
1300
|
+
txID: bigint,
|
|
1301
|
+
): Promise<FileInfo[]> {
|
|
1302
|
+
const plan: FileInfo[] = [];
|
|
1303
|
+
let currentMax = 0n;
|
|
1304
|
+
|
|
1305
|
+
// Pick the latest level-9 snapshot at or before the target as the base.
|
|
1306
|
+
let base: FileInfo | null = null;
|
|
1307
|
+
for await (const info of client.ltxFiles({ level: SnapshotLevel, seek: 0n, useMetadata: false })) {
|
|
1308
|
+
if (txID !== 0n && info.maxTXID > txID) continue;
|
|
1309
|
+
if (!base || info.maxTXID > base.maxTXID) base = info;
|
|
1310
|
+
}
|
|
1311
|
+
if (base) {
|
|
1312
|
+
plan.push(base);
|
|
1313
|
+
currentMax = base.maxTXID;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Collect candidate files from every compaction level (0..SnapshotLevel-1).
|
|
1317
|
+
// Compaction moves data to higher levels, so a TXID range may be represented
|
|
1318
|
+
// by a file at any level; gather them all and prefer the highest level first.
|
|
1319
|
+
const candidates: FileInfo[] = [];
|
|
1320
|
+
for (let level = 0; level < SnapshotLevel; level++) {
|
|
1321
|
+
for await (const info of client.ltxFiles({ level, seek: 0n, useMetadata: false })) {
|
|
1322
|
+
if (txID !== 0n && info.maxTXID > txID) continue;
|
|
1323
|
+
candidates.push(info);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
// Order by minTXID, then by higher level (more compacted) first, so a file
|
|
1327
|
+
// at a higher level supersedes lower-level files spanning the same range.
|
|
1328
|
+
candidates.sort((a, b) => {
|
|
1329
|
+
if (a.minTXID !== b.minTXID) return a.minTXID < b.minTXID ? -1 : 1;
|
|
1330
|
+
return b.level - a.level;
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
const seenRanges: Array<[bigint, bigint]> = [];
|
|
1334
|
+
for (const info of candidates) {
|
|
1335
|
+
if (info.maxTXID <= currentMax) continue; // covered by the base or earlier
|
|
1336
|
+
if (info.minTXID > currentMax + 1n) break; // non-contiguous gap
|
|
1337
|
+
// Skip a candidate entirely covered by a higher-level file already in the plan.
|
|
1338
|
+
if (seenRanges.some(([lo, hi]) => lo <= info.minTXID && info.maxTXID <= hi)) continue;
|
|
1339
|
+
plan.push(info);
|
|
1340
|
+
currentMax = info.maxTXID;
|
|
1341
|
+
seenRanges.push([info.minTXID, info.maxTXID]);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (plan.length === 0) {
|
|
1345
|
+
throw new NoMatchingBackupError();
|
|
1346
|
+
}
|
|
1347
|
+
if (txID !== 0n && currentMax < txID) {
|
|
1348
|
+
throw new NoMatchingBackupError();
|
|
1349
|
+
}
|
|
1350
|
+
return plan;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Streams a snapshot's pages (1..commit) into the output file. The lock page
|
|
1355
|
+
* is written as a zeroed page (SQLite requires it to exist but never uses it).
|
|
1356
|
+
*/
|
|
1357
|
+
async function writeSnapshotToFile(
|
|
1358
|
+
fh: FileHandle,
|
|
1359
|
+
reader: StreamLtxReader,
|
|
1360
|
+
pageSize: number,
|
|
1361
|
+
commit: number,
|
|
1362
|
+
): Promise<void> {
|
|
1363
|
+
const lockPg = lockPgno(pageSize);
|
|
1364
|
+
const page = new Uint8Array(pageSize);
|
|
1365
|
+
const zero = new Uint8Array(pageSize);
|
|
1366
|
+
|
|
1367
|
+
for (let pgno = 1; pgno <= commit; pgno++) {
|
|
1368
|
+
if (pgno === lockPg) {
|
|
1369
|
+
await writeAllAt(fh, zero, (pgno - 1) * pageSize);
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
const hdr = await reader.readPage(page);
|
|
1373
|
+
if (hdr === null) throw new LTXFormatError(`unexpected end of pages at pgno ${pgno}`);
|
|
1374
|
+
if (hdr.pgno !== pgno) {
|
|
1375
|
+
throw new LTXFormatError(`unexpected pgno while decoding page: read ${hdr.pgno}, expected ${pgno}`);
|
|
1376
|
+
}
|
|
1377
|
+
await writeAllAt(fh, page, (pgno - 1) * pageSize);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Expect one final read to be the end-of-pages marker.
|
|
1381
|
+
if ((await reader.readPage(page)) !== null) {
|
|
1382
|
+
throw new LTXFormatError('unexpected page after end of database');
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** Overlays an incremental file's pages into the output file at their offsets. */
|
|
1387
|
+
async function overlayPagesToFile(
|
|
1388
|
+
fh: FileHandle,
|
|
1389
|
+
reader: StreamLtxReader,
|
|
1390
|
+
pageSize: number,
|
|
1391
|
+
): Promise<void> {
|
|
1392
|
+
const page = new Uint8Array(pageSize);
|
|
1393
|
+
for (;;) {
|
|
1394
|
+
const hdr = await reader.readPage(page);
|
|
1395
|
+
if (hdr === null) break;
|
|
1396
|
+
await writeAllAt(fh, page, (hdr.pgno - 1) * pageSize);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** Writes all of `bytes` to `handle` at an absolute `position`, looping on partial writes. */
|
|
1401
|
+
async function writeAllAt(
|
|
1402
|
+
handle: FileHandle,
|
|
1403
|
+
bytes: Uint8Array,
|
|
1404
|
+
position: number,
|
|
1405
|
+
): Promise<void> {
|
|
1406
|
+
let offset = 0;
|
|
1407
|
+
while (offset < bytes.length) {
|
|
1408
|
+
const { bytesWritten } = await handle.write(
|
|
1409
|
+
bytes,
|
|
1410
|
+
offset,
|
|
1411
|
+
bytes.length - offset,
|
|
1412
|
+
position + offset,
|
|
1413
|
+
);
|
|
1414
|
+
offset += bytesWritten;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** Returns true if `path` exists. */
|
|
1419
|
+
async function statSafe(path: string): Promise<boolean> {
|
|
1420
|
+
try {
|
|
1421
|
+
await stat(path);
|
|
1422
|
+
return true;
|
|
1423
|
+
} catch {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Encodes a TXID as the 16-char lowercase hex used in LTX filenames. */
|
|
1429
|
+
function hexTxid(txid: bigint): string {
|
|
1430
|
+
return txid.toString(16).padStart(16, '0');
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/** Decodes a (possibly trimmed) 1..16 char hex TXID; null on malformed input. */
|
|
1434
|
+
function decodeTxid(s: string): bigint | null {
|
|
1435
|
+
const t = s.trim();
|
|
1436
|
+
if (!/^[0-9a-fA-F]{1,16}$/.test(t)) return null;
|
|
1437
|
+
try {
|
|
1438
|
+
return BigInt(`0x${t}`);
|
|
1439
|
+
} catch {
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** Reads the last applied TXID from a follow sidecar file, or null if absent/invalid. */
|
|
1445
|
+
async function readTxidFile(path: string): Promise<bigint | null> {
|
|
1446
|
+
try {
|
|
1447
|
+
return decodeTxid(await readFile(path, 'utf8'));
|
|
1448
|
+
} catch {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** Atomically writes the applied TXID to a follow sidecar file (temp + rename). */
|
|
1454
|
+
async function writeTxidFile(path: string, txid: bigint): Promise<void> {
|
|
1455
|
+
const tmp = `${path}.tmp`;
|
|
1456
|
+
await mkdir(nodePath.dirname(path), { recursive: true });
|
|
1457
|
+
const fh = await open(tmp, 'w');
|
|
1458
|
+
try {
|
|
1459
|
+
await fh.writeFile(hexTxid(txid) + '\n');
|
|
1460
|
+
await fh.sync();
|
|
1461
|
+
} finally {
|
|
1462
|
+
await fh.close();
|
|
1463
|
+
}
|
|
1464
|
+
await rename(tmp, path);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** Reads the SQLite page size from a database file header (offset 16, BE 16-bit). */
|
|
1468
|
+
async function readDbPageSize(path: string): Promise<number> {
|
|
1469
|
+
const fh = await open(path, 'r');
|
|
1470
|
+
try {
|
|
1471
|
+
const buf = Buffer.alloc(4);
|
|
1472
|
+
const { bytesRead } = await fh.read(buf, 0, 4, 16);
|
|
1473
|
+
if (bytesRead !== 4) throw new DatabaseError(`cannot read database header of ${path}`);
|
|
1474
|
+
const raw = buf.readUInt16BE(0);
|
|
1475
|
+
// SQLite encodes a 64 KiB page size as 1.
|
|
1476
|
+
return raw === 1 ? 65536 : raw;
|
|
1477
|
+
} finally {
|
|
1478
|
+
await fh.close();
|
|
1479
|
+
}
|
|
1480
|
+
}
|