slopkit 0.1.2 → 0.3.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 +51 -0
- package/dist/client.d.ts +31 -4
- package/dist/client.js +25 -2
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +55 -3
- package/dist/conformance/fixtures.js +9 -5
- package/dist/conformance/legacy/replica-012.d.ts +52 -0
- package/dist/conformance/legacy/replica-012.js +403 -0
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -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-bytes.test.d.ts +1 -0
- package/dist/conformance/push-bytes.test.js +174 -0
- package/dist/conformance/push-refusal.test.d.ts +1 -0
- package/dist/conformance/push-refusal.test.js +644 -0
- package/dist/contract.d.ts +19 -6
- package/dist/contract.js +76 -9
- 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 +324 -0
- package/dist/server/web.d.ts +9 -5
- package/dist/server/web.js +29 -12
- package/dist/sync.d.ts +221 -16
- package/dist/sync.js +565 -96
- package/dist/web/replica.js +93 -16
- 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. */
|
|
@@ -49,40 +144,128 @@ const BACKOFF_MAX_MS = 30_000;
|
|
|
49
144
|
export const STORAGE_DEADLINE_MS = 3000;
|
|
50
145
|
/** Commands per push unless the spec says otherwise; the rest wait for the next push. */
|
|
51
146
|
export const PUSH_MAX = 50;
|
|
147
|
+
/** UTF-8 bytes of one push request unless the spec says otherwise (48 KiB). */
|
|
148
|
+
export const PUSH_BYTES = 49_152;
|
|
149
|
+
/** Commands the outbox admits unless the spec says otherwise; updates to kept ones always are. */
|
|
150
|
+
export const OUTBOX_MAX = 500;
|
|
52
151
|
/** Settled commands stay drawn this long. */
|
|
53
152
|
export const DONE_KEEP_MS = 60_000;
|
|
153
|
+
/** A string's length in UTF-8 bytes, as it goes on the wire. */
|
|
154
|
+
function utf8(text) {
|
|
155
|
+
let bytes = 0;
|
|
156
|
+
// By code point: a surrogate pair is one character of four bytes, a lone surrogate encodes as three.
|
|
157
|
+
for (const char of text) {
|
|
158
|
+
const code = char.codePointAt(0) ?? 0;
|
|
159
|
+
bytes += code < 0x80 ? 1 : code < 0x8_00 ? 2 : code < 0x1_00_00 ? 3 : 4;
|
|
160
|
+
}
|
|
161
|
+
return bytes;
|
|
162
|
+
}
|
|
163
|
+
/** A push's bytes as `call` encodes it: this envelope, each command's bytes, and a comma between commands. */
|
|
164
|
+
const envelope = (client, dataset, since) => utf8(JSON.stringify({ client, dataset, since, commands: [] }));
|
|
165
|
+
const bytesOf = (pending) => utf8(JSON.stringify({ id: pending.id, command: pending.command }));
|
|
166
|
+
/**
|
|
167
|
+
* Where the outbox stands: full once it holds `max` commands, by this page's count or, failing that, by the store's
|
|
168
|
+
* last refusal (other tabs' commands count too). Unreadable kept commands take room as well.
|
|
169
|
+
*/
|
|
170
|
+
export function deriveOutboxState(model) {
|
|
171
|
+
const finished = model.commands.filter((each) => each.state.kind === "done").length;
|
|
172
|
+
const waiting = model.commands.length - finished + model.unreadable;
|
|
173
|
+
if (waiting + finished >= model.outbox.max) {
|
|
174
|
+
return { kind: "full", waiting, finished, max: model.outbox.max };
|
|
175
|
+
}
|
|
176
|
+
return model.outbox.full ?? { kind: "ok" };
|
|
177
|
+
}
|
|
178
|
+
/** The command a push's schema path names (`request.commands[2].command.value` names 2), if it names one. */
|
|
179
|
+
function commandAt(path) {
|
|
180
|
+
const found = /^request\.commands\[(\d+)\](?:$|[.[])/.exec(path ?? "");
|
|
181
|
+
return found?.[1] === undefined ? undefined : Number(found[1]);
|
|
182
|
+
}
|
|
54
183
|
/** Whether the server can be asked now. */
|
|
55
184
|
export function reachable(connection) {
|
|
56
185
|
return connection.kind === "live" || connection.kind === "stale";
|
|
57
186
|
}
|
|
58
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* Whether the replica holds this command's effects: it is done, and the copy is at its revision and either complete or
|
|
189
|
+
* holding the rows its receipt names (a pull reply carried them). One that failed on the page has none.
|
|
190
|
+
*/
|
|
59
191
|
export function settled(model, pending) {
|
|
60
|
-
return pending.state.kind === "done" &&
|
|
192
|
+
return (pending.state.kind === "done" &&
|
|
193
|
+
(pending.state.revision === 0 ||
|
|
194
|
+
(model.replica.revision >= pending.state.revision &&
|
|
195
|
+
(historyOf(model.replica).kind === "complete" ||
|
|
196
|
+
model.carried.has(`${pending.client}:${pending.id}`)))));
|
|
61
197
|
}
|
|
62
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Whether what the page shows is also on the disk: by revision only, so tabs at different cursors never chase each
|
|
200
|
+
* other's writes. A failed write is owed again even at the same revision (a page that failed to land).
|
|
201
|
+
*/
|
|
63
202
|
export function saved(model) {
|
|
64
|
-
return (model.saved.dataset === model.replica.dataset &&
|
|
203
|
+
return (model.saved.dataset === model.replica.dataset &&
|
|
204
|
+
model.saved.revision >= model.replica.revision &&
|
|
205
|
+
!model.saved.owed);
|
|
65
206
|
}
|
|
66
207
|
/**
|
|
67
208
|
* What a commit does to the disk, decided inside the disk's own transaction from what it holds: nothing when the disk
|
|
68
|
-
* already has this or newer (another tab got there
|
|
69
|
-
* whole replica, so a skipped or failed patch is never
|
|
209
|
+
* already has this or newer (another tab got there; a complete copy at the same revision is newer than any page), the
|
|
210
|
+
* patch alone when the disk is at least at its base, else the whole replica, so a skipped or failed patch is never
|
|
211
|
+
* covered by a later checkpoint. A delta keeps the disk's own history, and takes the patch's only when the disk is at
|
|
212
|
+
* the cursor the patch moved from (`adopt`): coverage never lands without its rows. A page may replace the disk's
|
|
70
213
|
* dataset only with one it moved to from that very dataset (`replaces`): a tab still on a dataset the server has since
|
|
71
214
|
* replaced is `stale` and never writes it back.
|
|
72
215
|
*/
|
|
73
216
|
export function commitPlan(stored, commit) {
|
|
74
217
|
const same = stored.dataset === commit.dataset;
|
|
218
|
+
const disk = stored.history ?? COMPLETE;
|
|
75
219
|
if (!same && stored.dataset !== null && stored.dataset !== commit.replaces) {
|
|
76
220
|
return "stale";
|
|
77
221
|
}
|
|
78
|
-
if (same &&
|
|
222
|
+
if (same &&
|
|
223
|
+
(stored.revision > commit.revision ||
|
|
224
|
+
(stored.revision === commit.revision && disk.kind === "complete"))) {
|
|
79
225
|
return "skip";
|
|
80
226
|
}
|
|
81
227
|
if (!commit.reset && same && stored.revision >= commit.base) {
|
|
82
|
-
return "delta";
|
|
228
|
+
return disk.kind === "partial" && disk.next === commit.from ? "adopt" : "delta";
|
|
83
229
|
}
|
|
84
230
|
return "whole";
|
|
85
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* The copy's history: loading while storage loads, none without a dataset, else partial or complete. Partial is a claim
|
|
234
|
+
* beside the numbers ("history from <through>, loading older · held of total"), and totals cover only `through` and
|
|
235
|
+
* above. Complete says nothing about storage: that is `deriveDurabilityState`, its own subject.
|
|
236
|
+
*/
|
|
237
|
+
export function deriveHistoryState(model) {
|
|
238
|
+
if (model.boot.kind === "loading") {
|
|
239
|
+
return { kind: "loading" };
|
|
240
|
+
}
|
|
241
|
+
if (model.replica.dataset === null) {
|
|
242
|
+
return { kind: "none" };
|
|
243
|
+
}
|
|
244
|
+
const history = historyOf(model.replica);
|
|
245
|
+
return history.kind === "complete"
|
|
246
|
+
? history
|
|
247
|
+
: { kind: "partial", through: history.through, held: history.held, total: history.total };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The copy's durability at the model's clock: loading, replaced by another tab, a failing store (lost while the page
|
|
251
|
+
* shows more than the disk holds), unsaved past the storage deadline, commands kept that this version can't read, or ok.
|
|
252
|
+
*/
|
|
253
|
+
export function deriveDurabilityState(model) {
|
|
254
|
+
const behind = model.saved.behindSince;
|
|
255
|
+
if (model.boot.kind === "loading") {
|
|
256
|
+
return { kind: "loading" };
|
|
257
|
+
}
|
|
258
|
+
if (model.storage.kind === "replaced") {
|
|
259
|
+
return { kind: "replaced" };
|
|
260
|
+
}
|
|
261
|
+
if (model.storage.kind === "failed") {
|
|
262
|
+
return { kind: behind === null ? "failing" : "lost", reason: model.storage.reason };
|
|
263
|
+
}
|
|
264
|
+
if (behind !== null && model.now - behind >= STORAGE_DEADLINE_MS) {
|
|
265
|
+
return { kind: "unsaved" };
|
|
266
|
+
}
|
|
267
|
+
return model.unreadable > 0 ? { kind: "unreadable", count: model.unreadable } : { kind: "ok" };
|
|
268
|
+
}
|
|
86
269
|
/**
|
|
87
270
|
* Storage after a write that worked: a failure that only a keep or a drop had (the replica's own writes aren't
|
|
88
271
|
* failing) is over; a replaced copy stays replaced.
|
|
@@ -100,7 +283,8 @@ function failedConnection(connection, failure) {
|
|
|
100
283
|
case "signed-out": {
|
|
101
284
|
return { kind: "signed-out" };
|
|
102
285
|
}
|
|
103
|
-
case "invalid":
|
|
286
|
+
case "invalid":
|
|
287
|
+
case "unreadable": {
|
|
104
288
|
return { kind: "incompatible", failure: failure.message };
|
|
105
289
|
}
|
|
106
290
|
case "forbidden":
|
|
@@ -117,14 +301,23 @@ function failedConnection(connection, failure) {
|
|
|
117
301
|
}
|
|
118
302
|
export function createSync(spec) {
|
|
119
303
|
const pushMax = spec.pushMax ?? PUSH_MAX;
|
|
120
|
-
|
|
121
|
-
|
|
304
|
+
const pushBytes = spec.pushBytes ?? PUSH_BYTES;
|
|
305
|
+
const outboxMax = spec.outboxMax ?? OUTBOX_MAX;
|
|
306
|
+
for (const [name, value] of [
|
|
307
|
+
["pushMax", pushMax],
|
|
308
|
+
["pushBytes", pushBytes],
|
|
309
|
+
["outboxMax", outboxMax],
|
|
310
|
+
]) {
|
|
311
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
312
|
+
throw new Error(`sync: ${name} must be a positive integer, got ${value}`);
|
|
313
|
+
}
|
|
122
314
|
}
|
|
123
315
|
const emptyReplica = () => ({
|
|
124
316
|
dataset: null,
|
|
125
317
|
revision: 0,
|
|
126
318
|
tables: spec.empty,
|
|
127
319
|
server: null,
|
|
320
|
+
history: COMPLETE,
|
|
128
321
|
});
|
|
129
322
|
function initial(client, now) {
|
|
130
323
|
return {
|
|
@@ -140,14 +333,19 @@ export function createSync(spec) {
|
|
|
140
333
|
writing: 0,
|
|
141
334
|
failures: 0,
|
|
142
335
|
nextAt: now,
|
|
336
|
+
owed: false,
|
|
143
337
|
},
|
|
144
338
|
target: 0,
|
|
339
|
+
restart: null,
|
|
145
340
|
reconciledAt: null,
|
|
146
341
|
connection: { kind: "starting" },
|
|
147
342
|
socket: { open: false, heardAt: 0 },
|
|
148
343
|
pull: { kind: "idle", nextAt: now, failures: 0 },
|
|
149
344
|
push: { kind: "idle", nextAt: now, failures: 0 },
|
|
150
345
|
commands: [],
|
|
346
|
+
outbox: { max: outboxMax, full: null },
|
|
347
|
+
asking: 0,
|
|
348
|
+
carried: new Set(),
|
|
151
349
|
unreadable: 0,
|
|
152
350
|
visible: true,
|
|
153
351
|
verify: { kind: "pending", tries: 0 },
|
|
@@ -155,22 +353,37 @@ export function createSync(spec) {
|
|
|
155
353
|
};
|
|
156
354
|
}
|
|
157
355
|
/**
|
|
158
|
-
* The one merge: a patch applied to a replica, with its disk operations
|
|
159
|
-
*
|
|
356
|
+
* The one merge: a patch applied to a replica, with its disk operations; undefined when it is older than the replica
|
|
357
|
+
* (a late reply), `gap` when it can't follow from it. Only a reset (or the answer to `since` 0, which holds
|
|
358
|
+
* everything) replaces the copy, with its own history. A delta applies only to the same dataset with
|
|
359
|
+
* `since ≤ copy.revision ≤ reply.revision` (equal only with a page or new server facts); it moves the cursor only when
|
|
360
|
+
* it answers the copy's own `next`, and a reply without a page never changes the history.
|
|
160
361
|
*/
|
|
161
|
-
function merge(replica, patch) {
|
|
162
|
-
const reset = patch.reset ||
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
362
|
+
function merge(replica, patch, since) {
|
|
363
|
+
const reset = patch.reset || since === 0;
|
|
364
|
+
const page = patch.page;
|
|
365
|
+
const was = historyOf(replica);
|
|
366
|
+
let history = was;
|
|
367
|
+
if (reset) {
|
|
368
|
+
// A reset older than what we hold of the same dataset is a late reply.
|
|
369
|
+
if (patch.dataset === replica.dataset && patch.revision < replica.revision) {
|
|
370
|
+
return undefined;
|
|
168
371
|
}
|
|
169
|
-
|
|
372
|
+
history = covered(page);
|
|
170
373
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
374
|
+
else if (patch.dataset !== replica.dataset || since > replica.revision) {
|
|
375
|
+
return "gap";
|
|
376
|
+
}
|
|
377
|
+
else if (patch.revision < replica.revision ||
|
|
378
|
+
(patch.revision === replica.revision && (page === undefined || was.kind === "complete"))) {
|
|
379
|
+
// Nothing newer, but the server's own facts (version, settings) may have moved.
|
|
380
|
+
return patch.revision === replica.revision &&
|
|
381
|
+
JSON.stringify(patch.server) !== JSON.stringify(replica.server)
|
|
382
|
+
? { replica: { ...replica, server: patch.server }, ops: [], reset: false }
|
|
383
|
+
: undefined;
|
|
384
|
+
}
|
|
385
|
+
else if (page !== undefined && was.kind === "partial" && page.from === was.next) {
|
|
386
|
+
history = covered(page);
|
|
174
387
|
}
|
|
175
388
|
let tables = reset ? spec.empty : replica.tables;
|
|
176
389
|
const ops = [];
|
|
@@ -179,12 +392,51 @@ export function createSync(spec) {
|
|
|
179
392
|
ops.push(...lens.ops(patch.rows));
|
|
180
393
|
}
|
|
181
394
|
return {
|
|
182
|
-
replica: {
|
|
395
|
+
replica: {
|
|
396
|
+
dataset: patch.dataset,
|
|
397
|
+
revision: patch.revision,
|
|
398
|
+
server: patch.server,
|
|
399
|
+
tables,
|
|
400
|
+
history,
|
|
401
|
+
},
|
|
183
402
|
ops,
|
|
184
403
|
reset,
|
|
185
404
|
};
|
|
186
405
|
}
|
|
187
406
|
const ready = (model) => model.boot.kind === "ready";
|
|
407
|
+
const keep = (pending) => ({ kind: "keep", pending, max: outboxMax });
|
|
408
|
+
/** Waiting to be sent: queued, or unknown and not parked (asked again under its key). */
|
|
409
|
+
const sendable = (each) => each.state.kind === "queued" || (each.state.kind === "unknown" && each.state.parked !== true);
|
|
410
|
+
const tooLarge = (bytes) => `this is too large to send: ${bytes} bytes, where a push holds at most ${pushBytes}`;
|
|
411
|
+
/** A command that failed on the page: done with that outcome, never sent, and its kept intent dropped. */
|
|
412
|
+
function fail(pending, message, now) {
|
|
413
|
+
return {
|
|
414
|
+
...pending,
|
|
415
|
+
at: now,
|
|
416
|
+
state: { kind: "done", outcome: spec.refused(pending.command, message), revision: 0 },
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* A command the server refused by name, or one too large to send: final if no attempt of it may have acted; else
|
|
421
|
+
* parked, since a refusal proves only that attempt didn't act. Kept either way, and passed over by every push.
|
|
422
|
+
*/
|
|
423
|
+
function refuse(pending, doubt, message, now, effects) {
|
|
424
|
+
if (doubt) {
|
|
425
|
+
const parked = {
|
|
426
|
+
...pending,
|
|
427
|
+
state: { kind: "unknown", failure: message, parked: true },
|
|
428
|
+
};
|
|
429
|
+
effects.push(keep(parked), {
|
|
430
|
+
kind: "telemetry",
|
|
431
|
+
name: "command.parked",
|
|
432
|
+
attributes: { reason: message },
|
|
433
|
+
});
|
|
434
|
+
return parked;
|
|
435
|
+
}
|
|
436
|
+
const done = fail(pending, message, now);
|
|
437
|
+
effects.push(keep(done), { kind: "answered", pending: done });
|
|
438
|
+
return done;
|
|
439
|
+
}
|
|
188
440
|
/** Start what is due: the obligations are read off the model after every message, never remembered. */
|
|
189
441
|
function schedule(model, effects) {
|
|
190
442
|
let next = model;
|
|
@@ -196,56 +448,103 @@ export function createSync(spec) {
|
|
|
196
448
|
const behind = next.replica.revision < next.target;
|
|
197
449
|
const unsettled = next.commands.some((each) => each.state.kind === "done" && !settled(next, each));
|
|
198
450
|
const polling = next.connection.kind !== "live" || unsettled || (spec.outstanding?.(next.replica) ?? false);
|
|
199
|
-
const
|
|
200
|
-
|
|
451
|
+
const history = historyOf(next.replica);
|
|
452
|
+
const whole = next.restart === "whole";
|
|
453
|
+
// Only a complete copy is checked: a fingerprint of everything can't certify part of it.
|
|
454
|
+
const verifying = next.verify.kind === "pending" && (history.kind === "complete" || whole);
|
|
455
|
+
// A partial copy owes its next page, back to back, until it is complete.
|
|
456
|
+
const owed = behind || verifying || history.kind === "partial" || next.restart !== null;
|
|
457
|
+
const since = next.restart === null ? next.replica.revision : 0;
|
|
458
|
+
if ((owed && (failures === 0 || due)) || (polling && due)) {
|
|
459
|
+
const current = next;
|
|
460
|
+
// Commands waiting on an outcome or on their effects, at most a push's worth: their receipts come back with
|
|
461
|
+
// the rows they name. When more wait, each pull names the next ones round, so every one is asked about in
|
|
462
|
+
// turn (a parked command has no other way to settle).
|
|
463
|
+
const waiting = current.commands
|
|
464
|
+
.filter((each) => each.state.kind === "sending" ||
|
|
465
|
+
each.state.kind === "unknown" ||
|
|
466
|
+
(each.state.kind === "done" && !settled(current, each)))
|
|
467
|
+
.map((each) => ({ client: each.client, id: each.id }));
|
|
468
|
+
const turn = waiting.length > pushMax ? current.asking % waiting.length : 0;
|
|
469
|
+
const commands = [...waiting.slice(turn), ...waiting.slice(0, turn)].slice(0, pushMax);
|
|
201
470
|
out.push({
|
|
202
471
|
kind: "pull",
|
|
203
472
|
client: next.client,
|
|
204
473
|
dataset: next.replica.dataset,
|
|
205
|
-
since
|
|
474
|
+
since,
|
|
206
475
|
verify: verifying,
|
|
476
|
+
...(whole ? {} : { page: history.kind === "partial" && since > 0 ? history.next : null }),
|
|
477
|
+
commands,
|
|
207
478
|
});
|
|
208
479
|
next = {
|
|
209
480
|
...next,
|
|
210
|
-
|
|
211
|
-
|
|
481
|
+
asking: waiting.length > pushMax ? turn + pushMax : 0,
|
|
482
|
+
pull: { kind: "pulling", since, failures },
|
|
483
|
+
verify: verifying && next.verify.kind === "pending"
|
|
212
484
|
? { kind: "requested", tries: next.verify.tries }
|
|
213
485
|
: next.verify,
|
|
214
486
|
};
|
|
215
487
|
}
|
|
216
488
|
}
|
|
217
489
|
if (next.push.kind === "idle" && now >= next.push.nextAt && reachable(next.connection)) {
|
|
218
|
-
const
|
|
490
|
+
const tapped = (each) => each.since ?? next.replica.revision;
|
|
491
|
+
let first = next.commands.find(sendable);
|
|
492
|
+
// A kept command too large to send even alone (an older release kept it, or the bound was lowered) is refused,
|
|
493
|
+
// or parked if an attempt may have acted, and passed over: it never strands the commands behind it.
|
|
494
|
+
while (first !== undefined) {
|
|
495
|
+
const alone = envelope(first.client, first.dataset, tapped(first)) + bytesOf(first);
|
|
496
|
+
if (alone <= pushBytes) {
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
const large = first;
|
|
500
|
+
const told = [];
|
|
501
|
+
const withdrawn = refuse(large, large.state.kind === "unknown", tooLarge(alone), now, told);
|
|
502
|
+
out.push(...told);
|
|
503
|
+
next = {
|
|
504
|
+
...next,
|
|
505
|
+
commands: next.commands.map((each) => (each === large ? withdrawn : each)),
|
|
506
|
+
};
|
|
507
|
+
first = next.commands.find(sendable);
|
|
508
|
+
}
|
|
219
509
|
if (first !== undefined) {
|
|
220
510
|
// One push per client, dataset and tapped-against revision: the server judges each by what it was made on.
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
(each.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
511
|
+
// Oldest first, at most `pushMax` commands and `pushBytes` bytes: it stops at the first command that doesn't
|
|
512
|
+
// fit, never skipping ahead to a smaller one, and the rest go in the next push.
|
|
513
|
+
const lead = first;
|
|
514
|
+
const batch = [];
|
|
515
|
+
let bytes = envelope(lead.client, lead.dataset, tapped(lead));
|
|
516
|
+
for (const each of next.commands) {
|
|
517
|
+
if (each.client !== lead.client ||
|
|
518
|
+
each.dataset !== lead.dataset ||
|
|
519
|
+
tapped(each) !== tapped(lead) ||
|
|
520
|
+
!sendable(each)) {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const size = bytesOf(each) + (batch.length > 0 ? 1 : 0);
|
|
524
|
+
if (batch.length >= pushMax || bytes + size > pushBytes) {
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
bytes += size;
|
|
528
|
+
batch.push(each);
|
|
529
|
+
}
|
|
530
|
+
const sent = new Set(batch);
|
|
231
531
|
out.push({
|
|
232
532
|
kind: "push",
|
|
233
|
-
client:
|
|
234
|
-
dataset:
|
|
235
|
-
since: tapped(
|
|
236
|
-
commands:
|
|
237
|
-
.filter(mine)
|
|
238
|
-
.map((each) => ({ id: each.id, command: each.command })),
|
|
533
|
+
client: lead.client,
|
|
534
|
+
dataset: lead.dataset,
|
|
535
|
+
since: tapped(lead),
|
|
536
|
+
commands: batch.map((each) => ({ id: each.id, command: each.command })),
|
|
239
537
|
});
|
|
240
538
|
next = {
|
|
241
539
|
...next,
|
|
242
540
|
push: {
|
|
243
541
|
kind: "pushing",
|
|
244
|
-
client:
|
|
245
|
-
ids:
|
|
542
|
+
client: lead.client,
|
|
543
|
+
ids: batch.map((each) => each.id),
|
|
544
|
+
was: batch.map((each) => each.state),
|
|
246
545
|
failures: next.push.failures,
|
|
247
546
|
},
|
|
248
|
-
commands: next.commands.map((each) =>
|
|
547
|
+
commands: next.commands.map((each) => sent.has(each) ? { ...each, state: { kind: "sending" } } : each),
|
|
249
548
|
};
|
|
250
549
|
}
|
|
251
550
|
}
|
|
@@ -289,19 +588,16 @@ export function createSync(spec) {
|
|
|
289
588
|
at: model.now,
|
|
290
589
|
state: { kind: "done", outcome: receipt.outcome, revision: receipt.revision },
|
|
291
590
|
};
|
|
292
|
-
effects.push(
|
|
591
|
+
effects.push(keep(done), { kind: "answered", pending: done }, { kind: "telemetry", name: "command.done", attributes: { ms: model.now - each.at } });
|
|
293
592
|
return done;
|
|
294
593
|
});
|
|
295
594
|
const target = Math.max(model.target, ...receipts.map((each) => each.revision));
|
|
296
595
|
return [{ ...model, commands, target }, effects];
|
|
297
596
|
}
|
|
298
|
-
/**
|
|
299
|
-
function
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
at: now,
|
|
303
|
-
state: { kind: "done", outcome: spec.refused(pending.command, message), revision: 0 },
|
|
304
|
-
};
|
|
597
|
+
/** Each command's state before the push in flight for `client`; unknown when this page can't tell. */
|
|
598
|
+
function prior(model, client) {
|
|
599
|
+
const pushing = model.push.kind === "pushing" && model.push.client === client ? model.push : null;
|
|
600
|
+
return (each) => pushing?.was[pushing.ids.indexOf(each.id)] ?? { kind: "unknown", failure: "sent before" };
|
|
305
601
|
}
|
|
306
602
|
function reduce(model, msg) {
|
|
307
603
|
const now = msg.now;
|
|
@@ -355,20 +651,60 @@ export function createSync(spec) {
|
|
|
355
651
|
];
|
|
356
652
|
}
|
|
357
653
|
case "pulled": {
|
|
358
|
-
const
|
|
654
|
+
const result = merge(at.replica, msg.patch, msg.since);
|
|
655
|
+
const merged = result === "gap" ? undefined : result;
|
|
359
656
|
const replica = merged?.replica ?? at.replica;
|
|
360
657
|
const effects = [];
|
|
658
|
+
const was = historyOf(at.replica);
|
|
659
|
+
const history = historyOf(replica);
|
|
660
|
+
const failures = at.pull.kind === "pulling" ? at.pull.failures : 0;
|
|
661
|
+
// An old server answering a partial copy (with rows or without): only its whole copy completes it.
|
|
662
|
+
const old = was.kind === "partial" &&
|
|
663
|
+
msg.patch.page === undefined &&
|
|
664
|
+
!msg.patch.reset &&
|
|
665
|
+
msg.since > 0 &&
|
|
666
|
+
result !== "gap";
|
|
667
|
+
// A partial copy this reply didn't move on (the same page again, a repeated first page at the same revision, a
|
|
668
|
+
// reply it couldn't take) is a pull that failed, for backoff: never a busy loop on a server that doesn't move.
|
|
669
|
+
const stuck = was.kind === "partial" &&
|
|
670
|
+
!old &&
|
|
671
|
+
(merged === undefined ||
|
|
672
|
+
(msg.patch.page !== undefined &&
|
|
673
|
+
history.kind === "partial" &&
|
|
674
|
+
history.next === was.next &&
|
|
675
|
+
replica.dataset === at.replica.dataset &&
|
|
676
|
+
!(merged.reset && replica.revision > at.replica.revision)));
|
|
677
|
+
// Receipts that came with an applied reply came with the rows they name.
|
|
678
|
+
const carried = new Set(merged?.reset === true ? [] : at.carried);
|
|
679
|
+
if (merged !== undefined) {
|
|
680
|
+
for (const each of msg.receipts) {
|
|
681
|
+
if (each.revision <= replica.revision) {
|
|
682
|
+
carried.add(`${each.client ?? at.client}:${each.id}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
361
686
|
let next = {
|
|
362
687
|
...at,
|
|
363
688
|
replica,
|
|
689
|
+
carried,
|
|
364
690
|
target: msg.patch.dataset === at.replica.dataset
|
|
365
691
|
? Math.max(at.target, msg.patch.revision)
|
|
366
692
|
: msg.patch.revision,
|
|
693
|
+
// A reply it couldn't follow asks a real reset; an old server's reply to a partial copy, its whole copy.
|
|
694
|
+
restart: result === "gap"
|
|
695
|
+
? (at.restart ?? "paged")
|
|
696
|
+
: merged?.reset === true
|
|
697
|
+
? null
|
|
698
|
+
: old
|
|
699
|
+
? "whole"
|
|
700
|
+
: at.restart,
|
|
367
701
|
reconciledAt: now,
|
|
368
702
|
connection: at.socket.open && now - at.socket.heardAt < SOCKET_DEADLINE_MS
|
|
369
703
|
? { kind: "live" }
|
|
370
704
|
: { kind: "stale" },
|
|
371
|
-
pull:
|
|
705
|
+
pull: stuck
|
|
706
|
+
? { kind: "idle", nextAt: now + backoff(failures + 1), failures: failures + 1 }
|
|
707
|
+
: { kind: "idle", nextAt: now + POLL_MS, failures: 0 },
|
|
372
708
|
};
|
|
373
709
|
if (merged !== undefined && next.storage.kind !== "replaced") {
|
|
374
710
|
effects.push({
|
|
@@ -378,12 +714,15 @@ export function createSync(spec) {
|
|
|
378
714
|
reset: merged.reset,
|
|
379
715
|
base: msg.since,
|
|
380
716
|
replaces: at.saved.dataset,
|
|
717
|
+
from: was.kind === "partial" ? was.next : null,
|
|
381
718
|
});
|
|
382
719
|
next = { ...next, saved: { ...next.saved, writing: next.saved.writing + 1 } };
|
|
383
720
|
}
|
|
384
721
|
if (at.verify.kind === "requested") {
|
|
385
|
-
// The fingerprint describes the server at the reply's revision: comparable only if the replica is there
|
|
722
|
+
// The fingerprint describes the server at the reply's revision: comparable only if the replica is there, and
|
|
723
|
+
// whole (a fingerprint of everything never certifies part of it).
|
|
386
724
|
if (msg.patch.checksum !== undefined &&
|
|
725
|
+
history.kind === "complete" &&
|
|
387
726
|
replica.revision === msg.patch.revision &&
|
|
388
727
|
replica.dataset === msg.patch.dataset) {
|
|
389
728
|
effects.push({ kind: "check", replica, checksum: msg.patch.checksum });
|
|
@@ -392,30 +731,39 @@ export function createSync(spec) {
|
|
|
392
731
|
next = { ...next, verify: { kind: "pending", tries: at.verify.tries } };
|
|
393
732
|
}
|
|
394
733
|
}
|
|
395
|
-
|
|
734
|
+
// Receipts by client: an earlier page's commands are answered too.
|
|
735
|
+
let answered = next;
|
|
736
|
+
const more = [];
|
|
737
|
+
for (const client of new Set(msg.receipts.map((each) => each.client ?? at.client))) {
|
|
738
|
+
const [after, told] = answer(answered, client, msg.receipts.filter((each) => (each.client ?? at.client) === client));
|
|
739
|
+
answered = after;
|
|
740
|
+
more.push(...told);
|
|
741
|
+
}
|
|
396
742
|
// Intents tapped against a store the server no longer has never go to its replacement (the server refuses
|
|
397
743
|
// them too). One that may already have run there says so.
|
|
398
744
|
const replaced = answered.commands.map((each) => {
|
|
399
745
|
if (each.state.kind === "done" ||
|
|
400
746
|
each.dataset === null ||
|
|
401
|
-
|
|
747
|
+
replica.dataset === null ||
|
|
748
|
+
each.dataset === replica.dataset) {
|
|
402
749
|
return each;
|
|
403
750
|
}
|
|
404
751
|
const done = fail(each, each.state.kind === "unknown"
|
|
405
752
|
? "the server's data was replaced; whether this ran before is unknown"
|
|
406
753
|
: "the server's data was replaced since this was tapped, so it wasn't sent", now);
|
|
407
|
-
more.push(
|
|
754
|
+
more.push(keep(done), { kind: "answered", pending: done });
|
|
408
755
|
return done;
|
|
409
756
|
});
|
|
410
757
|
return [{ ...answered, commands: replaced }, [...effects, ...more]];
|
|
411
758
|
}
|
|
412
759
|
case "pullFailed": {
|
|
413
760
|
const failures = (at.pull.kind === "pulling" ? at.pull.failures : 0) + 1;
|
|
761
|
+
const wait = Math.max(backoff(failures), msg.failure.retryAfterMs ?? 0);
|
|
414
762
|
return [
|
|
415
763
|
{
|
|
416
764
|
...at,
|
|
417
765
|
connection: failedConnection(at.connection, msg.failure),
|
|
418
|
-
pull: { kind: "idle", nextAt: now +
|
|
766
|
+
pull: { kind: "idle", nextAt: now + wait, failures },
|
|
419
767
|
verify: at.verify.kind === "requested"
|
|
420
768
|
? { kind: "pending", tries: at.verify.tries }
|
|
421
769
|
: at.verify,
|
|
@@ -426,7 +774,8 @@ export function createSync(spec) {
|
|
|
426
774
|
case "pushed": {
|
|
427
775
|
const ids = new Set(msg.ids);
|
|
428
776
|
const [answered, effects] = answer(at, msg.client, msg.receipts, ids);
|
|
429
|
-
// A command the reply has no receipt for is still acting elsewhere: asked again.
|
|
777
|
+
// A command the reply has no receipt for is still acting elsewhere: asked again. The disk holds its in-flight
|
|
778
|
+
// mark, so a reload knows it too.
|
|
430
779
|
const commands = answered.commands.map((each) => each.client === msg.client && ids.has(each.id) && each.state.kind === "sending"
|
|
431
780
|
? { ...each, state: { kind: "unknown", failure: "the server is still on it" } }
|
|
432
781
|
: each);
|
|
@@ -442,42 +791,108 @@ export function createSync(spec) {
|
|
|
442
791
|
];
|
|
443
792
|
}
|
|
444
793
|
case "pushFailed": {
|
|
445
|
-
|
|
446
|
-
// unknown and asked again under the same key, which answers with the first outcome.
|
|
447
|
-
const final = msg.failure.kind === "forbidden" || msg.failure.kind === "conflict";
|
|
794
|
+
const failure = msg.failure;
|
|
448
795
|
const ids = new Set(msg.ids);
|
|
796
|
+
const before = prior(at, msg.client);
|
|
449
797
|
const effects = [];
|
|
798
|
+
// The server's own refusal of a request it never ran (its schema is checked before the handler, and a handler
|
|
799
|
+
// never refuses after anything acted): this attempt didn't act, though an earlier one may have.
|
|
800
|
+
const refused = failure.refused === true &&
|
|
801
|
+
(failure.kind === "invalid" ||
|
|
802
|
+
failure.kind === "forbidden" ||
|
|
803
|
+
failure.kind === "conflict");
|
|
804
|
+
// A schema refusal naming one of this push's commands is that command's alone.
|
|
805
|
+
const index = refused && failure.kind === "invalid" ? commandAt(failure.path) : undefined;
|
|
806
|
+
const named = index === undefined ? undefined : msg.ids[index];
|
|
450
807
|
const commands = at.commands.map((each) => {
|
|
451
|
-
if (each.client !== msg.client || !ids.has(each.id) || each.state.kind
|
|
808
|
+
if (each.client !== msg.client || !ids.has(each.id) || each.state.kind !== "sending") {
|
|
452
809
|
return each;
|
|
453
810
|
}
|
|
454
|
-
|
|
455
|
-
|
|
811
|
+
const was = before(each);
|
|
812
|
+
// An attempt whose outcome was lost may have acted: no later refusal makes it final.
|
|
813
|
+
const doubt = was.kind === "unknown";
|
|
814
|
+
if (named !== undefined) {
|
|
815
|
+
return each.id === named
|
|
816
|
+
? refuse(each, doubt, failure.message, now, effects)
|
|
817
|
+
: { ...each, state: was };
|
|
456
818
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
819
|
+
// A kit forbidden or conflict: nothing in this attempt acted. Final only for a command no attempt of which
|
|
820
|
+
// may have acted.
|
|
821
|
+
if (refused && failure.kind !== "invalid") {
|
|
822
|
+
return doubt
|
|
823
|
+
? { ...each, state: was }
|
|
824
|
+
: refuse(each, false, failure.message, now, effects);
|
|
825
|
+
}
|
|
826
|
+
// An envelope refusal (a page and server of different releases): nothing acted, and the page stops. The disk
|
|
827
|
+
// holds the in-flight mark: a command never uncertain is kept as it was again.
|
|
828
|
+
if (refused) {
|
|
829
|
+
const restored = { ...each, state: was };
|
|
830
|
+
if (!doubt) {
|
|
831
|
+
effects.push(keep(restored));
|
|
832
|
+
}
|
|
833
|
+
return restored;
|
|
834
|
+
}
|
|
835
|
+
// Anything else may have acted (a lost reply, an unreadable one, a proxy's answer): asked again, same key.
|
|
836
|
+
// The disk holds its in-flight mark.
|
|
837
|
+
return { ...each, state: { kind: "unknown", failure: failure.message } };
|
|
460
838
|
});
|
|
461
|
-
const
|
|
839
|
+
const telemetry = {
|
|
840
|
+
kind: "telemetry",
|
|
841
|
+
name: "push.failed",
|
|
842
|
+
attributes: {
|
|
843
|
+
kind: failure.kind,
|
|
844
|
+
refused: failure.refused === true,
|
|
845
|
+
path: failure.path ?? null,
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
const pushing = at.push.kind === "pushing" ? at.push.failures : 0;
|
|
849
|
+
// One command refused by name: the rest go again at once, without it.
|
|
850
|
+
if (named !== undefined) {
|
|
851
|
+
return [
|
|
852
|
+
{ ...at, commands, push: { kind: "idle", nextAt: now, failures: pushing } },
|
|
853
|
+
[...effects, telemetry],
|
|
854
|
+
];
|
|
855
|
+
}
|
|
856
|
+
const failures = pushing + 1;
|
|
462
857
|
return [
|
|
463
858
|
{
|
|
464
859
|
...at,
|
|
465
860
|
commands,
|
|
466
|
-
connection: failedConnection(at.connection,
|
|
861
|
+
connection: failedConnection(at.connection, failure),
|
|
862
|
+
push: {
|
|
863
|
+
kind: "idle",
|
|
864
|
+
nextAt: now + Math.max(backoff(failures), failure.retryAfterMs ?? 0),
|
|
865
|
+
failures,
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
[...effects, telemetry],
|
|
869
|
+
];
|
|
870
|
+
}
|
|
871
|
+
case "unsent": {
|
|
872
|
+
// Never sent: each command is what it was before, and goes again once the disk keeps its mark.
|
|
873
|
+
const ids = new Set(msg.ids);
|
|
874
|
+
const before = prior(at, msg.client);
|
|
875
|
+
const failures = (at.push.kind === "pushing" ? at.push.failures : 0) + 1;
|
|
876
|
+
return [
|
|
877
|
+
{
|
|
878
|
+
...at,
|
|
879
|
+
commands: at.commands.map((each) => each.client === msg.client && ids.has(each.id) && each.state.kind === "sending"
|
|
880
|
+
? { ...each, state: before(each) }
|
|
881
|
+
: each),
|
|
882
|
+
storage: msg.replaced ? { kind: "replaced" } : { kind: "failed", reason: msg.reason },
|
|
467
883
|
push: { kind: "idle", nextAt: now + backoff(failures), failures },
|
|
468
884
|
},
|
|
469
|
-
[
|
|
470
|
-
...effects,
|
|
471
|
-
{ kind: "telemetry", name: "push.failed", attributes: { kind: msg.failure.kind } },
|
|
472
|
-
],
|
|
885
|
+
[{ kind: "telemetry", name: "push.unsent", attributes: { reason: msg.reason } }],
|
|
473
886
|
];
|
|
474
887
|
}
|
|
475
888
|
case "committed": {
|
|
476
889
|
const same = msg.dataset === at.saved.dataset;
|
|
890
|
+
// A delta the disk took under its own history doesn't repair a failed write: the whole one stays owed.
|
|
891
|
+
const owed = at.saved.owed && msg.whole === false;
|
|
477
892
|
return [
|
|
478
893
|
{
|
|
479
894
|
...at,
|
|
480
|
-
storage: at.storage.kind === "replaced" ? at.storage : { kind: "ok" },
|
|
895
|
+
storage: at.storage.kind === "replaced" || owed ? at.storage : { kind: "ok" },
|
|
481
896
|
saved: {
|
|
482
897
|
dataset: msg.dataset,
|
|
483
898
|
revision: same ? Math.max(at.saved.revision, msg.revision) : msg.revision,
|
|
@@ -485,6 +900,7 @@ export function createSync(spec) {
|
|
|
485
900
|
writing: Math.max(0, at.saved.writing - 1),
|
|
486
901
|
failures: 0,
|
|
487
902
|
nextAt: now,
|
|
903
|
+
owed,
|
|
488
904
|
},
|
|
489
905
|
},
|
|
490
906
|
[],
|
|
@@ -501,16 +917,57 @@ export function createSync(spec) {
|
|
|
501
917
|
writing: Math.max(0, at.saved.writing - 1),
|
|
502
918
|
failures,
|
|
503
919
|
nextAt: now + backoff(failures),
|
|
920
|
+
owed: true,
|
|
504
921
|
},
|
|
505
922
|
},
|
|
506
923
|
[{ kind: "telemetry", name: "replica.save-failed", attributes: { reason: msg.reason } }],
|
|
507
924
|
];
|
|
508
925
|
}
|
|
509
926
|
case "kept": {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
927
|
+
let admitted = false;
|
|
928
|
+
const commands = at.commands.map((each) => {
|
|
929
|
+
if (each.client !== msg.client || each.id !== msg.id || each.state.kind !== "saving") {
|
|
930
|
+
return each;
|
|
931
|
+
}
|
|
932
|
+
admitted = true;
|
|
933
|
+
return { ...each, state: { kind: "queued" } };
|
|
934
|
+
});
|
|
935
|
+
return [
|
|
936
|
+
{
|
|
937
|
+
...at,
|
|
938
|
+
commands,
|
|
939
|
+
storage: working(at),
|
|
940
|
+
outbox: admitted ? { ...at.outbox, full: null } : at.outbox,
|
|
941
|
+
},
|
|
942
|
+
[],
|
|
943
|
+
];
|
|
944
|
+
}
|
|
945
|
+
case "full": {
|
|
946
|
+
// Not a storage failure: the store answered, with no room. The tap is answered and not retained.
|
|
947
|
+
const gone = at.commands.find((each) => each.client === msg.client && each.id === msg.id && each.state.kind === "saving");
|
|
948
|
+
if (gone === undefined) {
|
|
949
|
+
return [{ ...at, storage: working(at) }, []];
|
|
950
|
+
}
|
|
951
|
+
const refused = fail(gone, "this device's outbox is full, so it wasn't kept or sent", now);
|
|
952
|
+
return [
|
|
953
|
+
{
|
|
954
|
+
...at,
|
|
955
|
+
commands: at.commands.filter((each) => each !== gone),
|
|
956
|
+
storage: working(at),
|
|
957
|
+
outbox: {
|
|
958
|
+
...at.outbox,
|
|
959
|
+
full: { kind: "full", waiting: msg.waiting, finished: msg.finished, max: msg.max },
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
[
|
|
963
|
+
{ kind: "answered", pending: refused },
|
|
964
|
+
{
|
|
965
|
+
kind: "telemetry",
|
|
966
|
+
name: "commands.full",
|
|
967
|
+
attributes: { waiting: msg.waiting, finished: msg.finished, max: msg.max },
|
|
968
|
+
},
|
|
969
|
+
],
|
|
970
|
+
];
|
|
514
971
|
}
|
|
515
972
|
case "keepFailed": {
|
|
516
973
|
const effects = [
|
|
@@ -528,7 +985,7 @@ export function createSync(spec) {
|
|
|
528
985
|
}
|
|
529
986
|
case "dropped": {
|
|
530
987
|
if (msg.failure === null) {
|
|
531
|
-
return [{ ...at, storage: working(at) }, []];
|
|
988
|
+
return [{ ...at, storage: working(at), outbox: { ...at.outbox, full: null } }, []];
|
|
532
989
|
}
|
|
533
990
|
return [
|
|
534
991
|
{
|
|
@@ -625,9 +1082,15 @@ export function createSync(spec) {
|
|
|
625
1082
|
if (joined) {
|
|
626
1083
|
return [at, []];
|
|
627
1084
|
}
|
|
1085
|
+
// A tap no push could carry is answered now, never kept.
|
|
1086
|
+
const alone = envelope(at.client, pending.dataset, at.replica.revision) + bytesOf(pending);
|
|
1087
|
+
if (alone > pushBytes) {
|
|
1088
|
+
return [numbered, [{ kind: "answered", pending: fail(pending, tooLarge(alone), now) }]];
|
|
1089
|
+
}
|
|
1090
|
+
// Admitted by the store, inside the keep's own transaction: a full outbox answers `full`, and the tap goes.
|
|
628
1091
|
return [
|
|
629
1092
|
{ ...numbered, commands: [...at.commands, pending] },
|
|
630
|
-
[
|
|
1093
|
+
[keep({ ...pending, state: { kind: "queued" } })],
|
|
631
1094
|
];
|
|
632
1095
|
}
|
|
633
1096
|
case "dismiss": {
|
|
@@ -685,6 +1148,12 @@ export function createSync(spec) {
|
|
|
685
1148
|
let marked = since === next.saved.behindSince
|
|
686
1149
|
? next
|
|
687
1150
|
: { ...next, saved: { ...next.saved, behindSince: since } };
|
|
1151
|
+
// Commands that left the page leave what was carried for them.
|
|
1152
|
+
const kept = new Set(marked.commands.map((each) => `${each.client}:${each.id}`));
|
|
1153
|
+
const carried = [...marked.carried].filter((key) => kept.has(key));
|
|
1154
|
+
if (carried.length < marked.carried.size) {
|
|
1155
|
+
marked = { ...marked, carried: new Set(carried) };
|
|
1156
|
+
}
|
|
688
1157
|
// A command that resolves only now (its result came later than its receipt) is drawn its full time from now.
|
|
689
1158
|
const resolved = spec.resolved;
|
|
690
1159
|
if (resolved !== undefined) {
|