-
Notifications
You must be signed in to change notification settings - Fork 1
examples: make the at-least-once duplicate observable at a sink #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { Actor } from "solid-objects" | ||
|
|
||
| export class DeliveryCounter extends Actor { | ||
| static override readonly actorType = "DeliveryCounter" | ||
|
|
||
| count = 0 | ||
|
|
||
| deliver(): number { | ||
| this.count += 1 | ||
| this.emit("record", { arguments: {} }) | ||
| return this.count | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import assert from "node:assert/strict" | ||
| import { fork, type ChildProcess } from "node:child_process" | ||
| import { mkdtemp, rm } from "node:fs/promises" | ||
| import { tmpdir } from "node:os" | ||
| import { join } from "node:path" | ||
| import { fileURLToPath } from "node:url" | ||
| import { createRuntime } from "solid-objects" | ||
| import { sqlite } from "solid-objects/database/sqlite" | ||
| import { DeliveryCounter } from "./actor.ts" | ||
| import { readSink } from "./sink.ts" | ||
|
|
||
| const directory = await mkdtemp(join(tmpdir(), "solid-objects-at-least-once-")) | ||
| const databasePath = join(directory, "state.sqlite3") | ||
| const runtime = createRuntime({ | ||
| database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }), | ||
| leaseDurationMilliseconds: 250, | ||
| leaseRenewalIntervalMilliseconds: 50, | ||
| processHeartbeatIntervalMilliseconds: 75, | ||
| processAliveThresholdMilliseconds: 300, | ||
| workerCount: 1, | ||
| effectWorkerCount: 0, | ||
| reminderSchedulerCount: 0, | ||
| retentionIntervalMilliseconds: 0, | ||
| deadProcessCleanupIntervalMilliseconds: 0, | ||
| authorizeMessage: () => true, | ||
| authorizeQuery: () => true, | ||
| authorizeAdministration: () => true, | ||
| }) | ||
|
|
||
| try { | ||
| runtime.register(DeliveryCounter) | ||
| await runtime.install() | ||
| const duplicate = await proveDuplicateAtSink() | ||
| const remedy = await proveDeduplicationAbsorbsIt() | ||
| process.stdout.write(`${JSON.stringify({ duplicate, remedy }, null, 2)}\n`) | ||
| } finally { | ||
| await runtime.close() | ||
| await rm(directory, { recursive: true }) | ||
| } | ||
|
|
||
| async function proveDuplicateAtSink(): Promise<{ | ||
| stateCommits: number | ||
| sinkDeliveries: number | ||
| sameEffectId: boolean | ||
| attempts: number[] | ||
| }> { | ||
| const sinkPath = join(directory, "sink-dedup-off.json") | ||
| await stageOneDelivery("dedup-off") | ||
| await crashThenRecover({ sinkPath, deduplicate: "off" }) | ||
|
|
||
| const sink = await readSink(sinkPath) | ||
| const snapshot = await runtime.ref(DeliveryCounter, "dedup-off").snapshot() | ||
| assert.equal(snapshot.count, 1, "the state commit happened exactly once") | ||
| assert.equal(sink.deliveries.length, 2, "the sink observed the duplicate") | ||
| assert.equal( | ||
| sink.deliveries[0]?.effectId, | ||
| sink.deliveries[1]?.effectId, | ||
| "both deliveries carried the same stable effect id", | ||
| ) | ||
| return { | ||
| stateCommits: snapshot.count, | ||
| sinkDeliveries: sink.deliveries.length, | ||
| sameEffectId: sink.deliveries[0]?.effectId === sink.deliveries[1]?.effectId, | ||
| attempts: sink.deliveries.map((delivery) => delivery.attempt), | ||
| } | ||
| } | ||
|
|
||
| async function proveDeduplicationAbsorbsIt(): Promise<{ | ||
| stateCommits: number | ||
| sinkDeliveries: number | ||
| }> { | ||
| const sinkPath = join(directory, "sink-dedup-on.json") | ||
| await stageOneDelivery("dedup-on") | ||
| await crashThenRecover({ sinkPath, deduplicate: "on" }) | ||
|
|
||
| const sink = await readSink(sinkPath) | ||
| const snapshot = await runtime.ref(DeliveryCounter, "dedup-on").snapshot() | ||
| assert.equal(snapshot.count, 1, "the state commit happened exactly once") | ||
| assert.equal(sink.deliveries.length, 1, "the stable effect id absorbed the duplicate") | ||
| return { stateCommits: snapshot.count, sinkDeliveries: sink.deliveries.length } | ||
| } | ||
|
|
||
| async function stageOneDelivery(actorId: string): Promise<void> { | ||
| const message = await runtime.ref(DeliveryCounter, actorId).send.deliver() | ||
| const worker = runtime.worker() | ||
| try { | ||
| let processed = 0 | ||
| for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) { | ||
| processed = await worker.runOnce({ activationRetention: "release" }) | ||
| if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10)) | ||
| } | ||
| assert.equal(processed, 1, "the actor turn committed and staged the effect") | ||
| } finally { | ||
| await worker.stop() | ||
| } | ||
| assert.equal(await message.status(), "completed") | ||
| } | ||
|
|
||
| async function crashThenRecover(options: { | ||
| sinkPath: string | ||
| deduplicate: "on" | "off" | ||
| }): Promise<void> { | ||
| const crashing = spawnEffectWorker({ ...options, mode: "crash" }) | ||
| const crashExit = await crashing.finished | ||
| assert.equal(crashExit, 1, "the first delivery crashed before acknowledgement") | ||
|
|
||
| await new Promise((resolve) => setTimeout(resolve, 400)) | ||
|
|
||
| const recovering = spawnEffectWorker({ ...options, mode: "complete" }) | ||
| const recoveryExit = await recovering.finished | ||
| assert.equal(recoveryExit, 0, "the second delivery completed and acknowledged") | ||
| } | ||
|
|
||
| function spawnEffectWorker(options: { | ||
| sinkPath: string | ||
| deduplicate: "on" | "off" | ||
| mode: "crash" | "complete" | ||
| }): { child: ChildProcess; finished: Promise<number | null> } { | ||
| const child = fork( | ||
| fileURLToPath(new URL("./effect-worker.ts", import.meta.url)), | ||
| [databasePath, options.sinkPath, options.mode, options.deduplicate], | ||
| { stdio: ["ignore", "inherit", "inherit", "ipc"] }, | ||
| ) | ||
| const finished = new Promise<number | null>((resolve, reject) => { | ||
| child.once("exit", (code) => resolve(code)) | ||
| child.once("error", reject) | ||
| }) | ||
| return { child, finished } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { createRuntime } from "solid-objects" | ||
| import { sqlite } from "solid-objects/database/sqlite" | ||
| import { DeliveryCounter } from "./actor.ts" | ||
| import { recordDelivery } from "./sink.ts" | ||
|
|
||
| const databasePath = requiredArgument(2) | ||
| const sinkPath = requiredArgument(3) | ||
| const mode = requiredArgument(4) | ||
| const deduplicate = requiredArgument(5) === "on" | ||
|
|
||
| const runtime = createRuntime({ | ||
| database: sqlite({ path: databasePath, timeoutMilliseconds: 2_000, lockRetryAttempts: 20 }), | ||
| pollingIntervalMilliseconds: 10, | ||
| leaseDurationMilliseconds: 250, | ||
| leaseRenewalIntervalMilliseconds: 50, | ||
| processHeartbeatIntervalMilliseconds: 75, | ||
| processAliveThresholdMilliseconds: 300, | ||
| workerCount: 0, | ||
| effectWorkerCount: 1, | ||
| reminderSchedulerCount: 0, | ||
| retentionIntervalMilliseconds: 0, | ||
| deadProcessCleanupIntervalMilliseconds: 0, | ||
| authorizeMessage: () => true, | ||
| authorizeQuery: () => true, | ||
| authorizeAdministration: () => true, | ||
| }) | ||
|
|
||
| runtime.register(DeliveryCounter) | ||
| runtime.registerEffect("record", async (_argumentsValue, context) => { | ||
| const { applied } = await recordDelivery({ | ||
| path: sinkPath, | ||
| effectId: context.id, | ||
| attempt: context.attempt, | ||
| deduplicate, | ||
| }) | ||
| process.send?.({ event: "sink.recorded", effectId: context.id, applied }) | ||
| if (mode === "crash") { | ||
| process.exit(1) | ||
| } | ||
| return null | ||
| }) | ||
| await runtime.install() | ||
| const effectWorker = runtime.effectWorker() | ||
|
|
||
| try { | ||
| let processed = 0 | ||
| for (let attempt = 0; attempt < 200 && processed === 0; attempt += 1) { | ||
| processed = await effectWorker.runOnce() | ||
| if (processed === 0) await new Promise((resolve) => setTimeout(resolve, 10)) | ||
| } | ||
| if (processed === 0) throw new Error("no effect became claimable") | ||
| process.send?.({ event: "effects.finished", processed }) | ||
| } finally { | ||
| await effectWorker.stop() | ||
| await runtime.close() | ||
| } | ||
|
|
||
| function requiredArgument(index: number): string { | ||
| const value = process.argv[index] | ||
| if (!value) throw new TypeError(`argument ${index - 1} is required`) | ||
| return value | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { readFile, writeFile } from "node:fs/promises" | ||
|
|
||
| export interface SinkDelivery { | ||
| effectId: string | ||
| attempt: number | ||
| } | ||
|
|
||
| export interface SinkState { | ||
| deliveries: SinkDelivery[] | ||
| } | ||
|
|
||
| export async function readSink(path: string): Promise<SinkState> { | ||
| try { | ||
| const parsed: SinkState = JSON.parse(await readFile(path, "utf-8")) | ||
| return parsed | ||
| } catch (error) { | ||
| if ((error as NodeJS.ErrnoException).code === "ENOENT") return { deliveries: [] } | ||
| throw error | ||
| } | ||
| } | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| export async function recordDelivery(options: { | ||
| path: string | ||
| effectId: string | ||
| attempt: number | ||
| deduplicate: boolean | ||
| }): Promise<{ applied: boolean }> { | ||
| const sink = await readSink(options.path) | ||
| const seen = sink.deliveries.some((delivery) => delivery.effectId === options.effectId) | ||
| if (options.deduplicate && seen) return { applied: false } | ||
| sink.deliveries.push({ effectId: options.effectId, attempt: options.attempt }) | ||
| await writeFile(options.path, JSON.stringify(sink, null, 2)) | ||
| return { applied: true } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { mkdtemp, rm, writeFile } from "node:fs/promises" | ||
| import { tmpdir } from "node:os" | ||
| import { join } from "node:path" | ||
| import { afterEach, describe, expect, it } from "vitest" | ||
| import { readSink, recordDelivery } from "../examples/at-least-once/sink.js" | ||
|
|
||
| let directory: string | undefined | ||
|
|
||
| afterEach(async () => { | ||
| if (directory) await rm(directory, { recursive: true, force: true }) | ||
| directory = undefined | ||
| }) | ||
|
|
||
| async function sinkPath(): Promise<string> { | ||
| directory = await mkdtemp(join(tmpdir(), "solid-objects-sink-")) | ||
| return join(directory, "sink.json") | ||
| } | ||
|
|
||
| describe("at-least-once sink", () => { | ||
| it("records every delivery when deduplication is off", async () => { | ||
| const path = await sinkPath() | ||
|
|
||
| const first = await recordDelivery({ | ||
| path, | ||
| effectId: "effect-1", | ||
| attempt: 1, | ||
| deduplicate: false, | ||
| }) | ||
| const second = await recordDelivery({ | ||
| path, | ||
| effectId: "effect-1", | ||
| attempt: 2, | ||
| deduplicate: false, | ||
| }) | ||
|
|
||
| expect(first).toEqual({ applied: true }) | ||
| expect(second).toEqual({ applied: true }) | ||
| expect((await readSink(path)).deliveries).toEqual([ | ||
| { effectId: "effect-1", attempt: 1 }, | ||
| { effectId: "effect-1", attempt: 2 }, | ||
| ]) | ||
| }) | ||
|
|
||
| it("applies a replayed effect id once when deduplication is on", async () => { | ||
| const path = await sinkPath() | ||
|
|
||
| const first = await recordDelivery({ | ||
| path, | ||
| effectId: "effect-1", | ||
| attempt: 1, | ||
| deduplicate: true, | ||
| }) | ||
| const replay = await recordDelivery({ | ||
| path, | ||
| effectId: "effect-1", | ||
| attempt: 2, | ||
| deduplicate: true, | ||
| }) | ||
|
|
||
| expect(first).toEqual({ applied: true }) | ||
| expect(replay).toEqual({ applied: false }) | ||
| expect((await readSink(path)).deliveries).toEqual([{ effectId: "effect-1", attempt: 1 }]) | ||
| }) | ||
|
|
||
| it("reads an empty sink where no file exists", async () => { | ||
| const path = await sinkPath() | ||
| expect(await readSink(path)).toEqual({ deliveries: [] }) | ||
| }) | ||
|
|
||
| it("refuses to read a damaged sink as an empty one", async () => { | ||
| const path = await sinkPath() | ||
| await writeFile(path, "{ deliveries: ") | ||
|
|
||
| await expect(readSink(path)).rejects.toThrow() | ||
| }) | ||
|
|
||
| it("refuses to read an unreadable sink as an empty one", async () => { | ||
| await sinkPath() | ||
|
|
||
| await expect(readSink(directory as string)).rejects.toThrow() | ||
| }) | ||
| }) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.