slopkit 0.1.1 → 0.2.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/DEPS.md +2 -1
- package/README.md +41 -0
- package/dist/client.d.ts +15 -1
- package/dist/client.js +4 -0
- package/dist/conformance/fixtures.d.ts +54 -3
- package/dist/conformance/fixtures.js +4 -4
- package/dist/conformance/legacy/replica-012.d.ts +52 -0
- package/dist/conformance/legacy/replica-012.js +403 -0
- package/dist/conformance/paging-budget.test.d.ts +1 -0
- package/dist/conformance/paging-budget.test.js +101 -0
- package/dist/conformance/paging-fixtures.d.ts +80 -0
- package/dist/conformance/paging-fixtures.js +135 -0
- package/dist/conformance/paging-network.test.d.ts +1 -0
- package/dist/conformance/paging-network.test.js +123 -0
- package/dist/conformance/paging-state.test.d.ts +1 -0
- package/dist/conformance/paging-state.test.js +180 -0
- package/dist/conformance/paging-storage.test.d.ts +1 -0
- package/dist/conformance/paging-storage.test.js +255 -0
- package/dist/conformance/paging.test.d.ts +1 -0
- package/dist/conformance/paging.test.js +209 -0
- package/dist/conformance/pull.d.ts +74 -0
- package/dist/conformance/pull.js +400 -0
- package/dist/conformance/push-batch.test.d.ts +2 -0
- package/dist/conformance/push-batch.test.js +66 -0
- package/dist/server/ledger.d.ts +13 -0
- package/dist/server/ledger.js +18 -0
- package/dist/server/pull.d.ts +85 -0
- package/dist/server/pull.js +233 -0
- package/dist/sync.d.ts +141 -14
- package/dist/sync.js +301 -41
- package/dist/web/replica.js +47 -12
- package/package.json +17 -1
package/dist/sync.js
CHANGED
|
@@ -1,5 +1,93 @@
|
|
|
1
1
|
import { unreachable } from "./machine.js";
|
|
2
2
|
import { check } from "./schema.js";
|
|
3
|
+
/** A key a chunk removed, in a table's layer of changes. */
|
|
4
|
+
const GONE = Symbol("gone");
|
|
5
|
+
/**
|
|
6
|
+
* A table's rows after a chunk, without copying the rows it already had: the chunk's changes are a layer over them, so
|
|
7
|
+
* merging a chunk costs its own size, not the table's. Layers merge when a newer one is as big as the one below it, and
|
|
8
|
+
* fold into a plain map once the top one holds half as many keys as it, so lookups pass a logarithmic number of layers
|
|
9
|
+
* and a changed row is copied a logarithmic number of times. Immutable: a published value never changes.
|
|
10
|
+
*/
|
|
11
|
+
class Layered {
|
|
12
|
+
size;
|
|
13
|
+
base;
|
|
14
|
+
/** Newest first. */
|
|
15
|
+
layers;
|
|
16
|
+
constructor(base, layers, size) {
|
|
17
|
+
this.base = base;
|
|
18
|
+
this.layers = layers;
|
|
19
|
+
this.size = size;
|
|
20
|
+
}
|
|
21
|
+
find(key) {
|
|
22
|
+
for (const each of this.layers) {
|
|
23
|
+
if (each.has(key)) {
|
|
24
|
+
return each.get(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return this.base.get(key);
|
|
28
|
+
}
|
|
29
|
+
get(key) {
|
|
30
|
+
const value = this.find(key);
|
|
31
|
+
return value === GONE ? undefined : value;
|
|
32
|
+
}
|
|
33
|
+
// Rows are never undefined.
|
|
34
|
+
has(key) {
|
|
35
|
+
return this.get(key) !== undefined;
|
|
36
|
+
}
|
|
37
|
+
// The base's keys in place, then new keys in the order they first came.
|
|
38
|
+
*entries() {
|
|
39
|
+
for (const key of this.base.keys()) {
|
|
40
|
+
const value = this.get(key);
|
|
41
|
+
if (value !== undefined) {
|
|
42
|
+
yield [key, value];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (let at = this.layers.length - 1; at >= 0; at--) {
|
|
47
|
+
for (const key of this.layers[at]?.keys() ?? []) {
|
|
48
|
+
const value = this.get(key);
|
|
49
|
+
if (!seen.has(key) && !this.base.has(key) && value !== undefined) {
|
|
50
|
+
seen.add(key);
|
|
51
|
+
yield [key, value];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
*keys() {
|
|
57
|
+
for (const [key] of this.entries()) {
|
|
58
|
+
yield key;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
*values() {
|
|
62
|
+
for (const [, value] of this.entries()) {
|
|
63
|
+
yield value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
[Symbol.iterator]() {
|
|
67
|
+
return this.entries();
|
|
68
|
+
}
|
|
69
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Map's receiver is whatever the caller passes
|
|
70
|
+
forEach(each, receiver) {
|
|
71
|
+
for (const [key, value] of this.entries()) {
|
|
72
|
+
each.call(receiver, value, key, this);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** `rows` with `changes` over it, as a new value; `rows` is left as it was. */
|
|
77
|
+
function overlay(rows, changes) {
|
|
78
|
+
let size = rows.size;
|
|
79
|
+
for (const [key, value] of changes) {
|
|
80
|
+
size += (value === GONE ? 0 : 1) - (rows.has(key) ? 1 : 0);
|
|
81
|
+
}
|
|
82
|
+
const base = rows instanceof Layered ? rows.base : rows;
|
|
83
|
+
const below = rows instanceof Layered ? [...rows.layers] : [];
|
|
84
|
+
let top = changes;
|
|
85
|
+
while (below[0] !== undefined && below[0].size <= top.size) {
|
|
86
|
+
top = new Map([...(below.shift() ?? []), ...top]);
|
|
87
|
+
}
|
|
88
|
+
const layered = new Layered(base, [top, ...below], size);
|
|
89
|
+
return top.size * 2 < base.size ? layered : new Map(layered);
|
|
90
|
+
}
|
|
3
91
|
export function table(def) {
|
|
4
92
|
const gone = (row) => def.gone?.(row) === true;
|
|
5
93
|
return {
|
|
@@ -15,16 +103,11 @@ export function table(def) {
|
|
|
15
103
|
if (changed.length === 0 && !reset) {
|
|
16
104
|
return tables;
|
|
17
105
|
}
|
|
18
|
-
const
|
|
106
|
+
const changes = new Map();
|
|
19
107
|
for (const row of changed) {
|
|
20
|
-
|
|
21
|
-
next.delete(def.key(row));
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
next.set(def.key(row), row);
|
|
25
|
-
}
|
|
108
|
+
changes.set(def.key(row), gone(row) ? GONE : row);
|
|
26
109
|
}
|
|
27
|
-
return def.set(tables,
|
|
110
|
+
return def.set(tables, overlay(reset ? new Map() : def.get(tables), changes));
|
|
28
111
|
},
|
|
29
112
|
snapshot: (tables) => [...def.get(tables).entries()].map(([key, row]) => ({ table: def.name, key, row })),
|
|
30
113
|
restore: (tables, values) => {
|
|
@@ -40,6 +123,18 @@ export function table(def) {
|
|
|
40
123
|
},
|
|
41
124
|
};
|
|
42
125
|
}
|
|
126
|
+
const COMPLETE = { kind: "complete" };
|
|
127
|
+
const historyOf = (replica) => replica.history ?? COMPLETE;
|
|
128
|
+
/** The history a page leaves: complete without one (an old server's whole copy) or once no older page is left. */
|
|
129
|
+
const covered = (page) => page === undefined || page.next === null
|
|
130
|
+
? COMPLETE
|
|
131
|
+
: {
|
|
132
|
+
kind: "partial",
|
|
133
|
+
next: page.next,
|
|
134
|
+
through: page.through,
|
|
135
|
+
held: page.held,
|
|
136
|
+
total: page.total,
|
|
137
|
+
};
|
|
43
138
|
/** The socket must be heard this often (the server pokes every 20 s); quieter, the page polls. */
|
|
44
139
|
export const SOCKET_DEADLINE_MS = 45_000;
|
|
45
140
|
/** Polling while the socket can't be trusted or work is in flight, and the first retry after a failure. */
|
|
@@ -47,40 +142,96 @@ export const POLL_MS = 4000;
|
|
|
47
142
|
const BACKOFF_MAX_MS = 30_000;
|
|
48
143
|
/** Storage gets this long to load, or to keep an intent, before the page goes on without it. */
|
|
49
144
|
export const STORAGE_DEADLINE_MS = 3000;
|
|
145
|
+
/** Commands per push unless the spec says otherwise; the rest wait for the next push. */
|
|
146
|
+
export const PUSH_MAX = 50;
|
|
50
147
|
/** Settled commands stay drawn this long. */
|
|
51
148
|
export const DONE_KEEP_MS = 60_000;
|
|
52
149
|
/** Whether the server can be asked now. */
|
|
53
150
|
export function reachable(connection) {
|
|
54
151
|
return connection.kind === "live" || connection.kind === "stale";
|
|
55
152
|
}
|
|
56
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Whether the replica holds this command's effects: it is done, and the copy is at its revision and either complete or
|
|
155
|
+
* holding the rows its receipt names (a pull reply carried them). One that failed on the page has none.
|
|
156
|
+
*/
|
|
57
157
|
export function settled(model, pending) {
|
|
58
|
-
return pending.state.kind === "done" &&
|
|
158
|
+
return (pending.state.kind === "done" &&
|
|
159
|
+
(pending.state.revision === 0 ||
|
|
160
|
+
(model.replica.revision >= pending.state.revision &&
|
|
161
|
+
(historyOf(model.replica).kind === "complete" ||
|
|
162
|
+
model.carried.has(`${pending.client}:${pending.id}`)))));
|
|
59
163
|
}
|
|
60
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Whether what the page shows is also on the disk: by revision only, so tabs at different cursors never chase each
|
|
166
|
+
* other's writes. A failed write is owed again even at the same revision (a page that failed to land).
|
|
167
|
+
*/
|
|
61
168
|
export function saved(model) {
|
|
62
|
-
return (model.saved.dataset === model.replica.dataset &&
|
|
169
|
+
return (model.saved.dataset === model.replica.dataset &&
|
|
170
|
+
model.saved.revision >= model.replica.revision &&
|
|
171
|
+
!model.saved.owed);
|
|
63
172
|
}
|
|
64
173
|
/**
|
|
65
174
|
* What a commit does to the disk, decided inside the disk's own transaction from what it holds: nothing when the disk
|
|
66
|
-
* already has this or newer (another tab got there
|
|
67
|
-
* whole replica, so a skipped or failed patch is never
|
|
175
|
+
* already has this or newer (another tab got there; a complete copy at the same revision is newer than any page), the
|
|
176
|
+
* patch alone when the disk is at least at its base, else the whole replica, so a skipped or failed patch is never
|
|
177
|
+
* covered by a later checkpoint. A delta keeps the disk's own history, and takes the patch's only when the disk is at
|
|
178
|
+
* the cursor the patch moved from (`adopt`): coverage never lands without its rows. A page may replace the disk's
|
|
68
179
|
* dataset only with one it moved to from that very dataset (`replaces`): a tab still on a dataset the server has since
|
|
69
180
|
* replaced is `stale` and never writes it back.
|
|
70
181
|
*/
|
|
71
182
|
export function commitPlan(stored, commit) {
|
|
72
183
|
const same = stored.dataset === commit.dataset;
|
|
184
|
+
const disk = stored.history ?? COMPLETE;
|
|
73
185
|
if (!same && stored.dataset !== null && stored.dataset !== commit.replaces) {
|
|
74
186
|
return "stale";
|
|
75
187
|
}
|
|
76
|
-
if (same &&
|
|
188
|
+
if (same &&
|
|
189
|
+
(stored.revision > commit.revision ||
|
|
190
|
+
(stored.revision === commit.revision && disk.kind === "complete"))) {
|
|
77
191
|
return "skip";
|
|
78
192
|
}
|
|
79
193
|
if (!commit.reset && same && stored.revision >= commit.base) {
|
|
80
|
-
return "delta";
|
|
194
|
+
return disk.kind === "partial" && disk.next === commit.from ? "adopt" : "delta";
|
|
81
195
|
}
|
|
82
196
|
return "whole";
|
|
83
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* The copy's history: loading while storage loads, none without a dataset, else partial or complete. Partial is a claim
|
|
200
|
+
* beside the numbers ("history from <through>, loading older · held of total"), and totals cover only `through` and
|
|
201
|
+
* above. Complete says nothing about storage: that is `deriveDurabilityState`, its own subject.
|
|
202
|
+
*/
|
|
203
|
+
export function deriveHistoryState(model) {
|
|
204
|
+
if (model.boot.kind === "loading") {
|
|
205
|
+
return { kind: "loading" };
|
|
206
|
+
}
|
|
207
|
+
if (model.replica.dataset === null) {
|
|
208
|
+
return { kind: "none" };
|
|
209
|
+
}
|
|
210
|
+
const history = historyOf(model.replica);
|
|
211
|
+
return history.kind === "complete"
|
|
212
|
+
? history
|
|
213
|
+
: { kind: "partial", through: history.through, held: history.held, total: history.total };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The copy's durability at the model's clock: loading, replaced by another tab, a failing store (lost while the page
|
|
217
|
+
* shows more than the disk holds), unsaved past the storage deadline, commands kept that this version can't read, or ok.
|
|
218
|
+
*/
|
|
219
|
+
export function deriveDurabilityState(model) {
|
|
220
|
+
const behind = model.saved.behindSince;
|
|
221
|
+
if (model.boot.kind === "loading") {
|
|
222
|
+
return { kind: "loading" };
|
|
223
|
+
}
|
|
224
|
+
if (model.storage.kind === "replaced") {
|
|
225
|
+
return { kind: "replaced" };
|
|
226
|
+
}
|
|
227
|
+
if (model.storage.kind === "failed") {
|
|
228
|
+
return { kind: behind === null ? "failing" : "lost", reason: model.storage.reason };
|
|
229
|
+
}
|
|
230
|
+
if (behind !== null && model.now - behind >= STORAGE_DEADLINE_MS) {
|
|
231
|
+
return { kind: "unsaved" };
|
|
232
|
+
}
|
|
233
|
+
return model.unreadable > 0 ? { kind: "unreadable", count: model.unreadable } : { kind: "ok" };
|
|
234
|
+
}
|
|
84
235
|
/**
|
|
85
236
|
* Storage after a write that worked: a failure that only a keep or a drop had (the replica's own writes aren't
|
|
86
237
|
* failing) is over; a replaced copy stays replaced.
|
|
@@ -114,11 +265,16 @@ function failedConnection(connection, failure) {
|
|
|
114
265
|
}
|
|
115
266
|
}
|
|
116
267
|
export function createSync(spec) {
|
|
268
|
+
const pushMax = spec.pushMax ?? PUSH_MAX;
|
|
269
|
+
if (!Number.isSafeInteger(pushMax) || pushMax < 1) {
|
|
270
|
+
throw new Error(`sync: pushMax must be a positive integer, got ${pushMax}`);
|
|
271
|
+
}
|
|
117
272
|
const emptyReplica = () => ({
|
|
118
273
|
dataset: null,
|
|
119
274
|
revision: 0,
|
|
120
275
|
tables: spec.empty,
|
|
121
276
|
server: null,
|
|
277
|
+
history: COMPLETE,
|
|
122
278
|
});
|
|
123
279
|
function initial(client, now) {
|
|
124
280
|
return {
|
|
@@ -134,14 +290,17 @@ export function createSync(spec) {
|
|
|
134
290
|
writing: 0,
|
|
135
291
|
failures: 0,
|
|
136
292
|
nextAt: now,
|
|
293
|
+
owed: false,
|
|
137
294
|
},
|
|
138
295
|
target: 0,
|
|
296
|
+
restart: null,
|
|
139
297
|
reconciledAt: null,
|
|
140
298
|
connection: { kind: "starting" },
|
|
141
299
|
socket: { open: false, heardAt: 0 },
|
|
142
300
|
pull: { kind: "idle", nextAt: now, failures: 0 },
|
|
143
301
|
push: { kind: "idle", nextAt: now, failures: 0 },
|
|
144
302
|
commands: [],
|
|
303
|
+
carried: new Set(),
|
|
145
304
|
unreadable: 0,
|
|
146
305
|
visible: true,
|
|
147
306
|
verify: { kind: "pending", tries: 0 },
|
|
@@ -149,22 +308,37 @@ export function createSync(spec) {
|
|
|
149
308
|
};
|
|
150
309
|
}
|
|
151
310
|
/**
|
|
152
|
-
* The one merge: a patch applied to a replica, with its disk operations
|
|
153
|
-
*
|
|
311
|
+
* The one merge: a patch applied to a replica, with its disk operations; undefined when it is older than the replica
|
|
312
|
+
* (a late reply), `gap` when it can't follow from it. Only a reset (or the answer to `since` 0, which holds
|
|
313
|
+
* everything) replaces the copy, with its own history. A delta applies only to the same dataset with
|
|
314
|
+
* `since ≤ copy.revision ≤ reply.revision` (equal only with a page or new server facts); it moves the cursor only when
|
|
315
|
+
* it answers the copy's own `next`, and a reply without a page never changes the history.
|
|
154
316
|
*/
|
|
155
|
-
function merge(replica, patch) {
|
|
156
|
-
const reset = patch.reset ||
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
317
|
+
function merge(replica, patch, since) {
|
|
318
|
+
const reset = patch.reset || since === 0;
|
|
319
|
+
const page = patch.page;
|
|
320
|
+
const was = historyOf(replica);
|
|
321
|
+
let history = was;
|
|
322
|
+
if (reset) {
|
|
323
|
+
// A reset older than what we hold of the same dataset is a late reply.
|
|
324
|
+
if (patch.dataset === replica.dataset && patch.revision < replica.revision) {
|
|
325
|
+
return undefined;
|
|
162
326
|
}
|
|
163
|
-
|
|
327
|
+
history = covered(page);
|
|
328
|
+
}
|
|
329
|
+
else if (patch.dataset !== replica.dataset || since > replica.revision) {
|
|
330
|
+
return "gap";
|
|
164
331
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
332
|
+
else if (patch.revision < replica.revision ||
|
|
333
|
+
(patch.revision === replica.revision && (page === undefined || was.kind === "complete"))) {
|
|
334
|
+
// Nothing newer, but the server's own facts (version, settings) may have moved.
|
|
335
|
+
return patch.revision === replica.revision &&
|
|
336
|
+
JSON.stringify(patch.server) !== JSON.stringify(replica.server)
|
|
337
|
+
? { replica: { ...replica, server: patch.server }, ops: [], reset: false }
|
|
338
|
+
: undefined;
|
|
339
|
+
}
|
|
340
|
+
else if (page !== undefined && was.kind === "partial" && page.from === was.next) {
|
|
341
|
+
history = covered(page);
|
|
168
342
|
}
|
|
169
343
|
let tables = reset ? spec.empty : replica.tables;
|
|
170
344
|
const ops = [];
|
|
@@ -173,7 +347,13 @@ export function createSync(spec) {
|
|
|
173
347
|
ops.push(...lens.ops(patch.rows));
|
|
174
348
|
}
|
|
175
349
|
return {
|
|
176
|
-
replica: {
|
|
350
|
+
replica: {
|
|
351
|
+
dataset: patch.dataset,
|
|
352
|
+
revision: patch.revision,
|
|
353
|
+
server: patch.server,
|
|
354
|
+
tables,
|
|
355
|
+
history,
|
|
356
|
+
},
|
|
177
357
|
ops,
|
|
178
358
|
reset,
|
|
179
359
|
};
|
|
@@ -190,19 +370,36 @@ export function createSync(spec) {
|
|
|
190
370
|
const behind = next.replica.revision < next.target;
|
|
191
371
|
const unsettled = next.commands.some((each) => each.state.kind === "done" && !settled(next, each));
|
|
192
372
|
const polling = next.connection.kind !== "live" || unsettled || (spec.outstanding?.(next.replica) ?? false);
|
|
193
|
-
const
|
|
194
|
-
|
|
373
|
+
const history = historyOf(next.replica);
|
|
374
|
+
const whole = next.restart === "whole";
|
|
375
|
+
// Only a complete copy is checked: a fingerprint of everything can't certify part of it.
|
|
376
|
+
const verifying = next.verify.kind === "pending" && (history.kind === "complete" || whole);
|
|
377
|
+
// A partial copy owes its next page, back to back, until it is complete.
|
|
378
|
+
const owed = behind || verifying || history.kind === "partial" || next.restart !== null;
|
|
379
|
+
const since = next.restart === null ? next.replica.revision : 0;
|
|
380
|
+
if ((owed && (failures === 0 || due)) || (polling && due)) {
|
|
381
|
+
const current = next;
|
|
382
|
+
// Commands waiting on an outcome or on their effects, oldest first, at most a push's worth: their receipts
|
|
383
|
+
// come back with the rows they name; the rest in the next pull.
|
|
384
|
+
const commands = current.commands
|
|
385
|
+
.filter((each) => each.state.kind === "sending" ||
|
|
386
|
+
each.state.kind === "unknown" ||
|
|
387
|
+
(each.state.kind === "done" && !settled(current, each)))
|
|
388
|
+
.slice(0, pushMax)
|
|
389
|
+
.map((each) => ({ client: each.client, id: each.id }));
|
|
195
390
|
out.push({
|
|
196
391
|
kind: "pull",
|
|
197
392
|
client: next.client,
|
|
198
393
|
dataset: next.replica.dataset,
|
|
199
|
-
since
|
|
394
|
+
since,
|
|
200
395
|
verify: verifying,
|
|
396
|
+
...(whole ? {} : { page: history.kind === "partial" && since > 0 ? history.next : null }),
|
|
397
|
+
commands,
|
|
201
398
|
});
|
|
202
399
|
next = {
|
|
203
400
|
...next,
|
|
204
|
-
pull: { kind: "pulling", since
|
|
205
|
-
verify: next.verify.kind === "pending"
|
|
401
|
+
pull: { kind: "pulling", since, failures },
|
|
402
|
+
verify: verifying && next.verify.kind === "pending"
|
|
206
403
|
? { kind: "requested", tries: next.verify.tries }
|
|
207
404
|
: next.verify,
|
|
208
405
|
};
|
|
@@ -212,12 +409,14 @@ export function createSync(spec) {
|
|
|
212
409
|
const first = next.commands.find((each) => each.state.kind === "queued" || each.state.kind === "unknown");
|
|
213
410
|
if (first !== undefined) {
|
|
214
411
|
// One push per client, dataset and tapped-against revision: the server judges each by what it was made on.
|
|
412
|
+
// At most `pushMax`, oldest first; the rest go in the next push.
|
|
215
413
|
const tapped = (each) => each.since ?? next.replica.revision;
|
|
216
414
|
const ids = new Set(next.commands
|
|
217
415
|
.filter((each) => each.client === first.client &&
|
|
218
416
|
each.dataset === first.dataset &&
|
|
219
417
|
tapped(each) === tapped(first) &&
|
|
220
418
|
(each.state.kind === "queued" || each.state.kind === "unknown"))
|
|
419
|
+
.slice(0, pushMax)
|
|
221
420
|
.map((each) => each.id));
|
|
222
421
|
const mine = (each) => each.client === first.client && ids.has(each.id);
|
|
223
422
|
out.push({
|
|
@@ -347,20 +546,60 @@ export function createSync(spec) {
|
|
|
347
546
|
];
|
|
348
547
|
}
|
|
349
548
|
case "pulled": {
|
|
350
|
-
const
|
|
549
|
+
const result = merge(at.replica, msg.patch, msg.since);
|
|
550
|
+
const merged = result === "gap" ? undefined : result;
|
|
351
551
|
const replica = merged?.replica ?? at.replica;
|
|
352
552
|
const effects = [];
|
|
553
|
+
const was = historyOf(at.replica);
|
|
554
|
+
const history = historyOf(replica);
|
|
555
|
+
const failures = at.pull.kind === "pulling" ? at.pull.failures : 0;
|
|
556
|
+
// An old server answering a partial copy (with rows or without): only its whole copy completes it.
|
|
557
|
+
const old = was.kind === "partial" &&
|
|
558
|
+
msg.patch.page === undefined &&
|
|
559
|
+
!msg.patch.reset &&
|
|
560
|
+
msg.since > 0 &&
|
|
561
|
+
result !== "gap";
|
|
562
|
+
// A partial copy this reply didn't move on (the same page again, a repeated first page at the same revision, a
|
|
563
|
+
// reply it couldn't take) is a pull that failed, for backoff: never a busy loop on a server that doesn't move.
|
|
564
|
+
const stuck = was.kind === "partial" &&
|
|
565
|
+
!old &&
|
|
566
|
+
(merged === undefined ||
|
|
567
|
+
(msg.patch.page !== undefined &&
|
|
568
|
+
history.kind === "partial" &&
|
|
569
|
+
history.next === was.next &&
|
|
570
|
+
replica.dataset === at.replica.dataset &&
|
|
571
|
+
!(merged.reset && replica.revision > at.replica.revision)));
|
|
572
|
+
// Receipts that came with an applied reply came with the rows they name.
|
|
573
|
+
const carried = new Set(merged?.reset === true ? [] : at.carried);
|
|
574
|
+
if (merged !== undefined) {
|
|
575
|
+
for (const each of msg.receipts) {
|
|
576
|
+
if (each.revision <= replica.revision) {
|
|
577
|
+
carried.add(`${each.client ?? at.client}:${each.id}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
353
581
|
let next = {
|
|
354
582
|
...at,
|
|
355
583
|
replica,
|
|
584
|
+
carried,
|
|
356
585
|
target: msg.patch.dataset === at.replica.dataset
|
|
357
586
|
? Math.max(at.target, msg.patch.revision)
|
|
358
587
|
: msg.patch.revision,
|
|
588
|
+
// A reply it couldn't follow asks a real reset; an old server's reply to a partial copy, its whole copy.
|
|
589
|
+
restart: result === "gap"
|
|
590
|
+
? (at.restart ?? "paged")
|
|
591
|
+
: merged?.reset === true
|
|
592
|
+
? null
|
|
593
|
+
: old
|
|
594
|
+
? "whole"
|
|
595
|
+
: at.restart,
|
|
359
596
|
reconciledAt: now,
|
|
360
597
|
connection: at.socket.open && now - at.socket.heardAt < SOCKET_DEADLINE_MS
|
|
361
598
|
? { kind: "live" }
|
|
362
599
|
: { kind: "stale" },
|
|
363
|
-
pull:
|
|
600
|
+
pull: stuck
|
|
601
|
+
? { kind: "idle", nextAt: now + backoff(failures + 1), failures: failures + 1 }
|
|
602
|
+
: { kind: "idle", nextAt: now + POLL_MS, failures: 0 },
|
|
364
603
|
};
|
|
365
604
|
if (merged !== undefined && next.storage.kind !== "replaced") {
|
|
366
605
|
effects.push({
|
|
@@ -370,12 +609,15 @@ export function createSync(spec) {
|
|
|
370
609
|
reset: merged.reset,
|
|
371
610
|
base: msg.since,
|
|
372
611
|
replaces: at.saved.dataset,
|
|
612
|
+
from: was.kind === "partial" ? was.next : null,
|
|
373
613
|
});
|
|
374
614
|
next = { ...next, saved: { ...next.saved, writing: next.saved.writing + 1 } };
|
|
375
615
|
}
|
|
376
616
|
if (at.verify.kind === "requested") {
|
|
377
|
-
// The fingerprint describes the server at the reply's revision: comparable only if the replica is there
|
|
617
|
+
// The fingerprint describes the server at the reply's revision: comparable only if the replica is there, and
|
|
618
|
+
// whole (a fingerprint of everything never certifies part of it).
|
|
378
619
|
if (msg.patch.checksum !== undefined &&
|
|
620
|
+
history.kind === "complete" &&
|
|
379
621
|
replica.revision === msg.patch.revision &&
|
|
380
622
|
replica.dataset === msg.patch.dataset) {
|
|
381
623
|
effects.push({ kind: "check", replica, checksum: msg.patch.checksum });
|
|
@@ -384,13 +626,21 @@ export function createSync(spec) {
|
|
|
384
626
|
next = { ...next, verify: { kind: "pending", tries: at.verify.tries } };
|
|
385
627
|
}
|
|
386
628
|
}
|
|
387
|
-
|
|
629
|
+
// Receipts by client: an earlier page's commands are answered too.
|
|
630
|
+
let answered = next;
|
|
631
|
+
const more = [];
|
|
632
|
+
for (const client of new Set(msg.receipts.map((each) => each.client ?? at.client))) {
|
|
633
|
+
const [after, told] = answer(answered, client, msg.receipts.filter((each) => (each.client ?? at.client) === client));
|
|
634
|
+
answered = after;
|
|
635
|
+
more.push(...told);
|
|
636
|
+
}
|
|
388
637
|
// Intents tapped against a store the server no longer has never go to its replacement (the server refuses
|
|
389
638
|
// them too). One that may already have run there says so.
|
|
390
639
|
const replaced = answered.commands.map((each) => {
|
|
391
640
|
if (each.state.kind === "done" ||
|
|
392
641
|
each.dataset === null ||
|
|
393
|
-
|
|
642
|
+
replica.dataset === null ||
|
|
643
|
+
each.dataset === replica.dataset) {
|
|
394
644
|
return each;
|
|
395
645
|
}
|
|
396
646
|
const done = fail(each, each.state.kind === "unknown"
|
|
@@ -466,10 +716,12 @@ export function createSync(spec) {
|
|
|
466
716
|
}
|
|
467
717
|
case "committed": {
|
|
468
718
|
const same = msg.dataset === at.saved.dataset;
|
|
719
|
+
// A delta the disk took under its own history doesn't repair a failed write: the whole one stays owed.
|
|
720
|
+
const owed = at.saved.owed && msg.whole === false;
|
|
469
721
|
return [
|
|
470
722
|
{
|
|
471
723
|
...at,
|
|
472
|
-
storage: at.storage.kind === "replaced" ? at.storage : { kind: "ok" },
|
|
724
|
+
storage: at.storage.kind === "replaced" || owed ? at.storage : { kind: "ok" },
|
|
473
725
|
saved: {
|
|
474
726
|
dataset: msg.dataset,
|
|
475
727
|
revision: same ? Math.max(at.saved.revision, msg.revision) : msg.revision,
|
|
@@ -477,6 +729,7 @@ export function createSync(spec) {
|
|
|
477
729
|
writing: Math.max(0, at.saved.writing - 1),
|
|
478
730
|
failures: 0,
|
|
479
731
|
nextAt: now,
|
|
732
|
+
owed,
|
|
480
733
|
},
|
|
481
734
|
},
|
|
482
735
|
[],
|
|
@@ -493,6 +746,7 @@ export function createSync(spec) {
|
|
|
493
746
|
writing: Math.max(0, at.saved.writing - 1),
|
|
494
747
|
failures,
|
|
495
748
|
nextAt: now + backoff(failures),
|
|
749
|
+
owed: true,
|
|
496
750
|
},
|
|
497
751
|
},
|
|
498
752
|
[{ kind: "telemetry", name: "replica.save-failed", attributes: { reason: msg.reason } }],
|
|
@@ -677,6 +931,12 @@ export function createSync(spec) {
|
|
|
677
931
|
let marked = since === next.saved.behindSince
|
|
678
932
|
? next
|
|
679
933
|
: { ...next, saved: { ...next.saved, behindSince: since } };
|
|
934
|
+
// Commands that left the page leave what was carried for them.
|
|
935
|
+
const kept = new Set(marked.commands.map((each) => `${each.client}:${each.id}`));
|
|
936
|
+
const carried = [...marked.carried].filter((key) => kept.has(key));
|
|
937
|
+
if (carried.length < marked.carried.size) {
|
|
938
|
+
marked = { ...marked, carried: new Set(carried) };
|
|
939
|
+
}
|
|
680
940
|
// A command that resolves only now (its result came later than its receipt) is drawn its full time from now.
|
|
681
941
|
const resolved = spec.resolved;
|
|
682
942
|
if (resolved !== undefined) {
|