@backloghq/opslog 0.4.0 → 0.5.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 +30 -0
- package/dist/store.d.ts +15 -6
- package/dist/store.js +80 -12
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -134,11 +134,41 @@ await store.open(dir, {
|
|
|
134
134
|
version: 1, // Schema version
|
|
135
135
|
migrate: (record, fromVersion) => record, // Migration function
|
|
136
136
|
readOnly: false, // Open in read-only mode (default: false)
|
|
137
|
+
writeMode: "immediate", // "immediate" (default) or "group" (buffered, ~12x faster)
|
|
138
|
+
groupCommitSize: 50, // Group: flush after N ops (default: 50)
|
|
139
|
+
groupCommitMs: 100, // Group: flush after N ms (default: 100)
|
|
137
140
|
agentId: "agent-A", // Enable multi-writer mode (optional)
|
|
138
141
|
backend: new FsBackend(), // Custom storage backend (optional, default: FsBackend)
|
|
139
142
|
});
|
|
140
143
|
```
|
|
141
144
|
|
|
145
|
+
## Group Commit
|
|
146
|
+
|
|
147
|
+
Buffer writes in memory and flush as a single disk write. ~12x faster for sustained writes.
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
const store = new Store();
|
|
151
|
+
await store.open("./data", {
|
|
152
|
+
writeMode: "group", // Buffer ops, flush periodically
|
|
153
|
+
groupCommitSize: 50, // Flush when buffer has 50 ops
|
|
154
|
+
groupCommitMs: 100, // Or after 100ms idle
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Writes are buffered — no fsync per op
|
|
158
|
+
await store.set("a", valueA);
|
|
159
|
+
await store.set("b", valueB); // Both flushed together
|
|
160
|
+
|
|
161
|
+
// Explicit flush if needed
|
|
162
|
+
await store.flush();
|
|
163
|
+
|
|
164
|
+
// close() always flushes before shutting down
|
|
165
|
+
await store.close();
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Safety:** Forced to `"immediate"` when `agentId` is set (multi-writer mode). Other agents can't see buffered ops, so group commit is single-writer only.
|
|
169
|
+
|
|
170
|
+
**Tradeoff:** A crash can lose ops that haven't been flushed yet (up to `groupCommitMs` milliseconds of data). Use `"immediate"` (default) when every write must be durable.
|
|
171
|
+
|
|
142
172
|
## Multi-Writer Mode
|
|
143
173
|
|
|
144
174
|
Multiple agents can write to the same store concurrently. Each agent gets its own WAL file — no write contention.
|
package/dist/store.d.ts
CHANGED
|
@@ -17,6 +17,11 @@ export declare class Store<T = Record<string, unknown>> {
|
|
|
17
17
|
private backend;
|
|
18
18
|
private agentId?;
|
|
19
19
|
private clock;
|
|
20
|
+
private groupCommit;
|
|
21
|
+
private groupBuffer;
|
|
22
|
+
private groupSize;
|
|
23
|
+
private groupMs;
|
|
24
|
+
private groupTimer;
|
|
20
25
|
private manifestVersion;
|
|
21
26
|
/**
|
|
22
27
|
* Serialize all state-mutating operations through a promise chain.
|
|
@@ -47,18 +52,19 @@ export declare class Store<T = Record<string, unknown>> {
|
|
|
47
52
|
loadArchive(segment: string): Promise<Map<string, T>>;
|
|
48
53
|
stats(): StoreStats;
|
|
49
54
|
/**
|
|
50
|
-
* Reload state from the backend
|
|
51
|
-
*
|
|
52
|
-
*
|
|
55
|
+
* Reload state from the backend.
|
|
56
|
+
* In multi-writer mode: re-reads manifest, snapshot, and all agent WAL files.
|
|
57
|
+
* In single-writer/readOnly mode: re-reads the active ops file for new entries.
|
|
58
|
+
* Use this to pick up writes from other agents or processes.
|
|
53
59
|
*/
|
|
54
60
|
refresh(): Promise<void>;
|
|
55
61
|
private watchTimer;
|
|
56
62
|
private watchCallback;
|
|
57
63
|
/**
|
|
58
|
-
* Tail the WAL for new operations.
|
|
59
|
-
*
|
|
64
|
+
* Tail the WAL for new operations.
|
|
65
|
+
* In single-writer/readOnly: re-reads the active ops file for new entries.
|
|
66
|
+
* In multi-writer: re-reads ALL agent WAL files from the manifest.
|
|
60
67
|
* Returns the newly applied operations.
|
|
61
|
-
* Works in any mode (single-writer readOnly, multi-writer, etc).
|
|
62
68
|
*/
|
|
63
69
|
tail(): Promise<Operation<T>[]>;
|
|
64
70
|
/**
|
|
@@ -87,5 +93,8 @@ export declare class Store<T = Record<string, unknown>> {
|
|
|
87
93
|
private applyOp;
|
|
88
94
|
private reverseOp;
|
|
89
95
|
private persistOp;
|
|
96
|
+
/** Flush buffered group commit ops to disk in a single write. */
|
|
97
|
+
flush(): Promise<void>;
|
|
98
|
+
private flushGroupBuffer;
|
|
90
99
|
private defaultPeriod;
|
|
91
100
|
}
|
package/dist/store.js
CHANGED
|
@@ -27,6 +27,12 @@ export class Store {
|
|
|
27
27
|
// Multi-writer state
|
|
28
28
|
agentId;
|
|
29
29
|
clock = null;
|
|
30
|
+
// Group commit state
|
|
31
|
+
groupCommit = false;
|
|
32
|
+
groupBuffer = [];
|
|
33
|
+
groupSize = 50;
|
|
34
|
+
groupMs = 100;
|
|
35
|
+
groupTimer = null;
|
|
30
36
|
manifestVersion = null;
|
|
31
37
|
/**
|
|
32
38
|
* Serialize all state-mutating operations through a promise chain.
|
|
@@ -46,12 +52,25 @@ export class Store {
|
|
|
46
52
|
async open(dir, options) {
|
|
47
53
|
this.dir = dir;
|
|
48
54
|
if (options) {
|
|
49
|
-
const { backend, agentId, ...rest } = options;
|
|
55
|
+
const { backend, agentId, writeMode, groupCommitSize, groupCommitMs, ...rest } = options;
|
|
50
56
|
this.coreOpts = { ...this.coreOpts, ...rest };
|
|
51
57
|
if (backend)
|
|
52
58
|
this.backend = backend;
|
|
53
59
|
if (agentId)
|
|
54
60
|
this.agentId = agentId;
|
|
61
|
+
// Group commit: enabled when writeMode is "group" AND not multi-writer
|
|
62
|
+
if (writeMode === "group") {
|
|
63
|
+
if (agentId) {
|
|
64
|
+
console.error("opslog: writeMode 'group' is not compatible with multi-writer (agentId). Using 'immediate'.");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
this.groupCommit = true;
|
|
68
|
+
if (groupCommitSize)
|
|
69
|
+
this.groupSize = groupCommitSize;
|
|
70
|
+
if (groupCommitMs)
|
|
71
|
+
this.groupMs = groupCommitMs;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
55
74
|
}
|
|
56
75
|
this.backend ??= new FsBackend();
|
|
57
76
|
await this.backend.initialize(dir, { readOnly: this.coreOpts.readOnly });
|
|
@@ -183,6 +202,10 @@ export class Store {
|
|
|
183
202
|
async close() {
|
|
184
203
|
this.ensureOpen();
|
|
185
204
|
this.unwatch();
|
|
205
|
+
// Flush any buffered group commit ops before checkpoint
|
|
206
|
+
if (this.groupCommit && this.groupBuffer.length > 0) {
|
|
207
|
+
await this.serialize(() => this.flushGroupBuffer());
|
|
208
|
+
}
|
|
186
209
|
if (!this.coreOpts.readOnly &&
|
|
187
210
|
this.coreOpts.checkpointOnClose &&
|
|
188
211
|
this.ops.length > 0) {
|
|
@@ -299,30 +322,41 @@ export class Store {
|
|
|
299
322
|
};
|
|
300
323
|
}
|
|
301
324
|
/**
|
|
302
|
-
* Reload state from the backend
|
|
303
|
-
*
|
|
304
|
-
*
|
|
325
|
+
* Reload state from the backend.
|
|
326
|
+
* In multi-writer mode: re-reads manifest, snapshot, and all agent WAL files.
|
|
327
|
+
* In single-writer/readOnly mode: re-reads the active ops file for new entries.
|
|
328
|
+
* Use this to pick up writes from other agents or processes.
|
|
305
329
|
*/
|
|
306
330
|
async refresh() {
|
|
307
331
|
this.ensureOpen();
|
|
308
|
-
if (
|
|
309
|
-
|
|
332
|
+
if (this.isMultiWriter()) {
|
|
333
|
+
return this.serialize(() => this._refresh());
|
|
310
334
|
}
|
|
311
|
-
|
|
335
|
+
// Single-writer / readOnly: just tail the active ops file
|
|
336
|
+
await this.tail();
|
|
312
337
|
}
|
|
313
338
|
// --- WAL tailing ---
|
|
314
339
|
watchTimer = null;
|
|
315
340
|
watchCallback = null;
|
|
316
341
|
/**
|
|
317
|
-
* Tail the WAL for new operations.
|
|
318
|
-
*
|
|
342
|
+
* Tail the WAL for new operations.
|
|
343
|
+
* In single-writer/readOnly: re-reads the active ops file for new entries.
|
|
344
|
+
* In multi-writer: re-reads ALL agent WAL files from the manifest.
|
|
319
345
|
* Returns the newly applied operations.
|
|
320
|
-
* Works in any mode (single-writer readOnly, multi-writer, etc).
|
|
321
346
|
*/
|
|
322
347
|
async tail() {
|
|
323
348
|
this.ensureOpen();
|
|
324
349
|
const prevCount = this.ops.length;
|
|
325
|
-
|
|
350
|
+
if (this.isMultiWriter()) {
|
|
351
|
+
// Multi-writer: full refresh to pick up all agents' writes
|
|
352
|
+
await this.serialize(() => this._refresh());
|
|
353
|
+
// Return the difference
|
|
354
|
+
if (this.ops.length > prevCount) {
|
|
355
|
+
return this.ops.slice(prevCount);
|
|
356
|
+
}
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
// Single-writer / readOnly: just re-read our ops file
|
|
326
360
|
const allOps = (await this.backend.readOps(this.activeOpsPath));
|
|
327
361
|
if (allOps.length <= prevCount)
|
|
328
362
|
return [];
|
|
@@ -472,6 +506,9 @@ export class Store {
|
|
|
472
506
|
return true;
|
|
473
507
|
}
|
|
474
508
|
async _compact() {
|
|
509
|
+
// Flush group buffer before checkpoint
|
|
510
|
+
if (this.groupCommit)
|
|
511
|
+
await this.flushGroupBuffer();
|
|
475
512
|
if (this.isMultiWriter()) {
|
|
476
513
|
await this._compactMultiWriter();
|
|
477
514
|
return;
|
|
@@ -645,12 +682,43 @@ export class Store {
|
|
|
645
682
|
}
|
|
646
683
|
}
|
|
647
684
|
async persistOp(op) {
|
|
648
|
-
await this.backend.appendOps(this.activeOpsPath, [op]);
|
|
649
685
|
this.ops.push(op);
|
|
686
|
+
if (this.groupCommit) {
|
|
687
|
+
// Buffer the op, flush when buffer is full or timer fires
|
|
688
|
+
this.groupBuffer.push(op);
|
|
689
|
+
if (this.groupBuffer.length >= this.groupSize) {
|
|
690
|
+
await this.flushGroupBuffer();
|
|
691
|
+
}
|
|
692
|
+
else if (!this.groupTimer) {
|
|
693
|
+
this.groupTimer = setTimeout(() => {
|
|
694
|
+
this.serialize(() => this.flushGroupBuffer()).catch(() => { });
|
|
695
|
+
}, this.groupMs);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
// Immediate: write to disk now
|
|
700
|
+
await this.backend.appendOps(this.activeOpsPath, [op]);
|
|
701
|
+
}
|
|
650
702
|
if (this.ops.length >= this.coreOpts.checkpointThreshold) {
|
|
651
703
|
await this._compact();
|
|
652
704
|
}
|
|
653
705
|
}
|
|
706
|
+
/** Flush buffered group commit ops to disk in a single write. */
|
|
707
|
+
async flush() {
|
|
708
|
+
if (!this.groupCommit || this.groupBuffer.length === 0)
|
|
709
|
+
return;
|
|
710
|
+
return this.serialize(() => this.flushGroupBuffer());
|
|
711
|
+
}
|
|
712
|
+
async flushGroupBuffer() {
|
|
713
|
+
if (this.groupBuffer.length === 0)
|
|
714
|
+
return;
|
|
715
|
+
if (this.groupTimer) {
|
|
716
|
+
clearTimeout(this.groupTimer);
|
|
717
|
+
this.groupTimer = null;
|
|
718
|
+
}
|
|
719
|
+
await this.backend.appendOps(this.activeOpsPath, this.groupBuffer);
|
|
720
|
+
this.groupBuffer = [];
|
|
721
|
+
}
|
|
654
722
|
defaultPeriod() {
|
|
655
723
|
const now = new Date();
|
|
656
724
|
const q = Math.ceil((now.getMonth() + 1) / 3);
|
package/dist/types.d.ts
CHANGED
|
@@ -58,6 +58,12 @@ export interface StoreOptions {
|
|
|
58
58
|
backend?: StorageBackend;
|
|
59
59
|
/** Agent ID for multi-writer mode. Enables per-agent WAL streams and LWW conflict resolution. */
|
|
60
60
|
agentId?: string;
|
|
61
|
+
/** Write mode: "immediate" flushes every op (default, safe for multi-writer). "group" buffers ops and flushes periodically (~50x faster writes). Forced to "immediate" when agentId is set. */
|
|
62
|
+
writeMode?: "immediate" | "group";
|
|
63
|
+
/** Group commit: max ops to buffer before flush (default: 50). Only used when writeMode is "group". */
|
|
64
|
+
groupCommitSize?: number;
|
|
65
|
+
/** Group commit: max milliseconds before flush (default: 100). Only used when writeMode is "group". */
|
|
66
|
+
groupCommitMs?: number;
|
|
61
67
|
}
|
|
62
68
|
export interface StoreStats {
|
|
63
69
|
activeRecords: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backloghq/opslog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Embedded event-sourced document store. Append-only operation log with immutable snapshots, zero native dependencies.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|