solid-objects 0.13.1 → 0.13.3
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/CHANGELOG.md +41 -0
- package/README.md +32 -27
- package/dist/actor.d.ts +5 -0
- package/dist/actor.d.ts.map +1 -1
- package/dist/actor.js +32 -0
- package/dist/actor.js.map +1 -1
- package/dist/doctor.js +2 -2
- package/dist/doctor.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/process-administration.d.ts +5 -0
- package/dist/process-administration.d.ts.map +1 -1
- package/dist/process-administration.js +9 -0
- package/dist/process-administration.js.map +1 -1
- package/dist/records.d.ts +1 -0
- package/dist/records.d.ts.map +1 -1
- package/dist/reminder-administration.d.ts +2 -0
- package/dist/reminder-administration.d.ts.map +1 -1
- package/dist/reminder-administration.js.map +1 -1
- package/dist/reminder-scheduler.js +1 -1
- package/dist/reminder-scheduler.js.map +1 -1
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +7 -4
- package/dist/repository.js.map +1 -1
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +9 -5
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +14 -1
- package/dist/schema.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/api.md +45 -0
- package/docs/operations.md +14 -3
- package/docs/parity.md +2 -2
- package/docs/releasing.md +9 -3
- package/docs/support.md +12 -1
- package/examples/failure-recovery/actor.ts +6 -8
- package/examples/failure-recovery/demo.ts +8 -26
- package/examples/failure-recovery/serialization.ts +133 -0
- package/package.json +2 -2
|
@@ -8,6 +8,11 @@ import { fork, type ChildProcess } from "node:child_process"
|
|
|
8
8
|
import { createRuntime, type ActorReference, type MessageReference } from "solid-objects"
|
|
9
9
|
import { sqlite } from "solid-objects/database/sqlite"
|
|
10
10
|
import { RecoveryCounter } from "./actor.ts"
|
|
11
|
+
import {
|
|
12
|
+
assertSerializedExecution,
|
|
13
|
+
parseSerializationEvent,
|
|
14
|
+
type SerializationProof,
|
|
15
|
+
} from "./serialization.ts"
|
|
11
16
|
|
|
12
17
|
interface WorkerMessage {
|
|
13
18
|
event: string
|
|
@@ -15,12 +20,6 @@ interface WorkerMessage {
|
|
|
15
20
|
processed?: number
|
|
16
21
|
}
|
|
17
22
|
|
|
18
|
-
interface SerializationEvent {
|
|
19
|
-
event: "start" | "finish"
|
|
20
|
-
messageId: string
|
|
21
|
-
at: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
23
|
interface ExternalEffectEvent {
|
|
25
24
|
messageId: string
|
|
26
25
|
attempt: number
|
|
@@ -59,7 +58,7 @@ try {
|
|
|
59
58
|
|
|
60
59
|
assert.equal(existsSync(directory), false)
|
|
61
60
|
|
|
62
|
-
async function proveSerialization(): Promise<{ finalState: number
|
|
61
|
+
async function proveSerialization(): Promise<SerializationProof & { finalState: number }> {
|
|
63
62
|
const controlDirectory = join(directory, "serialization")
|
|
64
63
|
await mkdir(controlDirectory)
|
|
65
64
|
const reference = runtime.ref(RecoveryCounter, "serialized")
|
|
@@ -74,15 +73,10 @@ async function proveSerialization(): Promise<{ finalState: number; overlap: fals
|
|
|
74
73
|
join(controlDirectory, "serialization.jsonl"),
|
|
75
74
|
parseSerializationEvent,
|
|
76
75
|
)
|
|
77
|
-
|
|
78
|
-
const starts = events.filter((event) => event.event === "start")
|
|
79
|
-
const finishes = events.filter((event) => event.event === "finish")
|
|
80
|
-
assert.equal(starts.length, 2)
|
|
81
|
-
assert.equal(finishes.length, 2)
|
|
82
|
-
assert(Number(starts[1]?.at) >= Number(finishes[0]?.at))
|
|
76
|
+
const proof = assertSerializedExecution(events, { messageCount: 2 })
|
|
83
77
|
const snapshot = await reference.snapshot()
|
|
84
78
|
assert.equal(snapshot.count, 2)
|
|
85
|
-
return { finalState: snapshot.count
|
|
79
|
+
return { ...proof, finalState: snapshot.count }
|
|
86
80
|
}
|
|
87
81
|
|
|
88
82
|
async function proveCrashRecovery(): Promise<{
|
|
@@ -200,18 +194,6 @@ async function readJsonLines<Value>(
|
|
|
200
194
|
return (await readFile(path, "utf8")).trim().split("\n").filter(Boolean).map(parse)
|
|
201
195
|
}
|
|
202
196
|
|
|
203
|
-
function parseSerializationEvent(line: string): SerializationEvent {
|
|
204
|
-
const event = JSON.parse(line) as Partial<SerializationEvent>
|
|
205
|
-
if (
|
|
206
|
-
(event.event !== "start" && event.event !== "finish") ||
|
|
207
|
-
typeof event.messageId !== "string" ||
|
|
208
|
-
typeof event.at !== "number"
|
|
209
|
-
) {
|
|
210
|
-
throw new TypeError("invalid serialization event")
|
|
211
|
-
}
|
|
212
|
-
return { event: event.event, messageId: event.messageId, at: event.at }
|
|
213
|
-
}
|
|
214
|
-
|
|
215
197
|
function parseExternalEffectEvent(line: string): ExternalEffectEvent {
|
|
216
198
|
const event = JSON.parse(line) as Partial<ExternalEffectEvent>
|
|
217
199
|
if (
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
|
|
3
|
+
export interface SerializationEvent {
|
|
4
|
+
event: "start" | "finish"
|
|
5
|
+
messageId: string
|
|
6
|
+
attempt: number
|
|
7
|
+
processId: number
|
|
8
|
+
at: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SerializationProof {
|
|
12
|
+
executions: number
|
|
13
|
+
retried: boolean
|
|
14
|
+
supersededOverlap: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Execution {
|
|
18
|
+
messageId: string
|
|
19
|
+
attempt: number
|
|
20
|
+
processId: number
|
|
21
|
+
startedAt: number
|
|
22
|
+
finishedAt: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseSerializationEvent(line: string): SerializationEvent {
|
|
26
|
+
const event = JSON.parse(line) as Partial<SerializationEvent>
|
|
27
|
+
if (
|
|
28
|
+
(event.event !== "start" && event.event !== "finish") ||
|
|
29
|
+
typeof event.messageId !== "string" ||
|
|
30
|
+
typeof event.attempt !== "number" ||
|
|
31
|
+
typeof event.processId !== "number" ||
|
|
32
|
+
typeof event.at !== "number"
|
|
33
|
+
) {
|
|
34
|
+
throw new TypeError("invalid serialization event")
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
event: event.event,
|
|
38
|
+
messageId: event.messageId,
|
|
39
|
+
attempt: event.attempt,
|
|
40
|
+
processId: event.processId,
|
|
41
|
+
at: event.at,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// One identity commits one state transition at a time. The control file is
|
|
46
|
+
// written outside the transaction, so it records execution attempts rather than
|
|
47
|
+
// commits: a worker that loses its lease keeps running until it notices, and its
|
|
48
|
+
// replacement executes the same message under a higher attempt. The superseded
|
|
49
|
+
// attempt may therefore overlap anything, because its write is fenced out and
|
|
50
|
+
// the committed state is what proves it.
|
|
51
|
+
//
|
|
52
|
+
// Each event carries its attempt and process, so a start pairs with its own
|
|
53
|
+
// finish rather than with whichever finish arrived next. Without that, a
|
|
54
|
+
// superseded attempt finishing late reads as its replacement finishing, and a
|
|
55
|
+
// second message could then overlap a replacement that is still running.
|
|
56
|
+
export function assertSerializedExecution(
|
|
57
|
+
events: readonly SerializationEvent[],
|
|
58
|
+
options: { messageCount: number },
|
|
59
|
+
): SerializationProof {
|
|
60
|
+
const executions = pairExecutions(events)
|
|
61
|
+
|
|
62
|
+
const messageIds = new Set(executions.map((execution) => execution.messageId))
|
|
63
|
+
assert.equal(
|
|
64
|
+
messageIds.size,
|
|
65
|
+
options.messageCount,
|
|
66
|
+
`expected ${options.messageCount} messages to run, saw ${messageIds.size}`,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const survivingAttempt = new Map<string, number>()
|
|
70
|
+
for (const execution of executions) {
|
|
71
|
+
const highest = survivingAttempt.get(execution.messageId) ?? 0
|
|
72
|
+
if (execution.attempt > highest) survivingAttempt.set(execution.messageId, execution.attempt)
|
|
73
|
+
}
|
|
74
|
+
const surviving = executions.filter(
|
|
75
|
+
(execution) => survivingAttempt.get(execution.messageId) === execution.attempt,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for (const [index, execution] of surviving.entries()) {
|
|
79
|
+
for (const other of surviving.slice(index + 1)) {
|
|
80
|
+
assert(
|
|
81
|
+
!overlaps(execution, other),
|
|
82
|
+
`${describe(execution)} and ${describe(other)} overlap, and neither was superseded`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const supersededOverlap = executions.some((execution) =>
|
|
88
|
+
executions.some((other) => other !== execution && overlaps(execution, other)),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
executions: executions.length,
|
|
93
|
+
retried: executions.length > options.messageCount,
|
|
94
|
+
supersededOverlap,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function pairExecutions(events: readonly SerializationEvent[]): Execution[] {
|
|
99
|
+
const started = new Map<string, SerializationEvent>()
|
|
100
|
+
const executions: Execution[] = []
|
|
101
|
+
|
|
102
|
+
for (const event of [...events].sort((left, right) => left.at - right.at)) {
|
|
103
|
+
const key = `${event.messageId}#${event.attempt}#${event.processId}`
|
|
104
|
+
if (event.event === "start") {
|
|
105
|
+
assert(!started.has(key), `${describe(event)} started twice`)
|
|
106
|
+
started.set(key, event)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const start = started.get(key)
|
|
110
|
+
assert(start !== undefined, `${describe(event)} finished with no matching start`)
|
|
111
|
+
started.delete(key)
|
|
112
|
+
executions.push({
|
|
113
|
+
messageId: event.messageId,
|
|
114
|
+
attempt: event.attempt,
|
|
115
|
+
processId: event.processId,
|
|
116
|
+
startedAt: start.at,
|
|
117
|
+
finishedAt: event.at,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const unfinished = [...started.values()].map(describe)
|
|
122
|
+
assert.equal(unfinished.length, 0, `${unfinished.join(", ")} never wrote a finish`)
|
|
123
|
+
|
|
124
|
+
return executions
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function overlaps(left: Execution, right: Execution): boolean {
|
|
128
|
+
return left.startedAt < right.finishedAt && right.startedAt < left.finishedAt
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function describe(execution: { messageId: string; attempt: number }): string {
|
|
132
|
+
return `${execution.messageId} attempt ${execution.attempt}`
|
|
133
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solid-objects",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"description": "Race-free realtime state per application identity, backed by your SQL database",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"url": "git+https://github.com/cardmagic/solid-objects-js.git"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=24.
|
|
26
|
+
"node": ">=24.4.0"
|
|
27
27
|
},
|
|
28
28
|
"packageManager": "pnpm@11.11.0",
|
|
29
29
|
"bin": {
|