@volter/world-core 3.0.155 → 3.0.156
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/dist/generated/pack-facts.json +143 -12
- package/dist/inject.cjs +150 -86
- package/dist/src/actions.d.ts +15 -0
- package/dist/src/actions.js +82 -21
- package/dist/src/ancestry.d.ts +5 -4
- package/dist/src/ancestry.js +53 -28
- package/dist/src/changeset.js +1 -1
- package/dist/src/clickhouse/compression.d.ts +28 -0
- package/dist/src/clickhouse/compression.js +281 -0
- package/dist/src/clickhouse/index.d.ts +1 -0
- package/dist/src/clickhouse/index.js +1 -0
- package/dist/src/derived-core.d.ts +12 -1
- package/dist/src/derived-core.js +145 -11
- package/dist/src/derived.js +3 -0
- package/dist/src/fork.js +1 -1
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/h2.js +63 -10
- package/dist/src/head.d.ts +3 -0
- package/dist/src/head.js +9 -0
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +7 -7
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +5 -1
- package/dist/src/log.d.ts +23 -3
- package/dist/src/log.js +160 -37
- package/dist/src/managed-original-inputs.d.ts +101 -0
- package/dist/src/managed-original-inputs.js +125 -0
- package/dist/src/mongodb/client.d.ts +8 -0
- package/dist/src/mongodb/client.js +27 -0
- package/dist/src/mongodb/index.d.ts +2 -0
- package/dist/src/mongodb/index.js +5 -0
- package/dist/src/mongodb/stream.d.ts +74 -0
- package/dist/src/mongodb/stream.js +206 -0
- package/dist/src/observe.js +1 -1
- package/dist/src/pack-fetch.js +20 -8
- package/dist/src/packRegistry.d.ts +10 -14
- package/dist/src/packRegistry.js +2 -13
- package/dist/src/protocol.d.ts +13 -0
- package/dist/src/protocol.js +13 -0
- package/dist/src/redis/engine.d.ts +10 -4
- package/dist/src/redis/engine.js +103 -32
- package/dist/src/redis/member-storage.js +20 -8
- package/dist/src/redis/service-times.d.ts +25 -0
- package/dist/src/redis/service-times.js +87 -0
- package/dist/src/redis/stream.d.ts +2 -1
- package/dist/src/redis/stream.js +109 -20
- package/dist/src/runtime-state.d.ts +11 -0
- package/dist/src/runtime-state.js +13 -0
- package/dist/src/runtime.d.ts +4 -2
- package/dist/src/runtime.js +5 -2
- package/dist/src/serve-http.d.ts +2 -0
- package/dist/src/serve-http.js +21 -4
- package/dist/src/serve.d.ts +10 -0
- package/dist/src/serve.js +43 -3
- package/dist/src/storage.d.ts +2 -1
- package/dist/src/storage.js +6 -3
- package/dist/src/world-boot.d.ts +1 -0
- package/dist/src/world-boot.js +6 -0
- package/dist/src/world-clock.js +6 -2
- package/dist/src/world-store.d.ts +108 -7
- package/dist/src/world-store.js +883 -103
- package/dist/src/xml.d.ts +30 -0
- package/dist/src/xml.js +74 -0
- package/generated/pack-facts.json +143 -12
- package/inject.cjs +150 -86
- package/package.json +20 -1
- package/src/actions.ts +78 -21
- package/src/ancestry.ts +46 -24
- package/src/changeset.ts +1 -1
- package/src/clickhouse/compression.ts +172 -0
- package/src/clickhouse/index.ts +2 -0
- package/src/derived-core.ts +142 -11
- package/src/derived.ts +3 -0
- package/src/fork.ts +1 -1
- package/src/grpc-wire.ts +2 -1
- package/src/h2.ts +46 -5
- package/src/head.ts +9 -0
- package/src/history.ts +7 -7
- package/src/index.ts +6 -1
- package/src/log.ts +147 -32
- package/src/managed-original-inputs.ts +163 -0
- package/src/mongodb/client.ts +27 -0
- package/src/mongodb/index.ts +5 -0
- package/src/mongodb/stream.ts +213 -0
- package/src/observe.ts +1 -1
- package/src/pack-fetch.ts +20 -8
- package/src/packRegistry.ts +3 -15
- package/src/protocol.ts +13 -0
- package/src/redis/engine.ts +100 -57
- package/src/redis/member-storage.ts +17 -9
- package/src/redis/service-times.ts +81 -0
- package/src/redis/stream.ts +66 -14
- package/src/runtime-state.ts +14 -0
- package/src/runtime.ts +6 -1
- package/src/serve-http.ts +23 -4
- package/src/serve.ts +45 -3
- package/src/storage.ts +5 -3
- package/src/world-boot.ts +6 -0
- package/src/world-clock.ts +6 -2
- package/src/world-store.ts +704 -91
- package/src/xml.ts +71 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// THE KERNEL'S MONGODB WIRE (`@volter/world-core/mongodb`): MongoDB's client/server message framing as a TwinStream,
|
|
2
|
+
// so an unmodified MongoDB driver connects to a twin as to a server. The kernel holds the framing, which every
|
|
3
|
+
// MongoDB-speaking vendor shares; the pack holds what a command means (it answers each command document from its own
|
|
4
|
+
// engine over the tree) and the document codec (BSON, a library the pack installs). The messages served are what the
|
|
5
|
+
// vendor's own driver sends, by citation (docs/contributing/architecture.md, "Other wires", the MongoDB wire): OP_MSG
|
|
6
|
+
// with a body section and document-sequence sections, and the legacy OP_QUERY a driver's first hello may use, answered
|
|
7
|
+
// with OP_REPLY. Any other message ends the connection.
|
|
8
|
+
// source: archive:https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz#sha256=a12bd599cb4f39cdf222b32380ea3efd1f60c6d88615e0700632a303a9006b02!/package/src/cmap/commands.ts "moreToCome is an OP_MSG only concept"
|
|
9
|
+
import type { TwinStream, TwinStreamSink } from '../twin-fetch.ts';
|
|
10
|
+
|
|
11
|
+
/** A command or reply document as the pack's codec gives it: the pack decides how BSON's types appear in it. */
|
|
12
|
+
export type MongoDocument = Record<string, unknown>;
|
|
13
|
+
/** BSON, as the pack supplies it (the `bson` library, installed by the pack's package.json). The kernel frames
|
|
14
|
+
* documents and never reads inside one but for a command's `$db`. */
|
|
15
|
+
export type MongoCodec = { serialize(document: MongoDocument): Uint8Array; deserialize(bytes: Uint8Array): MongoDocument };
|
|
16
|
+
|
|
17
|
+
/** What the sender's message said beside its command: whether it will take more than one reply to it (OP_MSG's exhaustAllowed). */
|
|
18
|
+
export type MongoAsked = { exhaustAllowed: boolean };
|
|
19
|
+
const TIMED = Symbol('mongodb timed reply');
|
|
20
|
+
/**
|
|
21
|
+
* An answer the wire sends LATER, or goes on sending: the vendor's protocol does this in real time (an awaitable
|
|
22
|
+
* `hello` is held for the sender's maxAwaitTimeMS, and one sent with exhaustAllowed is answered again every such
|
|
23
|
+
* interval, each reply marked moreToCome). The pack says WHAT and WHEN as data and holds no clock: the same request
|
|
24
|
+
* gets the same answer every time. The wire does the holding and the repeating on the wall clock, and the delay is
|
|
25
|
+
* never read back into state (architecture, "The MongoDB wire").
|
|
26
|
+
*/
|
|
27
|
+
export type MongoTimedReply = { readonly [TIMED]: true; reply: MongoDocument; afterMs: number; everyMs?: number };
|
|
28
|
+
/**
|
|
29
|
+
* `reply` after `afterMs` (0: at once); with `everyMs`, again each `everyMs` until the peer closes, where the sender
|
|
30
|
+
* allowed more than one reply. The two differ in the vendor's own answers: a hello naming the server's current
|
|
31
|
+
* topology is first answered after the wait, one naming another process at once, and both then repeat at the wait.
|
|
32
|
+
*/
|
|
33
|
+
export const timedReply = (reply: MongoDocument, afterMs: number, everyMs?: number): MongoTimedReply => ({ [TIMED]: true, reply, afterMs, ...(everyMs !== undefined ? { everyMs } : {}) });
|
|
34
|
+
const isTimed = (answer: MongoDocument | MongoTimedReply): answer is MongoTimedReply => (answer as MongoTimedReply)[TIMED] === true;
|
|
35
|
+
|
|
36
|
+
export type MongoStreamOptions<Session> = {
|
|
37
|
+
codec: MongoCodec;
|
|
38
|
+
/** A connection opened: what the pack keeps for it (its sessions, an open transaction); never the tree. */
|
|
39
|
+
open(peer: string): Session;
|
|
40
|
+
/** Answer ONE command document sent to `database`. A document sequence (an insert's `documents`, an update's
|
|
41
|
+
* `updates`) arrives as that field holding the array. The answer is the reply document, `ok: 1` or the vendor's
|
|
42
|
+
* error document; a command the pack does not serve is the pack's to answer as the vendor answers an unknown one. */
|
|
43
|
+
command(session: Session, database: string, command: MongoDocument, asked: MongoAsked): Promise<MongoDocument | MongoTimedReply>;
|
|
44
|
+
/** The connection went away: abort what it left open. */
|
|
45
|
+
close?(session: Session): void;
|
|
46
|
+
/** Told why a connection is ended by this stream and not by its peer: the pack threw answering a command, or a
|
|
47
|
+
* message was one this wire does not serve. Ending the connection is all the wire can say to the client, so the
|
|
48
|
+
* reason is said here; unset, it is written to the process's error output. A stream that ends connections in
|
|
49
|
+
* silence leaves its operator with a client that hangs and nothing to read. */
|
|
50
|
+
ended?(reason: string, peer: string): void;
|
|
51
|
+
/** The largest message accepted; the vendor's server names 48,000,000 in its hello (`maxMessageSizeBytes`). */
|
|
52
|
+
maxMessageSizeBytes?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const MongoOp = { REPLY: 1, QUERY: 2004, MSG: 2013 } as const;
|
|
56
|
+
/** OP_MSG's flag bits. */
|
|
57
|
+
export const MongoMsgFlag = { CHECKSUM_PRESENT: 1 << 0, MORE_TO_COME: 1 << 1, EXHAUST_ALLOWED: 1 << 16 } as const;
|
|
58
|
+
const HEADER = 16;
|
|
59
|
+
const dec = new TextDecoder();
|
|
60
|
+
const enc = new TextEncoder();
|
|
61
|
+
|
|
62
|
+
const view = (bytes: Uint8Array): DataView => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
63
|
+
function concat(parts: Uint8Array[]): Uint8Array {
|
|
64
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let at = 0;
|
|
65
|
+
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function header(length: number, requestId: number, responseTo: number, opcode: number): Uint8Array {
|
|
69
|
+
const out = new Uint8Array(HEADER), v = view(out);
|
|
70
|
+
v.setInt32(0, length, true); v.setInt32(4, requestId, true); v.setInt32(8, responseTo, true); v.setInt32(12, opcode, true);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** An OP_MSG message carrying one document as its body section. */
|
|
75
|
+
export function encodeMsg(codec: MongoCodec, document: MongoDocument, requestId: number, responseTo: number, flags = 0): Uint8Array {
|
|
76
|
+
const body = codec.serialize(document), bits = new Uint8Array(5);
|
|
77
|
+
view(bits).setUint32(0, flags, true);
|
|
78
|
+
return concat([header(HEADER + 4 + 1 + body.length, requestId, responseTo, MongoOp.MSG), bits, body]);
|
|
79
|
+
}
|
|
80
|
+
/** The command of an OP_MSG message: its body section, with each document sequence as the field it names. */
|
|
81
|
+
export function decodeMsg(codec: MongoCodec, message: Uint8Array): { flags: number; document: MongoDocument } {
|
|
82
|
+
const v = view(message), flags = v.getUint32(HEADER, true), end = message.length - (flags & MongoMsgFlag.CHECKSUM_PRESENT ? 4 : 0);
|
|
83
|
+
let document: MongoDocument | undefined; const sequences: Record<string, MongoDocument[]> = {};
|
|
84
|
+
for (let at = HEADER + 4; at < end;) {
|
|
85
|
+
const kind = message[at]!; at += 1;
|
|
86
|
+
if (kind === 0) {
|
|
87
|
+
const size = v.getInt32(at, true);
|
|
88
|
+
if (size < 5 || at + size > end) throw new Error('OP_MSG body section runs past the message');
|
|
89
|
+
document = codec.deserialize(message.subarray(at, at + size)); at += size;
|
|
90
|
+
} else if (kind === 1) {
|
|
91
|
+
const size = v.getInt32(at, true), stop = at + size;
|
|
92
|
+
if (size < 4 || stop > end) throw new Error('OP_MSG document sequence runs past the message');
|
|
93
|
+
const nameEnd = message.indexOf(0, at + 4);
|
|
94
|
+
if (nameEnd < 0 || nameEnd >= stop) throw new Error('OP_MSG document sequence names no field');
|
|
95
|
+
const name = dec.decode(message.subarray(at + 4, nameEnd)), documents: MongoDocument[] = [];
|
|
96
|
+
for (let cursor = nameEnd + 1; cursor < stop;) {
|
|
97
|
+
const length = v.getInt32(cursor, true);
|
|
98
|
+
if (length < 5 || cursor + length > stop) throw new Error('OP_MSG document sequence holds a document that runs past it');
|
|
99
|
+
documents.push(codec.deserialize(message.subarray(cursor, cursor + length))); cursor += length;
|
|
100
|
+
}
|
|
101
|
+
sequences[name] = documents; at = stop;
|
|
102
|
+
} else throw new Error(`OP_MSG section kind ${kind} is unknown`);
|
|
103
|
+
}
|
|
104
|
+
if (!document) throw new Error('OP_MSG carries no body section');
|
|
105
|
+
return { flags, document: { ...document, ...sequences } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** MongoDB's wire, served: each command document answered by the pack, in arrival order, on one connection. */
|
|
109
|
+
export function createMongoStream<Session>(options: MongoStreamOptions<Session>): TwinStream {
|
|
110
|
+
const limit = options.maxMessageSizeBytes ?? 48_000_000;
|
|
111
|
+
let nextResponse = 0;
|
|
112
|
+
return (sink: TwinStreamSink, peer: string) => {
|
|
113
|
+
const session = options.open(peer);
|
|
114
|
+
let buffer: Uint8Array = new Uint8Array(0);
|
|
115
|
+
let closed = false;
|
|
116
|
+
let chain: Promise<void> = Promise.resolve();
|
|
117
|
+
// A reply being held or repeated (a timed reply): its timer, and the messages that arrived meanwhile. A
|
|
118
|
+
// connection serves one command at a time, so nothing else is answered until a held reply is sent, and nothing
|
|
119
|
+
// at all while a repeating one runs (recording:hello-awaitable-exhaust-then-command.json: a ping sent into the
|
|
120
|
+
// stream has no reply). The timer is not work: `settled` does not wait for it, it does not keep the process
|
|
121
|
+
// alive (unref), and a close from either side clears it at once.
|
|
122
|
+
let held: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
+
const parked: Uint8Array[] = [];
|
|
124
|
+
const release = (): void => { if (held !== undefined) { clearTimeout(held); held = undefined; } parked.length = 0; };
|
|
125
|
+
const hold = (ms: number, then: () => void): void => {
|
|
126
|
+
held = setTimeout(() => { held = undefined; if (!closed) then(); }, Math.max(0, ms));
|
|
127
|
+
(held as { unref?: () => void }).unref?.();
|
|
128
|
+
};
|
|
129
|
+
const end = (reason: string): void => {
|
|
130
|
+
if (closed) return;
|
|
131
|
+
closed = true; release();
|
|
132
|
+
(options.ended ?? ((why, from) => console.error(`mongodb wire: ended the connection from ${from}: ${why}`)))(reason, peer);
|
|
133
|
+
options.close?.(session); sink.end();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const answer = async (message: Uint8Array): Promise<void> => {
|
|
137
|
+
const v = view(message), requestId = v.getInt32(4, true), opcode = v.getInt32(12, true);
|
|
138
|
+
if (opcode === MongoOp.MSG) {
|
|
139
|
+
const { flags, document } = decodeMsg(options.codec, message);
|
|
140
|
+
const database = typeof document.$db === 'string' ? document.$db : 'admin';
|
|
141
|
+
const exhaustAllowed = (flags & MongoMsgFlag.EXHAUST_ALLOWED) !== 0;
|
|
142
|
+
const answered = await options.command(session, database, document, { exhaustAllowed });
|
|
143
|
+
// the sender waits for no reply to a message it marked moreToCome
|
|
144
|
+
if (flags & MongoMsgFlag.MORE_TO_COME) return;
|
|
145
|
+
if (!isTimed(answered)) { sink.write(encodeMsg(options.codec, answered, ++nextResponse, requestId)); return; }
|
|
146
|
+
if (answered.everyMs === undefined || !exhaustAllowed) {
|
|
147
|
+
// held once: one reply after the wait, with no flag; then what arrived meanwhile is answered in order
|
|
148
|
+
// source: recording:hello-awaitable-no-exhaust.json "received"
|
|
149
|
+
hold(answered.afterMs, () => {
|
|
150
|
+
sink.write(encodeMsg(options.codec, answered.reply, ++nextResponse, requestId));
|
|
151
|
+
for (const message of parked.splice(0)) take(message);
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// repeated: each reply marked moreToCome; the first answers the request, each later one answers this
|
|
156
|
+
// wire's own previous reply, until the peer closes
|
|
157
|
+
// source: recording:hello-awaitable-exhaust.json "received"
|
|
158
|
+
let answers = requestId;
|
|
159
|
+
const again = (): void => {
|
|
160
|
+
const id = ++nextResponse;
|
|
161
|
+
sink.write(encodeMsg(options.codec, answered.reply, id, answers, MongoMsgFlag.MORE_TO_COME));
|
|
162
|
+
answers = id;
|
|
163
|
+
hold(answered.everyMs!, again);
|
|
164
|
+
};
|
|
165
|
+
// source: recording:hello-stale-process-exhaust.json "received" (the first at once, the rest at the wait)
|
|
166
|
+
hold(answered.afterMs, again);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (opcode === MongoOp.QUERY) {
|
|
170
|
+
// the legacy form a driver's first hello may take: a query on `<database>.$cmd` whose document is the command
|
|
171
|
+
const nameEnd = message.indexOf(0, HEADER + 4);
|
|
172
|
+
if (nameEnd < 0) throw new Error('OP_QUERY names no collection');
|
|
173
|
+
const namespace = dec.decode(message.subarray(HEADER + 4, nameEnd));
|
|
174
|
+
if (!namespace.endsWith('.$cmd')) throw new Error(`OP_QUERY on ${namespace}: only a command on <database>.$cmd is served`);
|
|
175
|
+
const command = options.codec.deserialize(message.subarray(nameEnd + 1 + 8));
|
|
176
|
+
// the legacy form has no flags of OP_MSG's: nothing is held or repeated for it
|
|
177
|
+
const legacy = await options.command(session, namespace.slice(0, -'.$cmd'.length), command, { exhaustAllowed: false });
|
|
178
|
+
const reply = options.codec.serialize(isTimed(legacy) ? legacy.reply : legacy);
|
|
179
|
+
// OP_REPLY: responseFlags (AwaitCapable), cursorID 0, startingFrom 0, numberReturned 1, then the document
|
|
180
|
+
const fixed = new Uint8Array(20), f = view(fixed);
|
|
181
|
+
f.setInt32(0, 8, true); f.setInt32(16, 1, true);
|
|
182
|
+
sink.write(concat([header(HEADER + 20 + reply.length, ++nextResponse, requestId, MongoOp.REPLY), fixed, reply]));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`opcode ${opcode} is not served`);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// One message, in arrival order: parked while a reply is held, answered otherwise.
|
|
189
|
+
const take = (message: Uint8Array): void => {
|
|
190
|
+
chain = chain.then(() => (closed ? undefined : held !== undefined ? void parked.push(message) : answer(message))).catch((error) => end(error instanceof Error ? error.message : String(error)));
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
data(chunk: Uint8Array): void {
|
|
194
|
+
if (closed) return;
|
|
195
|
+
buffer = buffer.length ? concat([buffer, chunk]) : chunk;
|
|
196
|
+
while (buffer.length >= 4) {
|
|
197
|
+
const length = view(buffer).getInt32(0, true);
|
|
198
|
+
// where the citation stops: what the vendor's server does with a malformed or oversized message is not
|
|
199
|
+
// read; this ends the connection, which no driver's well-formed message reaches
|
|
200
|
+
if (length < HEADER || length > limit) { end(`a message of ${length} bytes (accepted: ${HEADER} to ${limit})`); return; }
|
|
201
|
+
if (buffer.length < length) break;
|
|
202
|
+
const message = buffer.subarray(0, length); buffer = buffer.subarray(length);
|
|
203
|
+
take(message.slice());
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
settled: () => chain,
|
|
207
|
+
close: () => { if (!closed) { closed = true; release(); options.close?.(session); } },
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The bytes of a C string, for a client's OP_QUERY. */
|
|
213
|
+
export const cstring = (text: string): Uint8Array => concat([enc.encode(text), new Uint8Array(1)]);
|
package/src/observe.ts
CHANGED
package/src/pack-fetch.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseExactJson } from './exact-json.ts';
|
|
2
|
+
import { redisServiceTimes } from './redis/service-times.ts';
|
|
2
3
|
import { readHostPort } from './host-port.ts';
|
|
3
4
|
// A Protocol 3 pack's whole HTTP surface, served by the kernel from the pack's declarations (docs/contributing/
|
|
4
5
|
// architecture.md, "The pack contract: the kernel's root"): the discovery door (`GET /twin`, the manifest's
|
|
@@ -431,6 +432,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
431
432
|
return readable instanceof Response ? readable : readable ? atContentHost(readable) : undecodable(arrived);
|
|
432
433
|
};
|
|
433
434
|
const fetch = async (arrived: Request): Promise<Response> => {
|
|
435
|
+
redisServiceTimes.lap('1 request made by the stream');
|
|
434
436
|
// the discovery door is the pack's own, whichever lane would take its path
|
|
435
437
|
const asked = new URL(arrived.url).pathname.replace(/\/+$/, '') || '/';
|
|
436
438
|
const discovery = arrived.method === 'GET' && m.discovery !== undefined && (asked === '/twin' || asked === '/twin/scenario');
|
|
@@ -460,6 +462,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
460
462
|
const url = new URL(request.url);
|
|
461
463
|
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
462
464
|
const readOnly = (options.readOnly ?? false) || isReadOnlyRequest(request);
|
|
465
|
+
redisServiceTimes.lap('2 content and lane routes, decoding, routing');
|
|
463
466
|
if (request.method === 'GET' && path === '/twin' && m.discovery) {
|
|
464
467
|
const d = m.discovery;
|
|
465
468
|
const discoveryDoors = [...(m.doors ?? []), ...(m.machines ? [{ id: 'machine-pool', method: 'POST', path: '/_twin/machine-pool', note: 'Enroll the World execution pool: { kind: light | docker | none | fake, images?: { image: { context, command?, runtime? } } }.' }] : [])];
|
|
@@ -562,12 +565,19 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
|
|
|
562
565
|
if (!parts.around) return encoded(m, request, await decided(request));
|
|
563
566
|
// The front reads a context before forwarding (possibly with new headers). Materialize a transport body before
|
|
564
567
|
// those independent reads, rather than chaining tees of the incoming stream through the front and the scenario.
|
|
568
|
+
redisServiceTimes.lap('3 doors, screens and machine routes passed');
|
|
565
569
|
const bytes = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
566
570
|
const buffered = new Request(request.url, { method: request.method, headers: request.headers, signal: request.signal, ...(bytes ? { body: bytes } : {}) });
|
|
567
571
|
const matched = matchOperation(routes, buffered.method, url.pathname, url.searchParams, buffered.headers);
|
|
568
572
|
const operation = matched?.operation ?? ({ id: 'unmatched', method: buffered.method, path: url.pathname, class: buffered.method === 'GET' ? 'retrieve' : 'action' } as DerivedOperation);
|
|
573
|
+
redisServiceTimes.lap('4 body buffered, operation matched');
|
|
569
574
|
const ctx = await contextFor(m, { request: buffered.clone(), operation, params: matched?.params ?? pathParams(routes, url.pathname) }, scope);
|
|
570
|
-
|
|
575
|
+
redisServiceTimes.lap('5 the around context opened');
|
|
576
|
+
const answered = await parts.around(ctx, (rewritten) => decided(rewritten ?? buffered));
|
|
577
|
+
redisServiceTimes.lap('6d the handler, and the around after it');
|
|
578
|
+
const sent = await encoded(m, buffered, answered);
|
|
579
|
+
redisServiceTimes.lap('7 answer encoded');
|
|
580
|
+
return sent;
|
|
571
581
|
};
|
|
572
582
|
// the request's id is the correlation of every entry it appends (runtime contract D3), minted here, always: it is the
|
|
573
583
|
// group a perform sends as one call (the real-system adapters, "Perform"). Under `auto` the group is performed once
|
|
@@ -743,8 +753,16 @@ export function createVendorFetch(manifest: VendorManifest, lanes: Record<string
|
|
|
743
753
|
const ingest = Object.values(lanes).find((l) => l.fetch.ingest)?.fetch.ingest;
|
|
744
754
|
const lane = laneRouter(manifest.vendor, manifest.lanes, lanes);
|
|
745
755
|
return Object.assign(async (request: Request): Promise<Response> => {
|
|
746
|
-
for (const part of Object.values(lanes)) await (part.fetch as typeof part.fetch & { advanceClock?: (request: Request) => Promise<void> }).advanceClock?.(request);
|
|
747
756
|
const path = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
|
|
757
|
+
// the discovery door is answered before any lane's clock moves, as a pack's own is (createPackFetch): reading what
|
|
758
|
+
// the twin is moves no vendor clock
|
|
759
|
+
if (request.method === 'GET' && path === '/twin' && manifest.discovery) {
|
|
760
|
+
// the vendor's screens are its lanes' (a dashboard drawn by one lane is the vendor's)
|
|
761
|
+
const screens = lanesWorkspaces(lanes);
|
|
762
|
+
const laneDiscovery = await laneDoors(lanes, request, '/twin');
|
|
763
|
+
return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), ...(screens.length ? { screens } : {}), ...(Object.keys(laneDiscovery).length ? { lanes: laneDiscovery } : {}) });
|
|
764
|
+
}
|
|
765
|
+
for (const part of Object.values(lanes)) await (part.fetch as typeof part.fetch & { advanceClock?: (request: Request) => Promise<void> }).advanceClock?.(request);
|
|
748
766
|
// the vendor's board is the frames of the lanes that lay one out, each drawn when any lane's screen takes its url
|
|
749
767
|
if (request.method === 'GET' && path === '/twin/board') {
|
|
750
768
|
const frames: BoardFrame[] = [];
|
|
@@ -758,12 +776,6 @@ export function createVendorFetch(manifest: VendorManifest, lanes: Record<string
|
|
|
758
776
|
const draws = (url: string): boolean => Object.values(lanes).some((l) => l.fetch.draws?.(url) ?? false);
|
|
759
777
|
return Response.json({ frames: frames.map((f) => ({ ...f, drawn: draws(f.url) })) });
|
|
760
778
|
}
|
|
761
|
-
if (request.method === 'GET' && path === '/twin' && manifest.discovery) {
|
|
762
|
-
// the vendor's screens are its lanes' (a dashboard drawn by one lane is the vendor's)
|
|
763
|
-
const screens = lanesWorkspaces(lanes);
|
|
764
|
-
const laneDiscovery = await laneDoors(lanes, request, '/twin');
|
|
765
|
-
return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), ...(screens.length ? { screens } : {}), ...(Object.keys(laneDiscovery).length ? { lanes: laneDiscovery } : {}) });
|
|
766
|
-
}
|
|
767
779
|
if (request.method === 'GET' && path === '/twin/scenario') {
|
|
768
780
|
const laneScenarios = await laneDoors(lanes, request, '/twin/scenario');
|
|
769
781
|
if (Object.keys(laneScenarios).length) return Response.json({ vendor: manifest.vendor, lanes: laneScenarios });
|
package/src/packRegistry.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { DerivedManifest, VendorManifest } from './derived-core.ts';
|
|
|
15
15
|
import type { DerivedSurface } from './derived.ts';
|
|
16
16
|
import type { TwinEmitter } from './emit.ts';
|
|
17
17
|
import { registerAuthStrategy, registerStateSystem, type StateSystemAdapters } from './state-system.ts';
|
|
18
|
+
import { PROTOCOL_VERSION, protocolStanding } from './protocol.ts';
|
|
19
|
+
export { PROTOCOL_VERSION, PROTOCOL_MAJOR, protocolStanding } from './protocol.ts';
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* How a pack's clients ADDRESS it. The first three are HTTP API styles; `raw-tcp` is the
|
|
@@ -49,20 +51,6 @@ export type PackTransport = 'rest' | 'graphql' | 'web-api' | 'raw-tcp';
|
|
|
49
51
|
* seam (fly's runtime, github's git plane, supabase's external stack).
|
|
50
52
|
* 'proxy' — the vendor itself is a forwarding plane (tunnel).
|
|
51
53
|
*/
|
|
52
|
-
/** THE PLATFORM PROTOCOL VERSION (runtime contract R16): `major.minor`. The major names the
|
|
53
|
-
* descriptor shape, the factory signature, the doors and the adapter contract a package is
|
|
54
|
-
* written against, and the build form it is made in (the derived pack); a change that removes or
|
|
55
|
-
* alters a required shape bumps it, an additive change bumps minor. A package declares the major
|
|
56
|
-
* it targets (`protocol`), and the kernel serves its own major and no other: a package that
|
|
57
|
-
* declares another, or none, is refused. */
|
|
58
|
-
export const PROTOCOL_VERSION = '3.0';
|
|
59
|
-
export const PROTOCOL_MAJOR = Number(PROTOCOL_VERSION.split('.')[0]);
|
|
60
|
-
/** Where a package's declared protocol stands against this kernel: `current`, or `refused`. */
|
|
61
|
-
export function protocolStanding(declared: string | undefined): { major: number; standing: 'current' | 'refused' } {
|
|
62
|
-
const major = declared === undefined ? Number.NaN : Number(String(declared).split('.')[0]);
|
|
63
|
-
return major === PROTOCOL_MAJOR ? { major, standing: 'current' } : { major, standing: 'refused' };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
54
|
export const PACK_ARCHETYPES = ['crud', 'generative', 'signed-protocol', 'engine-control', 'proxy'] as const;
|
|
67
55
|
export type PackArchetype = (typeof PACK_ARCHETYPES)[number];
|
|
68
56
|
|
|
@@ -237,7 +225,7 @@ export type TwinPack = {
|
|
|
237
225
|
/** Another pack of this vendor issues the API's credentials; architecture, descriptor World wiring. */
|
|
238
226
|
credentialIssuer?: string;
|
|
239
227
|
prismaAdapter?: { adapter: string; export: string; urlEnv: string };
|
|
240
|
-
managedService?: { kind: '
|
|
228
|
+
managedService?: { kind: string; scheme: string; note: string; configurationArg?: '--configuration'; originalInputs?: { version: 1; manifestArg: '--original-inputs'; rootArg: '--original-root'; digestArg: '--original-inputs-sha256'; paths: string[] } };
|
|
241
229
|
managedDatabase?: { kind: 'postgres'; arg: string; note: string };
|
|
242
230
|
sidePorts?: Array<{ name: string; option: string; flag: string; path?: string; scheme?: string; note: string }>;
|
|
243
231
|
stateSystem?: StateSystemAdapters;
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** THE PLATFORM PROTOCOL VERSION (runtime contract R16): `major.minor`. The major names the
|
|
2
|
+
* descriptor shape, the factory signature, the doors and the adapter contract a package is
|
|
3
|
+
* written against, and the build form it is made in (the derived pack); a change that removes or
|
|
4
|
+
* alters a required shape bumps it, an additive change bumps minor. A package declares the major
|
|
5
|
+
* it targets (`protocol`), and the kernel serves its own major and no other: a package that
|
|
6
|
+
* declares another, or none, is refused. */
|
|
7
|
+
export const PROTOCOL_VERSION = '3.0';
|
|
8
|
+
export const PROTOCOL_MAJOR = Number(PROTOCOL_VERSION.split('.')[0]);
|
|
9
|
+
/** Where a package's declared protocol stands against this kernel: `current`, or `refused`. */
|
|
10
|
+
export function protocolStanding(declared: string | undefined): { major: number; standing: 'current' | 'refused' } {
|
|
11
|
+
const major = declared === undefined ? Number.NaN : Number(String(declared).split('.')[0]);
|
|
12
|
+
return major === PROTOCOL_MAJOR ? { major, standing: 'current' } : { major, standing: 'refused' };
|
|
13
|
+
}
|
package/src/redis/engine.ts
CHANGED
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
import { createHash } from 'node:crypto';
|
|
47
47
|
import { projectResources } from '../actions.ts';
|
|
48
48
|
import { treeChangesSince, treeStamp } from '../log.ts';
|
|
49
|
-
import {
|
|
49
|
+
import { redisServiceTimes as times } from './service-times.ts';
|
|
50
|
+
import { applyTwinWrites, type TwinResource, type TwinWriteInput } from '../serve.ts';
|
|
50
51
|
import { getActiveWorldStore } from '../world-store.ts';
|
|
51
52
|
import { LUA_NULL, luaScriptSha1, LuaError, LuaTable, runLua, type LuaHost, type LuaValue } from './lua.ts';
|
|
52
53
|
import { inlineCommand } from './frames.ts';
|
|
@@ -110,7 +111,7 @@ type StoredZSet = Array<[string, string]>;
|
|
|
110
111
|
export type StreamEntries = Array<[string, HashPairs]>;
|
|
111
112
|
|
|
112
113
|
/** A point-in-time copy of a run's whole mutable state, for script rollback. */
|
|
113
|
-
type Snapshot = { rows:
|
|
114
|
+
type Snapshot = { rows: OverlayState<KeyRow>; touched: Map<string, string>; scripts: OverlayState<string>; touchedScripts: Map<string, string> };
|
|
114
115
|
|
|
115
116
|
export type KeyRow = {
|
|
116
117
|
name: string;
|
|
@@ -399,29 +400,71 @@ function patchImage(image: KeyspaceImage, changed: readonly TwinResource[], remo
|
|
|
399
400
|
return true;
|
|
400
401
|
}
|
|
401
402
|
|
|
403
|
+
const GONE: unique symbol = Symbol('gone');
|
|
404
|
+
type OverlayState<V> = { own: Map<string, V | typeof GONE>; moved: Set<string> };
|
|
405
|
+
/**
|
|
406
|
+
* A run's map over the shared image: what the run changed is its own, the rest is read through. Copying the whole image
|
|
407
|
+
* per run (and again per script, for its rollback) cost every command the size of the keyspace (S2 R19: Twenty's queue
|
|
408
|
+
* twin, its queue scripts waiting behind each other's copies). Iteration is a `new Map(base)` changed in place's: the
|
|
409
|
+
* base's order with its changed values, then the keys added, a base key deleted and set again among them at its set.
|
|
410
|
+
*/
|
|
411
|
+
class Overlay<V> {
|
|
412
|
+
private own = new Map<string, V | typeof GONE>();
|
|
413
|
+
private moved = new Set<string>();
|
|
414
|
+
constructor(private readonly base: ReadonlyMap<string, V>) {}
|
|
415
|
+
get(key: string): V | undefined {
|
|
416
|
+
const mine = this.own.get(key);
|
|
417
|
+
return mine === GONE ? undefined : mine !== undefined || this.own.has(key) ? mine as V : this.base.get(key);
|
|
418
|
+
}
|
|
419
|
+
has(key: string): boolean { return this.own.has(key) ? this.own.get(key) !== GONE : this.base.has(key); }
|
|
420
|
+
set(key: string, value: V): void {
|
|
421
|
+
if (this.own.get(key) === GONE) { this.own.delete(key); this.moved.add(key); }
|
|
422
|
+
this.own.set(key, value);
|
|
423
|
+
}
|
|
424
|
+
delete(key: string): void {
|
|
425
|
+
this.own.delete(key);
|
|
426
|
+
this.moved.delete(key);
|
|
427
|
+
if (this.base.has(key)) this.own.set(key, GONE);
|
|
428
|
+
}
|
|
429
|
+
*entries(): IterableIterator<[string, V]> {
|
|
430
|
+
for (const [key, value] of this.base) {
|
|
431
|
+
if (this.moved.has(key)) continue;
|
|
432
|
+
if (!this.own.has(key)) { yield [key, value]; continue; }
|
|
433
|
+
const mine = this.own.get(key)!;
|
|
434
|
+
if (mine !== GONE) yield [key, mine];
|
|
435
|
+
}
|
|
436
|
+
for (const [key, mine] of this.own) if (mine !== GONE && (!this.base.has(key) || this.moved.has(key))) yield [key, mine];
|
|
437
|
+
}
|
|
438
|
+
*keys(): IterableIterator<string> { for (const [key] of this.entries()) yield key; }
|
|
439
|
+
*values(): IterableIterator<V> { for (const [, value] of this.entries()) yield value; }
|
|
440
|
+
/** What the run changed, copied: a rollback point the size of the changes, not of the keyspace. */
|
|
441
|
+
save(): OverlayState<V> { return { own: new Map(this.own), moved: new Set(this.moved) }; }
|
|
442
|
+
load(state: OverlayState<V>): void { this.own = new Map(state.own); this.moved = new Set(state.moved); }
|
|
443
|
+
}
|
|
444
|
+
|
|
402
445
|
/**
|
|
403
446
|
* A synchronous, in-memory image of the keyspace for the duration of ONE request.
|
|
404
447
|
*
|
|
405
448
|
* Seeded from the keyspace's image (the tree folded by `projectResources`, memoized per World state — the kernel IS
|
|
406
449
|
* the source of truth); every mutation records the key name in `touched`, and the flush writes exactly those keys
|
|
407
|
-
* back. This run
|
|
450
|
+
* back. This run owns its overlays: nothing a run changes reaches the image or a later request except
|
|
408
451
|
* through the tree.
|
|
409
452
|
*/
|
|
410
453
|
export class KeySpace {
|
|
411
|
-
private readonly rows:
|
|
454
|
+
private readonly rows: Overlay<KeyRow>;
|
|
412
455
|
private readonly touched = new Map<string, string>(); // key → the operation label to record
|
|
413
|
-
private readonly scripts:
|
|
456
|
+
private readonly scripts: Overlay<string>; // sha1 → script source (a SEPARATE namespace)
|
|
414
457
|
private readonly touchedScripts = new Map<string, string>();
|
|
415
458
|
/** name → the last write ordinal seen, INCLUDING for keys currently deleted or expired. */
|
|
416
|
-
private readonly revs:
|
|
417
|
-
/** The
|
|
459
|
+
private readonly revs: Overlay<number>;
|
|
460
|
+
/** The shared base maps are patched by the next image(); a KeySpace must finish reading its base before it yields. */
|
|
418
461
|
readonly image: KeyspaceImage;
|
|
419
462
|
constructor(readonly ctx: RedisContext, readonly nowMs: number) {
|
|
420
|
-
//
|
|
463
|
+
// The base maps are patched by the next image(); finish reading this run's base before yielding.
|
|
421
464
|
const image = this.image = ctx.dialect.storage?.image(ctx) ?? keyspaceImageOf(ctx);
|
|
422
|
-
this.rows = new
|
|
423
|
-
this.scripts = new
|
|
424
|
-
this.revs = new
|
|
465
|
+
this.rows = new Overlay(image.rows);
|
|
466
|
+
this.scripts = new Overlay(image.scripts);
|
|
467
|
+
this.revs = new Overlay(image.revs);
|
|
425
468
|
}
|
|
426
469
|
/**
|
|
427
470
|
* Is this row expired AS OF NOW? Checked on every read, not merely when the snapshot was built.
|
|
@@ -470,15 +513,13 @@ export class KeySpace {
|
|
|
470
513
|
}
|
|
471
514
|
/** A point-in-time copy, so a failed script can be rolled back to exactly where it started. */
|
|
472
515
|
snapshot(): Snapshot {
|
|
473
|
-
return { rows:
|
|
516
|
+
return { rows: this.rows.save(), touched: new Map(this.touched), scripts: this.scripts.save(), touchedScripts: new Map(this.touchedScripts) };
|
|
474
517
|
}
|
|
475
518
|
restore(s: Snapshot): void {
|
|
476
|
-
this.rows.
|
|
477
|
-
for (const [k, v] of s.rows) this.rows.set(k, v);
|
|
519
|
+
this.rows.load(s.rows);
|
|
478
520
|
this.touched.clear();
|
|
479
521
|
for (const [k, v] of s.touched) this.touched.set(k, v);
|
|
480
|
-
this.scripts.
|
|
481
|
-
for (const [k, v] of s.scripts) this.scripts.set(k, v);
|
|
522
|
+
this.scripts.load(s.scripts);
|
|
482
523
|
this.touchedScripts.clear();
|
|
483
524
|
for (const [k, v] of s.touchedScripts) this.touchedScripts.set(k, v);
|
|
484
525
|
}
|
|
@@ -524,39 +565,34 @@ export async function restoreKeyspace(image: readonly KeyImage[], ctx: RedisCont
|
|
|
524
565
|
}
|
|
525
566
|
|
|
526
567
|
async function flushKeySpace(space: KeySpace): Promise<void> {
|
|
568
|
+
// a run's writes land together, in this order, as they landed one at a time (serve.ts applyTwinWrites)
|
|
569
|
+
const writes: TwinWriteInput[] = [];
|
|
527
570
|
for (const { name, operation, row } of space.pendingWrites()) {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
actor: { kind: 'agent' },
|
|
542
|
-
},
|
|
543
|
-
space.ctx.root,
|
|
544
|
-
);
|
|
571
|
+
writes.push({
|
|
572
|
+
operation,
|
|
573
|
+
subjectType: 'key',
|
|
574
|
+
subjectId: `${scopeOf(space.ctx)}key:${name}`,
|
|
575
|
+
// The kernel MERGES fields, so a delete must write EVERY field back to its "nothing here"
|
|
576
|
+
// value — leaving `v`/`pexpire_at` behind would let a later recreate inherit a dead value
|
|
577
|
+
// or a dead TTL.
|
|
578
|
+
fields: row === null
|
|
579
|
+
? { name, kind: 'string', v: '', pexpire_at: null, last_id: '', gone: true, _rev: space.nextRev(name) }
|
|
580
|
+
: { name, kind: row.kind, v: row.v as never, pexpire_at: row.pexpireAt, last_id: row.lastId ?? '', gone: false, _rev: space.nextRev(name) },
|
|
581
|
+
occurredAt: space.ctx.occurredAt,
|
|
582
|
+
actor: { kind: 'agent' },
|
|
583
|
+
});
|
|
545
584
|
}
|
|
546
585
|
for (const { sha, operation, body } of space.pendingScriptWrites()) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
actor: { kind: 'agent' },
|
|
556
|
-
},
|
|
557
|
-
space.ctx.root,
|
|
558
|
-
);
|
|
586
|
+
writes.push({
|
|
587
|
+
operation,
|
|
588
|
+
subjectType: 'script',
|
|
589
|
+
subjectId: `${scopeOf(space.ctx)}script:${sha}`,
|
|
590
|
+
fields: body === null ? { sha, body: '', gone: true } : { sha, body, gone: false },
|
|
591
|
+
occurredAt: space.ctx.occurredAt,
|
|
592
|
+
actor: { kind: 'agent' },
|
|
593
|
+
});
|
|
559
594
|
}
|
|
595
|
+
await applyTwinWrites(space.ctx.service, writes, space.ctx.root);
|
|
560
596
|
}
|
|
561
597
|
|
|
562
598
|
// ── typed value accessors (each asserts the key's type first) ─────────────────────────────────
|
|
@@ -1608,21 +1644,28 @@ export type RunItem = { result: RedisValue } | { error: string };
|
|
|
1608
1644
|
* an unavailable command or a bad arity — are QUEUE-time and DO discard the whole batch, but the
|
|
1609
1645
|
* caller (`upstash-twin.ts`) rejects those before this function is ever reached.
|
|
1610
1646
|
*/
|
|
1611
|
-
export async function execRedisRun(commands: string[][], ctx: RedisContext): Promise<{ items: RunItem[]; wrote: boolean; instant: number }> {
|
|
1647
|
+
export async function execRedisRun(commands: string[][], ctx: RedisContext): Promise<{ items: RunItem[]; wrote: boolean; instant: number; touched: string[] }> {
|
|
1612
1648
|
const nowMs = Date.parse(ctx.occurredAt);
|
|
1613
1649
|
if (!Number.isFinite(nowMs)) throw new RedisCommandError(`ERR twin: unparseable occurredAt '${ctx.occurredAt}'`);
|
|
1614
|
-
|
|
1650
|
+
// The phases a served command's time is counted in (service-times.ts): the image, the commands, their writes.
|
|
1651
|
+
const space = times.timed('image', () => new KeySpace(ctx, nowMs));
|
|
1615
1652
|
const items: RunItem[] = [];
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1653
|
+
const began = times.now();
|
|
1654
|
+
try {
|
|
1655
|
+
for (const argv of commands) {
|
|
1656
|
+
try {
|
|
1657
|
+
items.push({ result: execOne(space, argv) });
|
|
1658
|
+
} catch (e) {
|
|
1659
|
+
if (e instanceof ReadOnlyError) throw e; // surfaces as HTTP 405 for the whole request
|
|
1660
|
+
if (e instanceof RedisCommandError) { items.push({ error: e.message }); continue; }
|
|
1661
|
+
throw e;
|
|
1662
|
+
}
|
|
1623
1663
|
}
|
|
1624
|
-
}
|
|
1664
|
+
} finally { times.add('execute', times.now() - began); }
|
|
1625
1665
|
const wrote = space.dirty;
|
|
1626
|
-
|
|
1627
|
-
|
|
1666
|
+
// The keys this run wrote, by name, read before the flush clears them: a stream wakes only the clients blocked on one.
|
|
1667
|
+
const touched = space.touchedNames();
|
|
1668
|
+
const flushing = times.now();
|
|
1669
|
+
try { await (space.ctx.dialect.storage?.flush(space) ?? flushKeySpace(space)); } finally { times.add('flush', times.now() - flushing); }
|
|
1670
|
+
return { items, wrote, instant: nowMs, touched };
|
|
1628
1671
|
}
|