@optimystic/db-p2p-storage-ns 1.15.0 → 1.16.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/README.md +78 -20
- package/dist/src/db.d.ts +45 -7
- package/dist/src/db.d.ts.map +1 -1
- package/dist/src/db.js +71 -5
- package/dist/src/db.js.map +1 -1
- package/dist/src/identity.d.ts +3 -1
- package/dist/src/identity.d.ts.map +1 -1
- package/dist/src/identity.js +5 -1
- package/dist/src/identity.js.map +1 -1
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/ns-opener.d.ts.map +1 -1
- package/dist/src/ns-opener.js +16 -31
- package/dist/src/ns-opener.js.map +1 -1
- package/dist/src/sqlite-kv-store.d.ts +15 -1
- package/dist/src/sqlite-kv-store.d.ts.map +1 -1
- package/dist/src/sqlite-kv-store.js +38 -0
- package/dist/src/sqlite-kv-store.js.map +1 -1
- package/dist/src/sqlite-storage.d.ts +126 -45
- package/dist/src/sqlite-storage.d.ts.map +1 -1
- package/dist/src/sqlite-storage.js +246 -129
- package/dist/src/sqlite-storage.js.map +1 -1
- package/dist/src/sqlite-sync.d.ts +17 -0
- package/dist/src/sqlite-sync.d.ts.map +1 -0
- package/dist/src/sqlite-sync.js +21 -0
- package/dist/src/sqlite-sync.js.map +1 -0
- package/package.json +3 -3
- package/src/db.ts +96 -6
- package/src/identity.ts +5 -1
- package/src/index.ts +4 -2
- package/src/ns-opener.ts +20 -29
- package/src/sqlite-kv-store.ts +36 -2
- package/src/sqlite-storage.ts +291 -164
- package/src/sqlite-sync.ts +24 -0
package/src/sqlite-storage.ts
CHANGED
|
@@ -1,243 +1,370 @@
|
|
|
1
1
|
import type { ActionId, BlockId } from '@optimystic/db-core';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
KvRawStorage,
|
|
4
|
+
StoreFailedError,
|
|
5
|
+
SyncDeadlines,
|
|
6
|
+
WriteGroupDriver,
|
|
7
|
+
identityForHandle,
|
|
8
|
+
type BatchBackend,
|
|
9
|
+
type BatchEntry,
|
|
10
|
+
type CommittedBatch,
|
|
11
|
+
type SetTimer,
|
|
12
|
+
type SinkCommitOptions,
|
|
13
|
+
type StoreDurability,
|
|
14
|
+
type StoreIdentity,
|
|
15
|
+
type StoreKey,
|
|
16
|
+
type SyncCover,
|
|
17
|
+
type WriteGroupOptions,
|
|
18
|
+
} from '@optimystic/db-p2p';
|
|
19
|
+
import type { SqliteDb, SqliteParam, SqliteStatement, SqliteTransaction } from './db.js';
|
|
4
20
|
import { createLogger } from './logger.js';
|
|
21
|
+
import { bumpDurabilityMark } from './sqlite-sync.js';
|
|
5
22
|
|
|
6
23
|
const log = createLogger('storage:sqlite');
|
|
7
24
|
|
|
25
|
+
type Store = StoreKey['store'];
|
|
26
|
+
|
|
27
|
+
/** How a logical store is laid out: its table is named after it, with these key columns and this value column. */
|
|
28
|
+
interface TableLayout {
|
|
29
|
+
readonly keyColumns: readonly string[];
|
|
30
|
+
readonly valueColumn: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LAYOUTS: Record<Store, TableLayout> = {
|
|
34
|
+
metadata: { keyColumns: ['block_id'], valueColumn: 'value' },
|
|
35
|
+
revisions: { keyColumns: ['block_id', 'rev'], valueColumn: 'action_id' },
|
|
36
|
+
proofs: { keyColumns: ['block_id', 'rev'], valueColumn: 'value' },
|
|
37
|
+
pending: { keyColumns: ['block_id', 'action_id'], valueColumn: 'value' },
|
|
38
|
+
transactions: { keyColumns: ['block_id', 'action_id'], valueColumn: 'value' },
|
|
39
|
+
materialized: { keyColumns: ['block_id', 'action_id'], valueColumn: 'value' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const STORES = Object.keys(LAYOUTS) as Store[];
|
|
43
|
+
|
|
44
|
+
const whereKey = (store: Store): string => LAYOUTS[store].keyColumns.map(column => `${column} = ?`).join(' AND ');
|
|
45
|
+
|
|
46
|
+
const sqlByStore = (build: (store: Store) => string): Record<Store, string> =>
|
|
47
|
+
Object.fromEntries(STORES.map(store => [store, build(store)])) as Record<Store, string>;
|
|
48
|
+
|
|
49
|
+
const GET_SQL = sqlByStore(store => `SELECT ${LAYOUTS[store].valueColumn} FROM ${store} WHERE ${whereKey(store)}`);
|
|
50
|
+
const PUT_SQL = sqlByStore(store => {
|
|
51
|
+
const columns = [...LAYOUTS[store].keyColumns, LAYOUTS[store].valueColumn];
|
|
52
|
+
return `INSERT OR REPLACE INTO ${store} (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
|
|
53
|
+
});
|
|
54
|
+
const DELETE_SQL = sqlByStore(store => `DELETE FROM ${store} WHERE ${whereKey(store)}`);
|
|
55
|
+
|
|
56
|
+
/** The key's bind parameters, in the order of its store's key columns. */
|
|
57
|
+
function keyParams(key: StoreKey): SqliteParam[] {
|
|
58
|
+
switch (key.store) {
|
|
59
|
+
case 'metadata':
|
|
60
|
+
return [key.blockId];
|
|
61
|
+
case 'revisions':
|
|
62
|
+
case 'proofs':
|
|
63
|
+
return [key.blockId, key.rev];
|
|
64
|
+
case 'pending':
|
|
65
|
+
case 'transactions':
|
|
66
|
+
case 'materialized':
|
|
67
|
+
return [key.blockId, key.actionId];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a {@link SqliteBatchBackend} is built with: the test-only `setTimer` that arms its deadline timer. */
|
|
72
|
+
export interface SqliteBatchBackendOptions {
|
|
73
|
+
readonly setTimer?: SetTimer;
|
|
74
|
+
}
|
|
75
|
+
|
|
8
76
|
/**
|
|
9
|
-
* SQLite {@link
|
|
10
|
-
*
|
|
11
|
-
*
|
|
77
|
+
* SQLite {@link BatchBackend}: the six logical block-storage stores mapped to the six relational
|
|
78
|
+
* tables (`metadata`, `revisions`, `pending`, `transactions`, `proofs`, `materialized`) with their
|
|
79
|
+
* original columns and keys, every batch applied as ONE SQLite transaction. The reads are the
|
|
80
|
+
* former per-write driver's statements unchanged, so the on-disk format did not change with the
|
|
81
|
+
* move to batches.
|
|
82
|
+
*
|
|
83
|
+
* `KvRawStorage` owns all JSON serialization, so this backend only ever reads/writes `Uint8Array`
|
|
84
|
+
* values: the value columns are BLOB and SQLite binds a `Uint8Array` as a BLOB and returns it as a
|
|
85
|
+
* `Uint8Array`, so no codec lives here (a TEXT column would risk UTF-8 coercion corrupting non-ASCII
|
|
86
|
+
* JSON bytes).
|
|
87
|
+
*
|
|
88
|
+
* ### Why a committed batch survives a power cut, and in order
|
|
89
|
+
*
|
|
90
|
+
* `applySchema` (`db.ts`) opens every database in write-ahead-log (WAL) mode under
|
|
91
|
+
* `synchronous = NORMAL`. A commit appends the batch's frames to the one WAL file, each frame
|
|
92
|
+
* checksummed over the frames before it, and recovery stops at the first bad frame; before every
|
|
93
|
+
* checkpoint SQLite syncs the WAL, so the database file never holds later content ahead of earlier
|
|
94
|
+
* content. What a power cut leaves is therefore a prefix of the committed transactions, in commit
|
|
95
|
+
* order — rule 3 of the flush contract on `RawStoreDriver.flush` — and a batch is one transaction,
|
|
96
|
+
* so it survives whole or not at all. A commit under `synchronous = FULL`
|
|
97
|
+
* (`SqliteTransactionOptions.synced`) syncs the WAL at that commit, and the WAL being one
|
|
98
|
+
* append-only file, that sync covers every earlier commit on the database, the key-value store's
|
|
99
|
+
* and the identity helper's included, since all three share one connection. So the sync of a batch
|
|
100
|
+
* is one `FULL` commit — this batch's own when it must be synced at once, a later batch's, or the
|
|
101
|
+
* `durability_mark` bump the deadline timer commits — which is what `durability: 'power-loss'`
|
|
102
|
+
* states.
|
|
12
103
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
104
|
+
* ### How a commit decides
|
|
105
|
+
*
|
|
106
|
+
* The deadlines, the timer and the backpressure test are the rules of `SyncDeadlines` from
|
|
107
|
+
* `@optimystic/db-p2p`, shared with the file backend. `commit` takes the batch's deadline as it is
|
|
108
|
+
* called and decides whether the transaction is synced BEFORE taking the connection mutex, since
|
|
109
|
+
* the pragma is set as the transaction starts; the bookkeeping — `defer` of a deferred batch,
|
|
110
|
+
* `take` of the cover a synced one settles — runs INSIDE the transaction body, under the mutex, so
|
|
111
|
+
* it is ordered against every other commit and sync: a cover taken there is exactly the batches
|
|
112
|
+
* whose transactions committed earlier, every one of which this `FULL` commit syncs. A deadline
|
|
113
|
+
* that passes between the decision and the mutex only delays that commit's backpressure to the
|
|
114
|
+
* timer, which is due anyway.
|
|
115
|
+
*
|
|
116
|
+
* A read sees a batch as soon as its transaction commits, before it is synced, which `BatchBackend`
|
|
117
|
+
* allows; the overlay above serves the batch until it is durable. The handle is shared with
|
|
118
|
+
* `SqliteKVStore` and `loadOrCreateNSPeerKey`, so `close()` never closes it. Every write of a batch
|
|
119
|
+
* goes through `tx.prepare`, never a class-level statement, which would re-take the mutex the
|
|
120
|
+
* transaction holds and deadlock (see `SqliteDb.transaction`).
|
|
24
121
|
*/
|
|
25
|
-
export class
|
|
122
|
+
export class SqliteBatchBackend implements BatchBackend {
|
|
123
|
+
readonly durability: StoreDurability = 'power-loss';
|
|
124
|
+
|
|
125
|
+
// NOTE: identity is the HANDLE object, so two handles opened over the same SQLite FILE read
|
|
126
|
+
// as two identities. Resolving that would mean naming the underlying database file, which
|
|
127
|
+
// `SqliteDb` does not expose. The package opener (`ns-opener.ts`) hands out one handle per
|
|
128
|
+
// file in practice, so this is the reachable case, not the complete one.
|
|
129
|
+
private readonly identity: StoreIdentity;
|
|
130
|
+
|
|
131
|
+
/** One point read per store. Reads run off the mutex, so a class-level statement is right here. */
|
|
132
|
+
private readonly reads: Record<Store, SqliteStatement>;
|
|
26
133
|
private readonly stmts: {
|
|
27
|
-
|
|
28
|
-
saveMetadata: SqliteStatement;
|
|
29
|
-
getRevision: SqliteStatement;
|
|
30
|
-
saveRevision: SqliteStatement;
|
|
31
|
-
listRevisionsAsc: SqliteStatement;
|
|
32
|
-
listRevisionsDesc: SqliteStatement;
|
|
33
|
-
getPending: SqliteStatement;
|
|
34
|
-
savePending: SqliteStatement;
|
|
35
|
-
deletePending: SqliteStatement;
|
|
134
|
+
listRevisions: SqliteStatement;
|
|
36
135
|
listPending: SqliteStatement;
|
|
37
136
|
listBlockIds: SqliteStatement;
|
|
38
|
-
getTransaction: SqliteStatement;
|
|
39
|
-
saveTransaction: SqliteStatement;
|
|
40
|
-
getProof: SqliteStatement;
|
|
41
|
-
saveProof: SqliteStatement;
|
|
42
|
-
getMaterialized: SqliteStatement;
|
|
43
|
-
saveMaterialized: SqliteStatement;
|
|
44
|
-
deleteMaterialized: SqliteStatement;
|
|
45
137
|
pageCount: SqliteStatement;
|
|
46
138
|
pageSize: SqliteStatement;
|
|
47
139
|
};
|
|
48
140
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Batches committed and not yet synced, in order, each with the `durable` the next sync settles;
|
|
143
|
+
* the earliest deadline among them, with a timer armed for it; and the `window-overrun` episode.
|
|
144
|
+
* A batch's payload is nothing: its transaction has already applied it.
|
|
145
|
+
*/
|
|
146
|
+
private readonly deadlines: SyncDeadlines<undefined>;
|
|
147
|
+
private fault: { cause: unknown } | undefined;
|
|
54
148
|
|
|
55
|
-
constructor(private readonly db: SqliteDb) {
|
|
149
|
+
constructor(private readonly db: SqliteDb, options: SqliteBatchBackendOptions = {}) {
|
|
56
150
|
this.identity = identityForHandle('sqlite-handle', db);
|
|
151
|
+
this.reads = Object.fromEntries(STORES.map(store => [store, db.prepare(GET_SQL[store])])) as Record<Store, SqliteStatement>;
|
|
57
152
|
this.stmts = {
|
|
58
|
-
|
|
59
|
-
saveMetadata: db.prepare('INSERT OR REPLACE INTO metadata (block_id, value) VALUES (?, ?)'),
|
|
60
|
-
getRevision: db.prepare('SELECT action_id FROM revisions WHERE block_id = ? AND rev = ?'),
|
|
61
|
-
saveRevision: db.prepare('INSERT OR REPLACE INTO revisions (block_id, rev, action_id) VALUES (?, ?, ?)'),
|
|
62
|
-
listRevisionsAsc: db.prepare('SELECT rev, action_id FROM revisions WHERE block_id = ? AND rev BETWEEN ? AND ? ORDER BY rev ASC'),
|
|
63
|
-
listRevisionsDesc: db.prepare('SELECT rev, action_id FROM revisions WHERE block_id = ? AND rev BETWEEN ? AND ? ORDER BY rev DESC'),
|
|
64
|
-
getPending: db.prepare('SELECT value FROM pending WHERE block_id = ? AND action_id = ?'),
|
|
65
|
-
savePending: db.prepare('INSERT OR REPLACE INTO pending (block_id, action_id, value) VALUES (?, ?, ?)'),
|
|
66
|
-
deletePending: db.prepare('DELETE FROM pending WHERE block_id = ? AND action_id = ?'),
|
|
153
|
+
listRevisions: db.prepare('SELECT rev, action_id FROM revisions WHERE block_id = ? AND rev BETWEEN ? AND ? ORDER BY rev ASC'),
|
|
67
154
|
listPending: db.prepare('SELECT action_id FROM pending WHERE block_id = ? ORDER BY action_id ASC'),
|
|
68
155
|
// metadata.block_id is the PRIMARY KEY, so each row is a distinct block id —
|
|
69
156
|
// no dedup needed. NOTE: drains the whole metadata table up front; if a peer
|
|
70
157
|
// ever holds millions of blocks and this SELECT becomes a startup-latency
|
|
71
158
|
// problem, page it (LIMIT/OFFSET or keyset) — fine at current scale.
|
|
72
159
|
listBlockIds: db.prepare('SELECT block_id FROM metadata'),
|
|
73
|
-
getTransaction: db.prepare('SELECT value FROM transactions WHERE block_id = ? AND action_id = ?'),
|
|
74
|
-
saveTransaction: db.prepare('INSERT OR REPLACE INTO transactions (block_id, action_id, value) VALUES (?, ?, ?)'),
|
|
75
|
-
getProof: db.prepare('SELECT value FROM proofs WHERE block_id = ? AND rev = ?'),
|
|
76
|
-
saveProof: db.prepare('INSERT OR REPLACE INTO proofs (block_id, rev, value) VALUES (?, ?, ?)'),
|
|
77
|
-
getMaterialized: db.prepare('SELECT value FROM materialized WHERE block_id = ? AND action_id = ?'),
|
|
78
|
-
saveMaterialized: db.prepare('INSERT OR REPLACE INTO materialized (block_id, action_id, value) VALUES (?, ?, ?)'),
|
|
79
|
-
deleteMaterialized: db.prepare('DELETE FROM materialized WHERE block_id = ? AND action_id = ?'),
|
|
80
160
|
pageCount: db.prepare('PRAGMA page_count'),
|
|
81
161
|
pageSize: db.prepare('PRAGMA page_size'),
|
|
82
162
|
};
|
|
163
|
+
this.deadlines = new SyncDeadlines({
|
|
164
|
+
onDue: firedAt => this.syncInBackground(firedAt),
|
|
165
|
+
setTimer: options.setTimer,
|
|
166
|
+
log,
|
|
167
|
+
subject: this.identity,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True once a transaction or a sync failed; every later `commit` and `sync` then throws {@link StoreFailedError}. */
|
|
172
|
+
get failed(): boolean {
|
|
173
|
+
return this.fault !== undefined;
|
|
83
174
|
}
|
|
84
175
|
|
|
85
176
|
storeIdentity(): StoreIdentity {
|
|
86
177
|
return this.identity;
|
|
87
178
|
}
|
|
88
179
|
|
|
89
|
-
// ---
|
|
180
|
+
// --- reads ---
|
|
90
181
|
|
|
91
|
-
async
|
|
92
|
-
const row = await this.
|
|
93
|
-
return row ? (row.
|
|
182
|
+
async get(key: StoreKey): Promise<Uint8Array | undefined> {
|
|
183
|
+
const row = await this.reads[key.store].get(...keyParams(key));
|
|
184
|
+
return row ? (row[LAYOUTS[key.store].valueColumn] as Uint8Array) : undefined;
|
|
94
185
|
}
|
|
95
186
|
|
|
96
|
-
async
|
|
97
|
-
await this.stmts.
|
|
187
|
+
async rangeRevisions(blockId: BlockId, lo: number, hi: number): Promise<[number, Uint8Array][]> {
|
|
188
|
+
const rows = await this.stmts.listRevisions.all(blockId, lo, hi);
|
|
189
|
+
return rows.map(row => [row.rev as number, row.action_id as Uint8Array]);
|
|
98
190
|
}
|
|
99
191
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const row = await this.stmts.getRevision.get(blockId, rev);
|
|
104
|
-
return row ? (row.action_id as Uint8Array) : undefined;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async putRevision(blockId: BlockId, rev: number, value: Uint8Array): Promise<void> {
|
|
108
|
-
await this.stmts.saveRevision.run(blockId, rev, value);
|
|
192
|
+
async listPendingActionIds(blockId: BlockId): Promise<ActionId[]> {
|
|
193
|
+
const rows = await this.stmts.listPending.all(blockId);
|
|
194
|
+
return rows.map(row => row.action_id as ActionId);
|
|
109
195
|
}
|
|
110
196
|
|
|
111
|
-
async *
|
|
112
|
-
// `.all(...)`
|
|
113
|
-
|
|
114
|
-
const stmt = reverse ? this.stmts.listRevisionsDesc : this.stmts.listRevisionsAsc;
|
|
115
|
-
const rows = await stmt.all(blockId, lo, hi);
|
|
197
|
+
async *listBlockIds(): AsyncIterable<BlockId> {
|
|
198
|
+
// Drained by `.all(...)` before yielding, so no cursor straddles the consumer's awaits.
|
|
199
|
+
const rows = await this.stmts.listBlockIds.all();
|
|
116
200
|
for (const row of rows) {
|
|
117
|
-
yield
|
|
201
|
+
yield row.block_id as BlockId;
|
|
118
202
|
}
|
|
119
203
|
}
|
|
120
204
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
async deletePending(blockId: BlockId, actionId: ActionId): Promise<void> {
|
|
133
|
-
await this.stmts.deletePending.run(blockId, actionId);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async *listPendingActionIds(blockId: BlockId): AsyncIterable<ActionId> {
|
|
137
|
-
// Drained by `.all(...)` before yielding — same rationale as rangeRevisions.
|
|
138
|
-
const rows = await this.stmts.listPending.all(blockId);
|
|
139
|
-
for (const row of rows) {
|
|
140
|
-
yield row.action_id as ActionId;
|
|
205
|
+
async approximateBytesUsed(): Promise<number> {
|
|
206
|
+
try {
|
|
207
|
+
const pageCountRow = await this.stmts.pageCount.get();
|
|
208
|
+
const pageSizeRow = await this.stmts.pageSize.get();
|
|
209
|
+
const pages = (pageCountRow?.page_count as number | undefined) ?? 0;
|
|
210
|
+
const size = (pageSizeRow?.page_size as number | undefined) ?? 0;
|
|
211
|
+
return pages * size;
|
|
212
|
+
} catch (err) {
|
|
213
|
+
log('PRAGMA page_count/page_size failed: %o', err);
|
|
214
|
+
return 0;
|
|
141
215
|
}
|
|
142
216
|
}
|
|
143
217
|
|
|
144
|
-
// ---
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
218
|
+
// --- the write group's sink ---
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Apply the batch as one transaction and resolve with its `durable`. `WriteGroupSink.commit`'s
|
|
222
|
+
* contract: with `syncWithinMs` 0, or a deadline already passed (`SyncDeadlines.mustSyncNow`),
|
|
223
|
+
* the transaction commits under `synchronous = FULL`, which syncs this batch and every one held,
|
|
224
|
+
* and `durable` is already settled; otherwise it commits under `NORMAL` and `durable` settles at
|
|
225
|
+
* the next sync, which the timer makes by the earliest deadline held. A rejection has latched the
|
|
226
|
+
* backend.
|
|
227
|
+
*/
|
|
228
|
+
commit(entries: readonly BatchEntry[], { syncWithinMs }: SinkCommitOptions): Promise<CommittedBatch> {
|
|
229
|
+
if (!(syncWithinMs >= 0) || !Number.isFinite(syncWithinMs)) {
|
|
230
|
+
return Promise.reject(new RangeError(`syncWithinMs must be a finite number of milliseconds at or above 0, got ${syncWithinMs}`));
|
|
231
|
+
}
|
|
232
|
+
if (this.fault) return Promise.reject(new StoreFailedError(this.fault.cause));
|
|
233
|
+
const deadline = this.deadlines.deadlineFor(syncWithinMs);
|
|
234
|
+
return this.deadlines.mustSyncNow(deadline) ? this.commitSynced(entries) : this.commitDeferred(entries, deadline);
|
|
149
235
|
}
|
|
150
236
|
|
|
151
|
-
|
|
152
|
-
|
|
237
|
+
/** Hasten: one synced commit covering every batch held when it runs; with nothing held, nothing is synced. `WriteGroupSink.sync`'s contract. */
|
|
238
|
+
sync(): Promise<void> {
|
|
239
|
+
return this.syncHeld();
|
|
153
240
|
}
|
|
154
241
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
242
|
+
/**
|
|
243
|
+
* The handle is shared, so it is not closed here: only the deadline timer must not outlive the
|
|
244
|
+
* backend. `WriteGroupDriver.close` flushes strictly first, which took and settled everything
|
|
245
|
+
* held and so disarmed the timer; a caller that did not flush gets the same here. After a latch
|
|
246
|
+
* nothing is held, since the latch took and rejected it.
|
|
247
|
+
*/
|
|
248
|
+
async close(): Promise<void> {
|
|
249
|
+
if (this.fault) return;
|
|
250
|
+
await this.syncHeld();
|
|
160
251
|
}
|
|
161
252
|
|
|
162
|
-
async
|
|
163
|
-
|
|
253
|
+
private async commitDeferred(entries: readonly BatchEntry[], deadline: number | undefined): Promise<CommittedBatch> {
|
|
254
|
+
try {
|
|
255
|
+
return await this.db.transaction(async (tx) => {
|
|
256
|
+
this.throwIfFailed();
|
|
257
|
+
await this.applyEntries(tx, entries);
|
|
258
|
+
// Deferred under the mutex, so the next sync's cover, also taken under it, includes this
|
|
259
|
+
// batch only once its transaction has committed.
|
|
260
|
+
return { durable: this.deadlines.defer(undefined, deadline) };
|
|
261
|
+
});
|
|
262
|
+
} catch (err) {
|
|
263
|
+
throw this.latch(err);
|
|
264
|
+
}
|
|
164
265
|
}
|
|
165
266
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const row = await this.stmts.getMaterialized.get(blockId, actionId);
|
|
170
|
-
return row ? (row.value as Uint8Array) : undefined;
|
|
267
|
+
private async commitSynced(entries: readonly BatchEntry[]): Promise<CommittedBatch> {
|
|
268
|
+
await this.syncHeld(undefined, tx => this.applyEntries(tx, entries));
|
|
269
|
+
return { durable: Promise.resolve() };
|
|
171
270
|
}
|
|
172
271
|
|
|
173
|
-
|
|
174
|
-
|
|
272
|
+
/**
|
|
273
|
+
* The sync step: one synced transaction whose body runs `write`, if given, then takes the cover
|
|
274
|
+
* of every batch held — under the mutex, so the cover is exactly the batches whose transactions
|
|
275
|
+
* committed before this one — and, when there is a cover to settle and nothing of its own was
|
|
276
|
+
* written, bumps `durability_mark` so the commit has a frame to sync. The cover settles once the
|
|
277
|
+
* commit has resolved. A failure anywhere latches the backend and rejects every covered
|
|
278
|
+
* `durable`, so no flush waits for a sync the store will not make.
|
|
279
|
+
*
|
|
280
|
+
* `owedSince` is the moment the timer fired, when the timer's task runs this: `SyncDeadlines.take`
|
|
281
|
+
* counts the sync's lateness from it rather than from a deadline the timer's own slop missed.
|
|
282
|
+
*/
|
|
283
|
+
private async syncHeld(owedSince?: number, write?: (tx: SqliteTransaction) => Promise<number>): Promise<void> {
|
|
284
|
+
// Assigned inside the body, so a failure after the take still reaches the cover it took.
|
|
285
|
+
let cover: SyncCover<undefined> | undefined;
|
|
286
|
+
try {
|
|
287
|
+
await this.db.transaction(async (tx) => {
|
|
288
|
+
this.throwIfFailed();
|
|
289
|
+
const written = write ? await write(tx) : 0;
|
|
290
|
+
cover = this.deadlines.take(owedSince);
|
|
291
|
+
if (written === 0 && cover.items.length > 0) await bumpDurabilityMark(tx);
|
|
292
|
+
}, { synced: true });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
const failure = this.latch(err);
|
|
295
|
+
cover?.fail(failure.cause);
|
|
296
|
+
throw failure;
|
|
297
|
+
}
|
|
298
|
+
cover?.settle();
|
|
175
299
|
}
|
|
176
300
|
|
|
177
|
-
|
|
178
|
-
|
|
301
|
+
/** The timer fired: run the sync step. Its failure has latched the backend, so it is only logged here. */
|
|
302
|
+
private syncInBackground(firedAt: number): void {
|
|
303
|
+
this.syncHeld(firedAt).catch((err: unknown) => {
|
|
304
|
+
log('deferred sync of %s failed; the store refuses every later write: %o', this.identity, err);
|
|
305
|
+
});
|
|
179
306
|
}
|
|
180
307
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (!row) {
|
|
193
|
-
throw new Error(`Pending action ${actionId} not found for block ${blockId}`);
|
|
308
|
+
/**
|
|
309
|
+
* Every entry in order — a put as `INSERT OR REPLACE`, a delete as `DELETE` — each statement
|
|
310
|
+
* prepared once per batch against the open transaction. Returns how many were applied.
|
|
311
|
+
*/
|
|
312
|
+
private async applyEntries(tx: SqliteTransaction, entries: readonly BatchEntry[]): Promise<number> {
|
|
313
|
+
const prepared = new Map<string, SqliteStatement>();
|
|
314
|
+
const statement = (sql: string): SqliteStatement => {
|
|
315
|
+
let stmt = prepared.get(sql);
|
|
316
|
+
if (!stmt) {
|
|
317
|
+
stmt = tx.prepare(sql);
|
|
318
|
+
prepared.set(sql, stmt);
|
|
194
319
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
})
|
|
320
|
+
return stmt;
|
|
321
|
+
};
|
|
322
|
+
for (const { key, value } of entries) {
|
|
323
|
+
if (value === undefined) await statement(DELETE_SQL[key.store]).run(...keyParams(key));
|
|
324
|
+
else await statement(PUT_SQL[key.store]).run(...keyParams(key), value);
|
|
325
|
+
}
|
|
326
|
+
return entries.length;
|
|
198
327
|
}
|
|
199
328
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
async *listBlockIds(): AsyncIterable<BlockId> {
|
|
203
|
-
const rows = await this.stmts.listBlockIds.all();
|
|
204
|
-
for (const row of rows) {
|
|
205
|
-
yield row.block_id as BlockId;
|
|
206
|
-
}
|
|
329
|
+
private throwIfFailed(): void {
|
|
330
|
+
if (this.fault) throw new StoreFailedError(this.fault.cause);
|
|
207
331
|
}
|
|
208
332
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
log('PRAGMA page_count/page_size failed: %o', err);
|
|
218
|
-
return 0;
|
|
219
|
-
}
|
|
333
|
+
/**
|
|
334
|
+
* Latch the backend failed on its first fault and reject every batch waiting for a sync that will
|
|
335
|
+
* now never be made, so no caller waits on a `durable` for ever. Returns the error to throw.
|
|
336
|
+
*/
|
|
337
|
+
private latch(err: unknown): StoreFailedError {
|
|
338
|
+
this.fault ??= { cause: err };
|
|
339
|
+
this.deadlines.failAll(err);
|
|
340
|
+
return new StoreFailedError(this.fault.cause);
|
|
220
341
|
}
|
|
221
342
|
}
|
|
222
343
|
|
|
344
|
+
/** The write group's options (`WriteGroupOptions`); the test-only `setTimer` arms both the group's idle flush and the backend's deadline timer. */
|
|
345
|
+
export type SqliteRawStorageOptions = WriteGroupOptions;
|
|
346
|
+
|
|
223
347
|
/**
|
|
224
|
-
* SQLite-backed {@link IRawStorage} for NativeScript peers
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
348
|
+
* SQLite-backed {@link IRawStorage} for NativeScript peers: the shared {@link KvRawStorage} kernel
|
|
349
|
+
* over a {@link WriteGroupDriver} over a {@link SqliteBatchBackend}. Every write resolves into the
|
|
350
|
+
* driver's in-memory overlay; `flush()` hands the group to the backend as one batch, which applies
|
|
351
|
+
* it as one SQLite transaction, synced before the flush resolves or within the window the flush
|
|
352
|
+
* allows. The public name/constructor (`new SqliteRawStorage(db)`) is unchanged so existing imports
|
|
353
|
+
* keep resolving.
|
|
229
354
|
*
|
|
230
|
-
* `listBlockIds`/`getApproximateBytesUsed` are re-declared here as
|
|
231
|
-
* (the
|
|
232
|
-
*
|
|
233
|
-
* them.
|
|
355
|
+
* `listBlockIds`/`getApproximateBytesUsed`/`getStoreIdentity`/`close` are re-declared here as
|
|
356
|
+
* always-present (the backend always implements the first three, so the driver and kernel always
|
|
357
|
+
* wire them, and the driver always has a `close`) — the base declares them optional, but every NS
|
|
358
|
+
* consumer relies on them. `close()` flushes what is still held, syncs it, and disarms the timers;
|
|
359
|
+
* it does not close the shared handle, which the host closes afterwards.
|
|
234
360
|
*/
|
|
235
361
|
export class SqliteRawStorage extends KvRawStorage {
|
|
236
362
|
declare listBlockIds: () => AsyncIterable<BlockId>;
|
|
237
363
|
declare getApproximateBytesUsed: () => Promise<number>;
|
|
238
364
|
declare getStoreIdentity: () => StoreIdentity;
|
|
365
|
+
declare close: () => Promise<void>;
|
|
239
366
|
|
|
240
|
-
constructor(db: SqliteDb) {
|
|
241
|
-
super(new
|
|
367
|
+
constructor(db: SqliteDb, options: SqliteRawStorageOptions = {}) {
|
|
368
|
+
super(new WriteGroupDriver(new SqliteBatchBackend(db, options), options));
|
|
242
369
|
}
|
|
243
370
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SqliteDb, SqliteTransaction } from './db.js';
|
|
2
|
+
|
|
3
|
+
const BUMP_MARK_SQL = 'INSERT INTO durability_mark (id, n) VALUES (1, 1) ON CONFLICT(id) DO UPDATE SET n = n + 1';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bump the one `durability_mark` row inside an open transaction, so that the transaction has a
|
|
7
|
+
* frame to commit whatever else it wrote. The row's count means nothing; a sync needs a change to
|
|
8
|
+
* commit, because a transaction that changed nothing writes no frame and syncs nothing.
|
|
9
|
+
*/
|
|
10
|
+
export async function bumpDurabilityMark(tx: SqliteTransaction): Promise<void> {
|
|
11
|
+
await tx.prepare(BUMP_MARK_SQL).run();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sync the database: one synced transaction (`SqliteTransactionOptions.synced`) that bumps the
|
|
16
|
+
* `durability_mark` row. Its commit syncs the write-ahead log, which is one append-only file, so
|
|
17
|
+
* every earlier commit on this database — the block store's, the key-value store's and the identity
|
|
18
|
+
* helper's, all on one connection — survives a power cut once this resolves. Every sync in the
|
|
19
|
+
* package goes through this helper or through a synced transaction that writes rows of its own
|
|
20
|
+
* (`SqliteBatchBackend`).
|
|
21
|
+
*/
|
|
22
|
+
export async function syncSqliteDb(db: SqliteDb): Promise<void> {
|
|
23
|
+
await db.transaction(bumpDurabilityMark, { synced: true });
|
|
24
|
+
}
|