@smthrs/journal 0.0.0-stage → 1.0.0-rc.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 +149 -0
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/cjs/CallFact.d.ts +129 -0
- package/dist/cjs/CallFact.d.ts.map +1 -0
- package/dist/cjs/CallFact.js +106 -0
- package/dist/cjs/CallFact.js.map +7 -0
- package/dist/cjs/Consensus.d.ts +440 -0
- package/dist/cjs/Consensus.d.ts.map +1 -0
- package/dist/cjs/Consensus.js +251 -0
- package/dist/cjs/Consensus.js.map +7 -0
- package/dist/cjs/EngineEvent.d.ts +890 -0
- package/dist/cjs/EngineEvent.d.ts.map +1 -0
- package/dist/cjs/EngineEvent.js +383 -0
- package/dist/cjs/EngineEvent.js.map +7 -0
- package/dist/cjs/ExecutionFact.d.ts +216 -0
- package/dist/cjs/ExecutionFact.d.ts.map +1 -0
- package/dist/cjs/ExecutionFact.js +303 -0
- package/dist/cjs/ExecutionFact.js.map +7 -0
- package/dist/cjs/Journal.d.ts +617 -0
- package/dist/cjs/Journal.d.ts.map +1 -0
- package/dist/cjs/Journal.js +190 -0
- package/dist/cjs/Journal.js.map +7 -0
- package/dist/cjs/JournalEvent.d.ts +421 -0
- package/dist/cjs/JournalEvent.d.ts.map +1 -0
- package/dist/cjs/JournalEvent.js +281 -0
- package/dist/cjs/JournalEvent.js.map +7 -0
- package/dist/cjs/JournalGeneration.d.ts +34 -0
- package/dist/cjs/JournalGeneration.d.ts.map +1 -0
- package/dist/cjs/JournalGeneration.js +72 -0
- package/dist/cjs/JournalGeneration.js.map +7 -0
- package/dist/cjs/JournalMetrics.d.ts +72 -0
- package/dist/cjs/JournalMetrics.d.ts.map +1 -0
- package/dist/cjs/JournalMetrics.js +55 -0
- package/dist/cjs/JournalMetrics.js.map +7 -0
- package/dist/cjs/Migrations.d.ts +42 -0
- package/dist/cjs/Migrations.d.ts.map +1 -0
- package/dist/cjs/Migrations.js +58 -0
- package/dist/cjs/Migrations.js.map +7 -0
- package/dist/cjs/OwnerId.d.ts +40 -0
- package/dist/cjs/OwnerId.d.ts.map +1 -0
- package/dist/cjs/OwnerId.js +40 -0
- package/dist/cjs/OwnerId.js.map +7 -0
- package/dist/cjs/Projection.d.ts +29 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +25 -0
- package/dist/cjs/Projection.js.map +7 -0
- package/dist/cjs/RedactedLogger.d.ts +121 -0
- package/dist/cjs/RedactedLogger.d.ts.map +1 -0
- package/dist/cjs/RedactedLogger.js +235 -0
- package/dist/cjs/RedactedLogger.js.map +7 -0
- package/dist/cjs/Redaction.d.ts +350 -0
- package/dist/cjs/Redaction.d.ts.map +1 -0
- package/dist/cjs/Redaction.js +928 -0
- package/dist/cjs/Redaction.js.map +7 -0
- package/dist/cjs/SqlConsensus.d.ts +44 -0
- package/dist/cjs/SqlConsensus.d.ts.map +1 -0
- package/dist/cjs/SqlConsensus.js +243 -0
- package/dist/cjs/SqlConsensus.js.map +7 -0
- package/dist/cjs/SqlJournal.d.ts +173 -0
- package/dist/cjs/SqlJournal.d.ts.map +1 -0
- package/dist/cjs/SqlJournal.js +1568 -0
- package/dist/cjs/SqlJournal.js.map +7 -0
- package/dist/cjs/StepFact.d.ts +66 -0
- package/dist/cjs/StepFact.d.ts.map +1 -0
- package/dist/cjs/StepFact.js +68 -0
- package/dist/cjs/StepFact.js.map +7 -0
- package/dist/cjs/index.d.ts +95 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +63 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/JournalInternalFault.d.ts +37 -0
- package/dist/cjs/internal/JournalInternalFault.d.ts.map +1 -0
- package/dist/cjs/internal/JournalInternalFault.js +43 -0
- package/dist/cjs/internal/JournalInternalFault.js.map +7 -0
- package/dist/cjs/migrations/0001_initial.d.ts +15 -0
- package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
- package/dist/cjs/migrations/0001_initial.js +54 -0
- package/dist/cjs/migrations/0001_initial.js.map +7 -0
- package/dist/cjs/migrations/0002_checkpoints.d.ts +27 -0
- package/dist/cjs/migrations/0002_checkpoints.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_checkpoints.js +48 -0
- package/dist/cjs/migrations/0002_checkpoints.js.map +7 -0
- package/dist/cjs/migrations/0003_startup_index.d.ts +15 -0
- package/dist/cjs/migrations/0003_startup_index.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_startup_index.js +40 -0
- package/dist/cjs/migrations/0003_startup_index.js.map +7 -0
- package/dist/cjs/migrations/0004_dedup.d.ts +15 -0
- package/dist/cjs/migrations/0004_dedup.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_dedup.js +57 -0
- package/dist/cjs/migrations/0004_dedup.js.map +7 -0
- package/dist/cjs/migrations/0005_run_event_type.d.ts +15 -0
- package/dist/cjs/migrations/0005_run_event_type.d.ts.map +1 -0
- package/dist/cjs/migrations/0005_run_event_type.js +40 -0
- package/dist/cjs/migrations/0005_run_event_type.js.map +7 -0
- package/dist/cjs/migrations/0006_consensus.d.ts +22 -0
- package/dist/cjs/migrations/0006_consensus.d.ts.map +1 -0
- package/dist/cjs/migrations/0006_consensus.js +84 -0
- package/dist/cjs/migrations/0006_consensus.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/Notifying.d.ts +65 -0
- package/dist/cjs/test/Notifying.d.ts.map +1 -0
- package/dist/cjs/test/Notifying.js +62 -0
- package/dist/cjs/test/Notifying.js.map +7 -0
- package/dist/cjs/test/TestJournal.d.ts +43 -0
- package/dist/cjs/test/TestJournal.d.ts.map +1 -0
- package/dist/cjs/test/TestJournal.js +43 -0
- package/dist/cjs/test/TestJournal.js.map +7 -0
- package/dist/esm/CallFact.d.ts +129 -0
- package/dist/esm/CallFact.d.ts.map +1 -0
- package/dist/esm/CallFact.js +96 -0
- package/dist/esm/CallFact.js.map +1 -0
- package/dist/esm/Consensus.d.ts +440 -0
- package/dist/esm/Consensus.d.ts.map +1 -0
- package/dist/esm/Consensus.js +454 -0
- package/dist/esm/Consensus.js.map +1 -0
- package/dist/esm/EngineEvent.d.ts +890 -0
- package/dist/esm/EngineEvent.d.ts.map +1 -0
- package/dist/esm/EngineEvent.js +614 -0
- package/dist/esm/EngineEvent.js.map +1 -0
- package/dist/esm/ExecutionFact.d.ts +216 -0
- package/dist/esm/ExecutionFact.d.ts.map +1 -0
- package/dist/esm/ExecutionFact.js +374 -0
- package/dist/esm/ExecutionFact.js.map +1 -0
- package/dist/esm/Journal.d.ts +617 -0
- package/dist/esm/Journal.d.ts.map +1 -0
- package/dist/esm/Journal.js +323 -0
- package/dist/esm/Journal.js.map +1 -0
- package/dist/esm/JournalEvent.d.ts +421 -0
- package/dist/esm/JournalEvent.d.ts.map +1 -0
- package/dist/esm/JournalEvent.js +517 -0
- package/dist/esm/JournalEvent.js.map +1 -0
- package/dist/esm/JournalGeneration.d.ts +34 -0
- package/dist/esm/JournalGeneration.d.ts.map +1 -0
- package/dist/esm/JournalGeneration.js +65 -0
- package/dist/esm/JournalGeneration.js.map +1 -0
- package/dist/esm/JournalMetrics.d.ts +72 -0
- package/dist/esm/JournalMetrics.d.ts.map +1 -0
- package/dist/esm/JournalMetrics.js +79 -0
- package/dist/esm/JournalMetrics.js.map +1 -0
- package/dist/esm/Migrations.d.ts +42 -0
- package/dist/esm/Migrations.d.ts.map +1 -0
- package/dist/esm/Migrations.js +59 -0
- package/dist/esm/Migrations.js.map +1 -0
- package/dist/esm/OwnerId.d.ts +40 -0
- package/dist/esm/OwnerId.d.ts.map +1 -0
- package/dist/esm/OwnerId.js +33 -0
- package/dist/esm/OwnerId.js.map +1 -0
- package/dist/esm/Projection.d.ts +29 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +13 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/RedactedLogger.d.ts +121 -0
- package/dist/esm/RedactedLogger.d.ts.map +1 -0
- package/dist/esm/RedactedLogger.js +445 -0
- package/dist/esm/RedactedLogger.js.map +1 -0
- package/dist/esm/Redaction.d.ts +350 -0
- package/dist/esm/Redaction.d.ts.map +1 -0
- package/dist/esm/Redaction.js +1584 -0
- package/dist/esm/Redaction.js.map +1 -0
- package/dist/esm/SqlConsensus.d.ts +44 -0
- package/dist/esm/SqlConsensus.d.ts.map +1 -0
- package/dist/esm/SqlConsensus.js +252 -0
- package/dist/esm/SqlConsensus.js.map +1 -0
- package/dist/esm/SqlJournal.d.ts +173 -0
- package/dist/esm/SqlJournal.d.ts.map +1 -0
- package/dist/esm/SqlJournal.js +1858 -0
- package/dist/esm/SqlJournal.js.map +1 -0
- package/dist/esm/StepFact.d.ts +66 -0
- package/dist/esm/StepFact.d.ts.map +1 -0
- package/dist/esm/StepFact.js +50 -0
- package/dist/esm/StepFact.js.map +1 -0
- package/dist/esm/index.d.ts +95 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +95 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/JournalInternalFault.d.ts +37 -0
- package/dist/esm/internal/JournalInternalFault.d.ts.map +1 -0
- package/dist/esm/internal/JournalInternalFault.js +28 -0
- package/dist/esm/internal/JournalInternalFault.js.map +1 -0
- package/dist/esm/migrations/0001_initial.d.ts +15 -0
- package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
- package/dist/esm/migrations/0001_initial.js +32 -0
- package/dist/esm/migrations/0001_initial.js.map +1 -0
- package/dist/esm/migrations/0002_checkpoints.d.ts +27 -0
- package/dist/esm/migrations/0002_checkpoints.d.ts.map +1 -0
- package/dist/esm/migrations/0002_checkpoints.js +38 -0
- package/dist/esm/migrations/0002_checkpoints.js.map +1 -0
- package/dist/esm/migrations/0003_startup_index.d.ts +15 -0
- package/dist/esm/migrations/0003_startup_index.d.ts.map +1 -0
- package/dist/esm/migrations/0003_startup_index.js +18 -0
- package/dist/esm/migrations/0003_startup_index.js.map +1 -0
- package/dist/esm/migrations/0004_dedup.d.ts +15 -0
- package/dist/esm/migrations/0004_dedup.d.ts.map +1 -0
- package/dist/esm/migrations/0004_dedup.js +38 -0
- package/dist/esm/migrations/0004_dedup.js.map +1 -0
- package/dist/esm/migrations/0005_run_event_type.d.ts +15 -0
- package/dist/esm/migrations/0005_run_event_type.d.ts.map +1 -0
- package/dist/esm/migrations/0005_run_event_type.js +18 -0
- package/dist/esm/migrations/0005_run_event_type.js.map +1 -0
- package/dist/esm/migrations/0006_consensus.d.ts +22 -0
- package/dist/esm/migrations/0006_consensus.d.ts.map +1 -0
- package/dist/esm/migrations/0006_consensus.js +69 -0
- package/dist/esm/migrations/0006_consensus.js.map +1 -0
- package/dist/esm/test/Notifying.d.ts +65 -0
- package/dist/esm/test/Notifying.d.ts.map +1 -0
- package/dist/esm/test/Notifying.js +62 -0
- package/dist/esm/test/Notifying.js.map +1 -0
- package/dist/esm/test/TestJournal.d.ts +43 -0
- package/dist/esm/test/TestJournal.d.ts.map +1 -0
- package/dist/esm/test/TestJournal.js +27 -0
- package/dist/esm/test/TestJournal.js.map +1 -0
- package/docs/README.md +164 -0
- package/docs/api.md +639 -0
- package/docs/concepts/compaction.md +157 -0
- package/docs/concepts/idempotency.md +127 -0
- package/docs/concepts/owner-fence.md +124 -0
- package/docs/concepts/redaction.md +218 -0
- package/docs/concepts/state-event-authority.md +159 -0
- package/docs/concepts/two-channels.md +167 -0
- package/docs/guides/commit-state-and-entry.md +114 -0
- package/docs/guides/compact-a-run.md +134 -0
- package/docs/guides/fold-a-projection.md +106 -0
- package/docs/guides/read-a-run.md +139 -0
- package/docs/guides/redact-log-output.md +94 -0
- package/docs/guides/testing.md +124 -0
- package/docs/guides/write-lifecycle-events.md +110 -0
- package/docs/installation.md +133 -0
- package/docs/quickstart.md +143 -0
- package/docs/troubleshooting.md +214 -0
- package/package.json +263 -3
- package/src/CallFact.ts +117 -0
- package/src/Consensus.ts +683 -0
- package/src/EngineEvent.ts +823 -0
- package/src/ExecutionFact.ts +433 -0
- package/src/Journal.ts +688 -0
- package/src/JournalEvent.ts +654 -0
- package/src/JournalGeneration.ts +77 -0
- package/src/JournalMetrics.ts +89 -0
- package/src/Migrations.ts +62 -0
- package/src/OwnerId.ts +42 -0
- package/src/Projection.ts +31 -0
- package/src/RedactedLogger.ts +483 -0
- package/src/Redaction.ts +1784 -0
- package/src/SqlConsensus.ts +344 -0
- package/src/SqlJournal.ts +2732 -0
- package/src/StepFact.ts +69 -0
- package/src/index.ts +109 -0
- package/src/internal/JournalInternalFault.ts +40 -0
- package/src/migrations/0001_initial.ts +41 -0
- package/src/migrations/0002_checkpoints.ts +46 -0
- package/src/migrations/0003_startup_index.ts +19 -0
- package/src/migrations/0004_dedup.ts +43 -0
- package/src/migrations/0005_run_event_type.ts +19 -0
- package/src/migrations/0006_consensus.ts +83 -0
- package/src/test/Notifying.ts +101 -0
- package/src/test/TestJournal.ts +52 -0
|
@@ -0,0 +1,1568 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var SqlJournal_exports = {};
|
|
30
|
+
__export(SqlJournal_exports, {
|
|
31
|
+
layer: () => layer,
|
|
32
|
+
layerWith: () => layerWith,
|
|
33
|
+
make: () => make
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(SqlJournal_exports);
|
|
36
|
+
var Dialect = __toESM(require("@smthrs/database/Dialect"), 1);
|
|
37
|
+
var import_DurableWriter = require("@smthrs/database/DurableWriter");
|
|
38
|
+
var Cause = __toESM(require("effect/Cause"), 1);
|
|
39
|
+
var Clock = __toESM(require("effect/Clock"), 1);
|
|
40
|
+
var Deferred = __toESM(require("effect/Deferred"), 1);
|
|
41
|
+
var Effect = __toESM(require("effect/Effect"), 1);
|
|
42
|
+
var Exit = __toESM(require("effect/Exit"), 1);
|
|
43
|
+
var Layer = __toESM(require("effect/Layer"), 1);
|
|
44
|
+
var Metric = __toESM(require("effect/Metric"), 1);
|
|
45
|
+
var Option = __toESM(require("effect/Option"), 1);
|
|
46
|
+
var PubSub = __toESM(require("effect/PubSub"), 1);
|
|
47
|
+
var Queue = __toESM(require("effect/Queue"), 1);
|
|
48
|
+
var Result = __toESM(require("effect/Result"), 1);
|
|
49
|
+
var Schema = __toESM(require("effect/Schema"), 1);
|
|
50
|
+
var Scope = __toESM(require("effect/Scope"), 1);
|
|
51
|
+
var Semaphore = __toESM(require("effect/Semaphore"), 1);
|
|
52
|
+
var Stream = __toESM(require("effect/Stream"), 1);
|
|
53
|
+
var SqlClient = __toESM(require("effect/unstable/sql/SqlClient"), 1);
|
|
54
|
+
var import_Consensus = require("./Consensus.js");
|
|
55
|
+
var import_JournalInternalFault = require("./internal/JournalInternalFault.js");
|
|
56
|
+
var import_Journal = require("./Journal.js");
|
|
57
|
+
var import_JournalEvent = require("./JournalEvent.js");
|
|
58
|
+
var JournalGeneration = __toESM(require("./JournalGeneration.js"), 1);
|
|
59
|
+
var JournalMetrics = __toESM(require("./JournalMetrics.js"), 1);
|
|
60
|
+
var import_OwnerId = require("./OwnerId.js");
|
|
61
|
+
var Redaction = __toESM(require("./Redaction.js"), 1);
|
|
62
|
+
var SqlConsensus = __toESM(require("./SqlConsensus.js"), 1);
|
|
63
|
+
const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
|
|
64
|
+
const defaultSourceEventCache = 4096;
|
|
65
|
+
const compactionCaptureTimeout = "30 seconds";
|
|
66
|
+
const error = (code, message, cause) => new import_Journal.JournalError({
|
|
67
|
+
code,
|
|
68
|
+
message,
|
|
69
|
+
...cause === void 0 ? {} : { cause }
|
|
70
|
+
});
|
|
71
|
+
const sourceKey = (runId, sourceId) => `${runId.length}:${runId}${sourceId.length}:${sourceId}`;
|
|
72
|
+
const sourceEventKey = (runId, sourceId, sourceSeq) => `${sourceKey(runId, sourceId)}:${sourceSeq}`;
|
|
73
|
+
const canonicalizeFingerprint = (value, depth) => {
|
|
74
|
+
if (depth > Redaction.maxDepth) {
|
|
75
|
+
throw new RangeError(`journal value nests deeper than ${Redaction.maxDepth} containers`);
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(value)) return value.map((element) => canonicalizeFingerprint(element, depth + 1));
|
|
78
|
+
if (value === null || typeof value !== "object") return value;
|
|
79
|
+
const record = value;
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.keys(record).sort().map((key) => [key, canonicalizeFingerprint(record[key], depth + 1)])
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
const encodeJson = (value, field) => Schema.encodeUnknownResult(UnknownFromJsonString)(value).pipe(
|
|
85
|
+
Result.mapError((cause) => error("invalid_event", `${field} must be JSON-serializable`, cause))
|
|
86
|
+
);
|
|
87
|
+
const encodeFingerprint = (value, field) => Result.flatMap(encodeJson(value, field), (json) => Result.try({
|
|
88
|
+
try: () => JSON.stringify(canonicalizeFingerprint(JSON.parse(json), 0)),
|
|
89
|
+
catch: (cause) => error("invalid_event", `${field} could not be canonicalized`, cause)
|
|
90
|
+
}));
|
|
91
|
+
const unfenced = { _tag: "Unfenced" };
|
|
92
|
+
const decodeOwner = Schema.decodeUnknownResult(import_OwnerId.OwnerId);
|
|
93
|
+
const requireFence = (owner, method) => Result.match(decodeOwner(owner), {
|
|
94
|
+
onFailure: (cause) => Result.fail(error("invalid_event", `${method} requires a well-formed owner fence`, cause)),
|
|
95
|
+
onSuccess: (value) => Result.succeed({ _tag: "Owned", owner: value })
|
|
96
|
+
});
|
|
97
|
+
const decodeInput = Schema.decodeUnknownResult(import_JournalEvent.Input);
|
|
98
|
+
const decodeEntry = Schema.decodeUnknownEffect(import_JournalEvent.Entry);
|
|
99
|
+
const decodeEntriesOptions = Schema.decodeUnknownResult(import_Journal.EntriesOptions);
|
|
100
|
+
const decodeStreamOptions = Schema.decodeUnknownResult(import_Journal.StreamOptions);
|
|
101
|
+
const decodeCheckpointOptions = Schema.decodeUnknownResult(import_Journal.CheckpointOptions);
|
|
102
|
+
const decodeCompactOptions = Schema.decodeUnknownResult(import_Journal.CompactOptions);
|
|
103
|
+
const decodeRunId = Schema.decodeUnknownResult(import_JournalEvent.RunId);
|
|
104
|
+
const decodeRow = (row) => Effect.all({
|
|
105
|
+
payload: Schema.decodeUnknownEffect(UnknownFromJsonString)(row.payload_json),
|
|
106
|
+
meta: Schema.decodeUnknownEffect(UnknownFromJsonString)(row.meta_json)
|
|
107
|
+
}).pipe(
|
|
108
|
+
Effect.map(({ meta, payload }) => ({
|
|
109
|
+
runId: row.run_id,
|
|
110
|
+
seq: Number(row.seq),
|
|
111
|
+
eventId: row.event_id,
|
|
112
|
+
sourceId: row.source_id,
|
|
113
|
+
sourceSeq: Number(row.source_seq),
|
|
114
|
+
emittedAtMs: Number(row.emitted_at_ms),
|
|
115
|
+
eventType: row.event_type,
|
|
116
|
+
payload,
|
|
117
|
+
meta
|
|
118
|
+
})),
|
|
119
|
+
Effect.flatMap(decodeEntry),
|
|
120
|
+
Effect.mapError((cause) => error("decode_failed", "could not decode a durable journal row", cause))
|
|
121
|
+
);
|
|
122
|
+
const decodeCheckpoint = Schema.decodeUnknownEffect(import_Journal.Checkpoint);
|
|
123
|
+
const decodeCheckpointRow = (row) => Schema.decodeUnknownEffect(UnknownFromJsonString)(row.state_json).pipe(
|
|
124
|
+
Effect.flatMap(
|
|
125
|
+
(state) => decodeCheckpoint({
|
|
126
|
+
runId: row.run_id,
|
|
127
|
+
seq: Number(row.seq),
|
|
128
|
+
state,
|
|
129
|
+
createdAtMs: Number(row.created_at_ms),
|
|
130
|
+
compactedAtMs: row.compacted_at_ms === null ? null : Number(row.compacted_at_ms)
|
|
131
|
+
})
|
|
132
|
+
),
|
|
133
|
+
Effect.mapError((cause) => error("decode_failed", "could not decode a durable checkpoint row", cause))
|
|
134
|
+
);
|
|
135
|
+
const freezePublished = (value) => {
|
|
136
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
137
|
+
const walk = (node) => {
|
|
138
|
+
if (node === null || typeof node !== "object") return;
|
|
139
|
+
if (seen.has(node)) return;
|
|
140
|
+
seen.add(node);
|
|
141
|
+
for (const key of Reflect.ownKeys(node)) {
|
|
142
|
+
walk(node[key]);
|
|
143
|
+
}
|
|
144
|
+
Object.freeze(node);
|
|
145
|
+
};
|
|
146
|
+
walk(value);
|
|
147
|
+
return value;
|
|
148
|
+
};
|
|
149
|
+
const encodedBytes = (encoder, payloadJson, metaJson) => encoder.encode(payloadJson).length + encoder.encode(metaJson).length;
|
|
150
|
+
const validateOptions = (options) => Effect.suspend(() => {
|
|
151
|
+
if (!Number.isSafeInteger(options.capacity) || options.capacity <= 0) {
|
|
152
|
+
return Effect.fail(error("invalid_event", "capacity must be a positive safe integer"));
|
|
153
|
+
}
|
|
154
|
+
const batchSize = options.batchSize ?? Math.min(options.capacity, 64);
|
|
155
|
+
if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
|
|
156
|
+
return Effect.fail(error("invalid_event", "batchSize must be a positive safe integer"));
|
|
157
|
+
}
|
|
158
|
+
const sourceEventCache = options.sourceEventCache ?? defaultSourceEventCache;
|
|
159
|
+
if (!Number.isSafeInteger(sourceEventCache) || sourceEventCache <= 0) {
|
|
160
|
+
return Effect.fail(error("invalid_event", "sourceEventCache must be a positive safe integer"));
|
|
161
|
+
}
|
|
162
|
+
if (options.maxEntryBytes !== void 0 && (!Number.isSafeInteger(options.maxEntryBytes) || options.maxEntryBytes <= 0)) {
|
|
163
|
+
return Effect.fail(error("invalid_event", "maxEntryBytes must be a positive safe integer"));
|
|
164
|
+
}
|
|
165
|
+
if (options.compaction !== void 0 && (!Number.isSafeInteger(options.compaction.entryThreshold) || options.compaction.entryThreshold <= 0)) {
|
|
166
|
+
return Effect.fail(error("invalid_event", "compaction.entryThreshold must be a positive safe integer"));
|
|
167
|
+
}
|
|
168
|
+
return Effect.succeed({
|
|
169
|
+
batchSize,
|
|
170
|
+
sourceEventCache,
|
|
171
|
+
maxEntryBytes: options.maxEntryBytes,
|
|
172
|
+
redact: options.redact ?? Redaction.make()
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
const isJournalError = Schema.is(import_Journal.JournalError);
|
|
176
|
+
const make = (options) => Effect.gen(function* () {
|
|
177
|
+
const { batchSize, maxEntryBytes, redact, sourceEventCache } = yield* validateOptions(options);
|
|
178
|
+
const readPageSize = Math.min(batchSize, import_Journal.maxEntriesLimit);
|
|
179
|
+
const sql = yield* Effect.service(SqlClient.SqlClient);
|
|
180
|
+
const writer = yield* import_DurableWriter.DurableWriter;
|
|
181
|
+
const consensus = yield* import_Consensus.Consensus;
|
|
182
|
+
yield* JournalGeneration.initialize.pipe(
|
|
183
|
+
Effect.mapError((cause) => error("read_failed", "could not initialize journal generations", cause))
|
|
184
|
+
);
|
|
185
|
+
const byteCounter = new TextEncoder();
|
|
186
|
+
const contentFingerprint = (eventType, payloadJson, metaJson) => Effect.tryPromise({
|
|
187
|
+
try: () => crypto.subtle.digest("SHA-256", byteCounter.encode(JSON.stringify([eventType, payloadJson, metaJson]))),
|
|
188
|
+
catch: (cause) => error("sink_failed", "could not fingerprint journal content", cause)
|
|
189
|
+
}).pipe(
|
|
190
|
+
Effect.map(
|
|
191
|
+
(digest) => Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
192
|
+
)
|
|
193
|
+
);
|
|
194
|
+
const queue = yield* Queue.dropping(options.capacity);
|
|
195
|
+
const changes = yield* PubSub.sliding(options.capacity);
|
|
196
|
+
const wakes = /* @__PURE__ */ new Map();
|
|
197
|
+
const readers = /* @__PURE__ */ new Map();
|
|
198
|
+
const sourceEventRows = yield* sql`
|
|
199
|
+
SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
200
|
+
event_type, payload_json, meta_json
|
|
201
|
+
FROM flows_journal_events
|
|
202
|
+
ORDER BY emitted_at_ms DESC, run_id DESC, seq DESC
|
|
203
|
+
LIMIT ${sourceEventCache}
|
|
204
|
+
`.pipe(
|
|
205
|
+
Effect.mapError((cause) => error("sink_failed", "could not initialize journal source events", cause))
|
|
206
|
+
);
|
|
207
|
+
const durableEntries = yield* Effect.forEach(sourceEventRows, decodeRow);
|
|
208
|
+
const initialized = yield* Effect.fromResult(
|
|
209
|
+
Result.gen(function* () {
|
|
210
|
+
const sequences = /* @__PURE__ */ new Map();
|
|
211
|
+
const sourceSequences = /* @__PURE__ */ new Map();
|
|
212
|
+
const sourceEvents = /* @__PURE__ */ new Map();
|
|
213
|
+
for (const entry of [...durableEntries].reverse()) {
|
|
214
|
+
if (!Number.isSafeInteger(entry.seq) || !Number.isSafeInteger(entry.sourceSeq) || entry.seq === Number.MAX_SAFE_INTEGER || entry.sourceSeq === Number.MAX_SAFE_INTEGER) {
|
|
215
|
+
return yield* Result.fail(
|
|
216
|
+
error("decode_failed", "durable event sequence is outside the allocatable safe integer range")
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
sourceEvents.set(sourceEventKey(entry.runId, entry.sourceId, entry.sourceSeq), {
|
|
220
|
+
seq: entry.seq,
|
|
221
|
+
eventType: entry.eventType,
|
|
222
|
+
payloadJson: yield* encodeFingerprint(entry.payload, "payload"),
|
|
223
|
+
metaJson: yield* encodeFingerprint(entry.meta, "meta"),
|
|
224
|
+
status: "committed"
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
sequences,
|
|
229
|
+
sourceSequences,
|
|
230
|
+
sourceEvents
|
|
231
|
+
};
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
const state = {
|
|
235
|
+
status: "open",
|
|
236
|
+
sinkFailure: void 0,
|
|
237
|
+
lossEpoch: 0,
|
|
238
|
+
flushedLossEpoch: 0,
|
|
239
|
+
pending: 0,
|
|
240
|
+
pendingByRun: /* @__PURE__ */ new Map(),
|
|
241
|
+
sequences: initialized.sequences,
|
|
242
|
+
sourceSequences: initialized.sourceSequences,
|
|
243
|
+
sourceEvents: initialized.sourceEvents,
|
|
244
|
+
flushWaiters: /* @__PURE__ */ new Set()
|
|
245
|
+
};
|
|
246
|
+
const allocation = yield* Semaphore.make(1);
|
|
247
|
+
const maintenanceScope = yield* Scope.make();
|
|
248
|
+
yield* Effect.addFinalizer((exit) => Scope.close(maintenanceScope, exit));
|
|
249
|
+
let pendingMaintenance = 0;
|
|
250
|
+
const compactionCounts = /* @__PURE__ */ new Map();
|
|
251
|
+
const compactingRuns = /* @__PURE__ */ new Set();
|
|
252
|
+
const runBarriers = /* @__PURE__ */ new Map();
|
|
253
|
+
const runDrainWaiters = /* @__PURE__ */ new Map();
|
|
254
|
+
const activeWritesByRun = /* @__PURE__ */ new Map();
|
|
255
|
+
const barrierFor = (runId) => {
|
|
256
|
+
const existing = runBarriers.get(runId);
|
|
257
|
+
if (existing !== void 0) return existing;
|
|
258
|
+
const created = {
|
|
259
|
+
semaphore: Semaphore.makeUnsafe(1),
|
|
260
|
+
compactionLock: Semaphore.makeUnsafe(1),
|
|
261
|
+
maintenance: Semaphore.makeUnsafe(1),
|
|
262
|
+
users: 0,
|
|
263
|
+
compaction: void 0
|
|
264
|
+
};
|
|
265
|
+
runBarriers.set(runId, created);
|
|
266
|
+
return created;
|
|
267
|
+
};
|
|
268
|
+
const withRunBarrier = (runId, use) => Effect.suspend(() => {
|
|
269
|
+
const barrier = barrierFor(runId);
|
|
270
|
+
barrier.users += 1;
|
|
271
|
+
return use(barrier).pipe(Effect.ensuring(Effect.sync(() => {
|
|
272
|
+
barrier.users -= 1;
|
|
273
|
+
})));
|
|
274
|
+
});
|
|
275
|
+
const retireQuiescentRuns = () => {
|
|
276
|
+
for (const [runId, barrier] of runBarriers) {
|
|
277
|
+
if (barrier.users > 0 || barrier.compaction !== void 0 || state.pendingByRun.has(runId) || activeWritesByRun.has(runId) || runDrainWaiters.has(runId) || readers.has(runId) || wakes.has(runId) || compactingRuns.has(runId)) continue;
|
|
278
|
+
runBarriers.delete(runId);
|
|
279
|
+
compactionCounts.delete(runId);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const withRunAdmission = (runId, effect) => withRunBarrier(runId, (barrier) => barrier.semaphore.withPermit(
|
|
283
|
+
Effect.suspend(() => {
|
|
284
|
+
const gate = barrier.compaction;
|
|
285
|
+
return gate === void 0 ? Effect.map(effect, (value) => ({ _tag: "Done", value })) : Effect.succeed({ _tag: "Wait", gate });
|
|
286
|
+
})
|
|
287
|
+
).pipe(
|
|
288
|
+
Effect.flatMap(
|
|
289
|
+
(attempt) => attempt._tag === "Done" ? Effect.succeed(attempt.value) : Deferred.await(attempt.gate).pipe(
|
|
290
|
+
Effect.andThen(withRunAdmission(runId, effect))
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
));
|
|
294
|
+
const withRunPermits = (runIds, effect) => {
|
|
295
|
+
const ordered = [...new Set(runIds)].sort();
|
|
296
|
+
const acquire = (index) => index === ordered.length ? effect : withRunBarrier(ordered[index], (barrier) => barrier.semaphore.withPermit(acquire(index + 1)));
|
|
297
|
+
return acquire(0);
|
|
298
|
+
};
|
|
299
|
+
const admitRunPending = (queued) => {
|
|
300
|
+
const pending = state.pendingByRun.get(queued.runId) ?? /* @__PURE__ */ new Set();
|
|
301
|
+
pending.add(queued);
|
|
302
|
+
state.pendingByRun.set(queued.runId, pending);
|
|
303
|
+
};
|
|
304
|
+
const completeRunDrain = (runId) => {
|
|
305
|
+
if ((state.pendingByRun.get(runId)?.size ?? 0) + (activeWritesByRun.get(runId) ?? 0) > 0) return;
|
|
306
|
+
const waiters = runDrainWaiters.get(runId);
|
|
307
|
+
runDrainWaiters.delete(runId);
|
|
308
|
+
for (const waiter of waiters ?? []) {
|
|
309
|
+
Deferred.doneUnsafe(waiter, Effect.void);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
const settleRunPending = (batch) => {
|
|
313
|
+
for (const queued of batch) {
|
|
314
|
+
const pending = state.pendingByRun.get(queued.runId);
|
|
315
|
+
pending.delete(queued);
|
|
316
|
+
if (pending.size > 0) continue;
|
|
317
|
+
state.pendingByRun.delete(queued.runId);
|
|
318
|
+
completeRunDrain(queued.runId);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const endRunWrite = (runId) => {
|
|
322
|
+
const remaining = activeWritesByRun.get(runId) - 1;
|
|
323
|
+
if (remaining > 0) {
|
|
324
|
+
activeWritesByRun.set(runId, remaining);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
activeWritesByRun.delete(runId);
|
|
328
|
+
completeRunDrain(runId);
|
|
329
|
+
};
|
|
330
|
+
const withActiveRunWrite = (runId, effect) => withRunBarrier(runId, () => Effect.suspend(() => {
|
|
331
|
+
activeWritesByRun.set(runId, (activeWritesByRun.get(runId) ?? 0) + 1);
|
|
332
|
+
return effect.pipe(Effect.ensuring(Effect.sync(() => endRunWrite(runId))));
|
|
333
|
+
}));
|
|
334
|
+
const awaitRunDrained = (runId) => Effect.suspend(() => {
|
|
335
|
+
if ((state.pendingByRun.get(runId)?.size ?? 0) + (activeWritesByRun.get(runId) ?? 0) === 0) {
|
|
336
|
+
return Effect.void;
|
|
337
|
+
}
|
|
338
|
+
const waiter = Deferred.makeUnsafe();
|
|
339
|
+
const waiters = runDrainWaiters.get(runId) ?? /* @__PURE__ */ new Set();
|
|
340
|
+
waiters.add(waiter);
|
|
341
|
+
runDrainWaiters.set(runId, waiters);
|
|
342
|
+
return Deferred.await(waiter);
|
|
343
|
+
});
|
|
344
|
+
const withCompactionBarrier = (runId, effect) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (enclosing) => withRunBarrier(runId, (barrier) => {
|
|
345
|
+
const nested = Option.isSome(enclosing);
|
|
346
|
+
const attempt = (nested ? Effect.void : awaitRunDrained(runId)).pipe(
|
|
347
|
+
Effect.andThen(writer.write(
|
|
348
|
+
barrier.compactionLock.withPermit(Effect.uninterruptibleMask(
|
|
349
|
+
(restore) => barrier.semaphore.withPermit(Effect.sync(() => {
|
|
350
|
+
if (!nested && (state.pendingByRun.get(runId)?.size ?? 0) + (activeWritesByRun.get(runId) ?? 0) > 0) {
|
|
351
|
+
return void 0;
|
|
352
|
+
}
|
|
353
|
+
const gate = Deferred.makeUnsafe();
|
|
354
|
+
barrier.compaction = gate;
|
|
355
|
+
return gate;
|
|
356
|
+
})).pipe(Effect.flatMap(
|
|
357
|
+
(gate) => gate === void 0 ? Effect.succeed(Option.none()) : restore(Effect.gen(function* () {
|
|
358
|
+
if (nested) {
|
|
359
|
+
const outcome = yield* insertBatch([...state.pendingByRun.get(runId) ?? []]);
|
|
360
|
+
for (const { queued, commit } of outcome.commits) {
|
|
361
|
+
yield* settleCommit(queued, commit, restore);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return yield* Effect.asSome(effect);
|
|
365
|
+
})).pipe(Effect.ensuring(Effect.sync(() => {
|
|
366
|
+
barrier.compaction = void 0;
|
|
367
|
+
Deferred.doneUnsafe(gate, Effect.void);
|
|
368
|
+
})))
|
|
369
|
+
))
|
|
370
|
+
))
|
|
371
|
+
)),
|
|
372
|
+
Effect.flatMap(
|
|
373
|
+
(result) => Option.isSome(result) ? Effect.succeed(result.value) : attempt
|
|
374
|
+
)
|
|
375
|
+
);
|
|
376
|
+
return attempt;
|
|
377
|
+
}));
|
|
378
|
+
const raiseSequenceFloor = (runId, seq) => {
|
|
379
|
+
state.sequences.set(runId, Math.max(state.sequences.get(runId) ?? 0, seq + 1));
|
|
380
|
+
};
|
|
381
|
+
const raiseSourceSequenceFloor = (runId, sourceId, sourceSeq) => {
|
|
382
|
+
const key = sourceKey(runId, sourceId);
|
|
383
|
+
state.sourceSequences.set(key, Math.max(state.sourceSequences.get(key) ?? 0, sourceSeq + 1));
|
|
384
|
+
};
|
|
385
|
+
const retain = (identity, event) => {
|
|
386
|
+
state.sourceEvents.delete(identity);
|
|
387
|
+
state.sourceEvents.set(identity, event);
|
|
388
|
+
if (state.sourceEvents.size <= sourceEventCache) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
for (const [candidate, retained] of state.sourceEvents) {
|
|
392
|
+
if (retained.status !== "committed" || candidate === identity) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
state.sourceEvents.delete(candidate);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
const completeFlushWaiters = (exit) => {
|
|
400
|
+
const waiters = Array.from(state.flushWaiters);
|
|
401
|
+
state.flushWaiters.clear();
|
|
402
|
+
for (const waiter of waiters) {
|
|
403
|
+
Deferred.doneUnsafe(waiter, exit);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const flushInternal = Effect.suspend(() => {
|
|
407
|
+
if (state.sinkFailure !== void 0 && state.lossEpoch > state.flushedLossEpoch) {
|
|
408
|
+
state.flushedLossEpoch = state.lossEpoch;
|
|
409
|
+
return Effect.fail(state.sinkFailure);
|
|
410
|
+
}
|
|
411
|
+
if (state.status === "closed") {
|
|
412
|
+
return Effect.fail(error("journal_closed", "journal is closed"));
|
|
413
|
+
}
|
|
414
|
+
if (state.pending === 0 && pendingMaintenance === 0) {
|
|
415
|
+
return Effect.void;
|
|
416
|
+
}
|
|
417
|
+
const waiter = Deferred.makeUnsafe();
|
|
418
|
+
state.flushWaiters.add(waiter);
|
|
419
|
+
return Deferred.await(waiter).pipe(
|
|
420
|
+
Effect.ensuring(Effect.sync(() => {
|
|
421
|
+
state.flushWaiters.delete(waiter);
|
|
422
|
+
}))
|
|
423
|
+
);
|
|
424
|
+
}).pipe(Effect.ensuring(Effect.sync(retireQuiescentRuns)));
|
|
425
|
+
const prepare = (input, emittedAtMs) => Result.gen(function* () {
|
|
426
|
+
if (state.status !== "open") {
|
|
427
|
+
return yield* Result.fail(error("journal_closed", "journal is closed"));
|
|
428
|
+
}
|
|
429
|
+
const validated = yield* Result.mapError(
|
|
430
|
+
decodeInput(input),
|
|
431
|
+
(cause) => error("invalid_event", "event violates the journal input contract", cause)
|
|
432
|
+
);
|
|
433
|
+
if (!Number.isSafeInteger(emittedAtMs) || emittedAtMs < 0) {
|
|
434
|
+
return yield* Result.fail(
|
|
435
|
+
error("invalid_event", "emittedAtMs must be a non-negative safe integer")
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
const redactedPayload = yield* Result.try({
|
|
439
|
+
try: () => redact(validated.payload),
|
|
440
|
+
catch: () => error("invalid_event", "payload could not be redacted")
|
|
441
|
+
});
|
|
442
|
+
const redactedMeta = yield* Result.try({
|
|
443
|
+
try: () => redact(validated.meta ?? null),
|
|
444
|
+
catch: () => error("invalid_event", "meta could not be redacted")
|
|
445
|
+
});
|
|
446
|
+
const payloadJson = yield* encodeFingerprint(redactedPayload, "payload");
|
|
447
|
+
const metaJson = yield* encodeFingerprint(redactedMeta, "meta");
|
|
448
|
+
if (maxEntryBytes !== void 0) {
|
|
449
|
+
const bytes = encodedBytes(byteCounter, payloadJson, metaJson);
|
|
450
|
+
if (bytes > maxEntryBytes) {
|
|
451
|
+
return yield* Result.fail(
|
|
452
|
+
error(
|
|
453
|
+
"invalid_event",
|
|
454
|
+
`event is ${bytes} bytes, over the ${maxEntryBytes}-byte maxEntryBytes bound`
|
|
455
|
+
)
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return { validated, payloadJson, metaJson };
|
|
460
|
+
});
|
|
461
|
+
const compareSourceEvent = (prepared, sourceSeq, existing) => {
|
|
462
|
+
const { metaJson, payloadJson, validated } = prepared;
|
|
463
|
+
if (validated.dedupe !== "identity" && (existing.eventType !== validated.eventType || existing.payloadJson !== payloadJson || existing.metaJson !== metaJson)) {
|
|
464
|
+
return Result.fail(error(
|
|
465
|
+
"idempotency_conflict",
|
|
466
|
+
`source event ${validated.sourceId}:${sourceSeq} for run ${validated.runId} was reused with different content`
|
|
467
|
+
));
|
|
468
|
+
}
|
|
469
|
+
return Result.succeed({
|
|
470
|
+
_tag: "Duplicate",
|
|
471
|
+
seq: existing.seq,
|
|
472
|
+
sourceSeq,
|
|
473
|
+
status: existing.status
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
const admitFromIndex = (prepared) => {
|
|
477
|
+
const { validated } = prepared;
|
|
478
|
+
const sourceSeq = validated.sourceSeq;
|
|
479
|
+
if (sourceSeq === void 0) return Result.succeed(void 0);
|
|
480
|
+
if (!Number.isSafeInteger(sourceSeq) || sourceSeq < 0 || sourceSeq === Number.MAX_SAFE_INTEGER) {
|
|
481
|
+
return Result.fail(
|
|
482
|
+
error("invalid_event", "journal sequence is outside the allocatable safe integer range")
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const cached = state.sourceEvents.get(sourceEventKey(validated.runId, validated.sourceId, sourceSeq));
|
|
486
|
+
return cached === void 0 ? Result.succeed(void 0) : compareSourceEvent(prepared, sourceSeq, cached);
|
|
487
|
+
};
|
|
488
|
+
const admitQueued = (prepared, floors, emittedAtMs) => Effect.suspend(() => {
|
|
489
|
+
if (state.status !== "open") {
|
|
490
|
+
return Effect.fail(error("journal_closed", "journal is closed"));
|
|
491
|
+
}
|
|
492
|
+
return Effect.flatMap(
|
|
493
|
+
Effect.fromResult(admitFromIndex(prepared)),
|
|
494
|
+
(indexed) => indexed !== void 0 ? Effect.succeed(indexed) : allocation.withPermit(Effect.fromResult(
|
|
495
|
+
Result.gen(function* () {
|
|
496
|
+
const { metaJson, payloadJson, validated } = prepared;
|
|
497
|
+
const key = sourceKey(validated.runId, validated.sourceId);
|
|
498
|
+
const nextSourceSeq = Math.max(
|
|
499
|
+
floors.sourceSeq,
|
|
500
|
+
state.sourceSequences.get(key) ?? 0
|
|
501
|
+
);
|
|
502
|
+
const sourceSeq = validated.sourceSeq ?? nextSourceSeq;
|
|
503
|
+
if (sourceSeq === Number.MAX_SAFE_INTEGER) {
|
|
504
|
+
return yield* Result.fail(
|
|
505
|
+
error(
|
|
506
|
+
"invalid_event",
|
|
507
|
+
"journal sequence is outside the allocatable safe integer range"
|
|
508
|
+
)
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const nextSeq = Math.max(
|
|
512
|
+
floors.seq,
|
|
513
|
+
state.sequences.get(validated.runId) ?? 0
|
|
514
|
+
);
|
|
515
|
+
if (nextSeq === Number.MAX_SAFE_INTEGER) {
|
|
516
|
+
return yield* Result.fail(
|
|
517
|
+
error(
|
|
518
|
+
"invalid_event",
|
|
519
|
+
"journal sequence is outside the allocatable safe integer range"
|
|
520
|
+
)
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const seq = nextSeq;
|
|
524
|
+
raiseSequenceFloor(validated.runId, seq);
|
|
525
|
+
state.sourceSequences.set(key, Math.max(nextSourceSeq, sourceSeq + 1));
|
|
526
|
+
const queued = {
|
|
527
|
+
runId: validated.runId,
|
|
528
|
+
seq,
|
|
529
|
+
eventId: (0, import_JournalEvent.makeEventId)(validated.runId, validated.sourceId, sourceSeq),
|
|
530
|
+
sourceId: validated.sourceId,
|
|
531
|
+
sourceSeq,
|
|
532
|
+
emittedAtMs,
|
|
533
|
+
eventType: validated.eventType,
|
|
534
|
+
payloadJson,
|
|
535
|
+
metaJson,
|
|
536
|
+
dedupe: validated.dedupe ?? "content"
|
|
537
|
+
};
|
|
538
|
+
let evicted;
|
|
539
|
+
if (Queue.sizeUnsafe(queue) >= options.capacity && options.overflow === "drop-oldest") {
|
|
540
|
+
const exit = Queue.takeUnsafe(queue);
|
|
541
|
+
if (exit === void 0 || !Exit.isSuccess(exit)) {
|
|
542
|
+
return yield* Result.fail(
|
|
543
|
+
error("journal_closed", "journal admission queue is unavailable")
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
evicted = exit.value;
|
|
547
|
+
const evictedIdentity = sourceEventKey(
|
|
548
|
+
evicted.runId,
|
|
549
|
+
evicted.sourceId,
|
|
550
|
+
evicted.sourceSeq
|
|
551
|
+
);
|
|
552
|
+
state.sourceEvents.delete(evictedIdentity);
|
|
553
|
+
state.pending = Math.max(0, state.pending - 1);
|
|
554
|
+
settleRunPending([evicted]);
|
|
555
|
+
}
|
|
556
|
+
const accepted = Queue.offerUnsafe(queue, queued);
|
|
557
|
+
if (!accepted) {
|
|
558
|
+
if (options.overflow === "reject") {
|
|
559
|
+
return yield* Result.fail(
|
|
560
|
+
error("queue_overflow", "journal admission queue is full")
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
_tag: "Dropped",
|
|
565
|
+
seq,
|
|
566
|
+
sourceSeq,
|
|
567
|
+
policy: "drop-newest"
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
retain(sourceEventKey(validated.runId, validated.sourceId, sourceSeq), {
|
|
571
|
+
seq,
|
|
572
|
+
eventType: validated.eventType,
|
|
573
|
+
payloadJson,
|
|
574
|
+
metaJson,
|
|
575
|
+
status: "pending"
|
|
576
|
+
});
|
|
577
|
+
state.pending += 1;
|
|
578
|
+
admitRunPending(queued);
|
|
579
|
+
if (evicted !== void 0) {
|
|
580
|
+
return {
|
|
581
|
+
_tag: "Accepted",
|
|
582
|
+
seq,
|
|
583
|
+
sourceSeq,
|
|
584
|
+
evicted: {
|
|
585
|
+
policy: "drop-oldest",
|
|
586
|
+
count: 1
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
_tag: "Accepted",
|
|
592
|
+
seq,
|
|
593
|
+
sourceSeq
|
|
594
|
+
};
|
|
595
|
+
})
|
|
596
|
+
))
|
|
597
|
+
);
|
|
598
|
+
});
|
|
599
|
+
const queuedEmit = Effect.fn("Journal.emitLossy")(
|
|
600
|
+
(input) => Effect.annotateCurrentSpan({
|
|
601
|
+
runId: input.runId,
|
|
602
|
+
sourceId: input.sourceId,
|
|
603
|
+
eventType: input.eventType
|
|
604
|
+
}).pipe(
|
|
605
|
+
Effect.andThen(Clock.currentTimeMillis.pipe(Effect.map(Math.floor))),
|
|
606
|
+
Effect.flatMap(
|
|
607
|
+
(emittedAtMs) => Effect.flatMap(
|
|
608
|
+
withRunAdmission(
|
|
609
|
+
input.runId,
|
|
610
|
+
Effect.suspend(
|
|
611
|
+
() => Effect.fromResult(Result.gen(function* () {
|
|
612
|
+
const prepared = yield* prepare(input, emittedAtMs);
|
|
613
|
+
const indexed = yield* admitFromIndex(prepared);
|
|
614
|
+
return { prepared, indexed };
|
|
615
|
+
}))
|
|
616
|
+
)
|
|
617
|
+
),
|
|
618
|
+
({ prepared, indexed }) => (
|
|
619
|
+
// A cached duplicate needs neither allocation floor, including
|
|
620
|
+
// after startup when the identity index is warm but floors are
|
|
621
|
+
// cold. On a miss, release the permit before reading SQL, then
|
|
622
|
+
// recheck identity and allocation under a fresh admission.
|
|
623
|
+
indexed !== void 0 ? Effect.succeed(indexed) : Effect.flatMap(
|
|
624
|
+
ensureFloors(prepared.validated),
|
|
625
|
+
(floors) => withRunAdmission(prepared.validated.runId, admitQueued(prepared, floors, emittedAtMs))
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
),
|
|
630
|
+
Effect.tap((receipt) => Metric.update(JournalMetrics.lossy[receipt._tag], 1))
|
|
631
|
+
)
|
|
632
|
+
);
|
|
633
|
+
const compactionFloor = (runId) => Effect.map(
|
|
634
|
+
sql`
|
|
635
|
+
SELECT MAX(seq) AS floor FROM flows_journal_checkpoints
|
|
636
|
+
WHERE run_id = ${runId} AND compacted_at_ms IS NOT NULL
|
|
637
|
+
`,
|
|
638
|
+
(rows) => {
|
|
639
|
+
const floor = rows[0]?.floor;
|
|
640
|
+
return floor === null || floor === void 0 ? void 0 : Number(floor);
|
|
641
|
+
}
|
|
642
|
+
);
|
|
643
|
+
const readPage = Effect.fn("Journal.entries")(
|
|
644
|
+
(pageOptions) => Effect.gen(function* () {
|
|
645
|
+
const options2 = {
|
|
646
|
+
...pageOptions,
|
|
647
|
+
...Array.isArray(pageOptions.eventTypes) ? { eventTypes: [...pageOptions.eventTypes] } : {}
|
|
648
|
+
};
|
|
649
|
+
yield* Effect.annotateCurrentSpan({
|
|
650
|
+
runId: options2.runId,
|
|
651
|
+
limit: options2.limit,
|
|
652
|
+
...options2.eventTypes === void 0 ? {} : { eventTypes: options2.eventTypes },
|
|
653
|
+
...options2.after === void 0 ? {} : { after: options2.after }
|
|
654
|
+
});
|
|
655
|
+
yield* Effect.fromResult(Result.mapError(
|
|
656
|
+
decodeEntriesOptions(options2),
|
|
657
|
+
(cause) => error("invalid_event", "entries options violate the journal read contract", cause)
|
|
658
|
+
));
|
|
659
|
+
const after = options2.after ?? -1;
|
|
660
|
+
const rows = yield* (options2.eventTypes === void 0 ? sql`
|
|
661
|
+
SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
662
|
+
event_type, payload_json, meta_json
|
|
663
|
+
FROM flows_journal_events
|
|
664
|
+
WHERE run_id = ${options2.runId} AND seq > ${after}
|
|
665
|
+
ORDER BY seq ASC
|
|
666
|
+
LIMIT ${options2.limit + 1}
|
|
667
|
+
` : sql`
|
|
668
|
+
SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
669
|
+
event_type, payload_json, meta_json
|
|
670
|
+
FROM flows_journal_events ${Dialect.indexHint(sql, "flows_journal_events_run_event_type_idx")}
|
|
671
|
+
WHERE run_id = ${options2.runId} AND ${sql.in("event_type", options2.eventTypes)} AND seq > ${after}
|
|
672
|
+
ORDER BY seq ASC
|
|
673
|
+
LIMIT ${options2.limit + 1}
|
|
674
|
+
`).pipe(
|
|
675
|
+
Effect.mapError((cause) => error("read_failed", "durable journal read failed", cause))
|
|
676
|
+
);
|
|
677
|
+
const floor = yield* compactionFloor(options2.runId).pipe(
|
|
678
|
+
Effect.mapError((cause) => error("read_failed", "durable journal read failed", cause))
|
|
679
|
+
);
|
|
680
|
+
if (floor !== void 0 && after < floor - 1) {
|
|
681
|
+
return yield* Effect.fail(
|
|
682
|
+
new import_Journal.JournalError({
|
|
683
|
+
code: "compacted",
|
|
684
|
+
message: `run ${options2.runId} is compacted through sequence ${floor}; resync from its checkpoint`,
|
|
685
|
+
checkpointSeq: floor
|
|
686
|
+
})
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const page = rows.slice(0, options2.limit);
|
|
690
|
+
const entries = yield* Effect.forEach(page, decodeRow);
|
|
691
|
+
return {
|
|
692
|
+
entries,
|
|
693
|
+
hasMore: rows.length > options2.limit
|
|
694
|
+
};
|
|
695
|
+
})
|
|
696
|
+
);
|
|
697
|
+
const subscribeRun = (runId) => Effect.gen(function* () {
|
|
698
|
+
const wake = yield* PubSub.sliding(1);
|
|
699
|
+
const subscription = yield* PubSub.subscribe(wake);
|
|
700
|
+
yield* Effect.acquireRelease(
|
|
701
|
+
Effect.sync(() => {
|
|
702
|
+
const subscribers = wakes.get(runId) ?? /* @__PURE__ */ new Set();
|
|
703
|
+
subscribers.add(wake);
|
|
704
|
+
wakes.set(runId, subscribers);
|
|
705
|
+
}),
|
|
706
|
+
() => Effect.sync(() => {
|
|
707
|
+
const subscribers = wakes.get(runId);
|
|
708
|
+
subscribers?.delete(wake);
|
|
709
|
+
if (subscribers?.size === 0) {
|
|
710
|
+
wakes.delete(runId);
|
|
711
|
+
}
|
|
712
|
+
}).pipe(Effect.andThen(PubSub.shutdown(wake)))
|
|
713
|
+
);
|
|
714
|
+
return subscription;
|
|
715
|
+
});
|
|
716
|
+
const stream = (streamOptions) => Stream.unwrap(
|
|
717
|
+
Effect.fn("Journal.stream")(function* () {
|
|
718
|
+
yield* Effect.annotateCurrentSpan({
|
|
719
|
+
runId: streamOptions.runId,
|
|
720
|
+
...streamOptions.afterSequence === void 0 ? {} : { afterSequence: streamOptions.afterSequence }
|
|
721
|
+
});
|
|
722
|
+
yield* Effect.fromResult(Result.mapError(
|
|
723
|
+
decodeStreamOptions(streamOptions),
|
|
724
|
+
(cause) => error("invalid_event", "stream options violate the journal read contract", cause)
|
|
725
|
+
));
|
|
726
|
+
const wake = yield* subscribeRun(streamOptions.runId);
|
|
727
|
+
const reader = { cursor: streamOptions.afterSequence ?? -1 };
|
|
728
|
+
yield* Effect.acquireRelease(
|
|
729
|
+
Effect.sync(() => {
|
|
730
|
+
const registered = readers.get(streamOptions.runId) ?? /* @__PURE__ */ new Set();
|
|
731
|
+
registered.add(reader);
|
|
732
|
+
readers.set(streamOptions.runId, registered);
|
|
733
|
+
}),
|
|
734
|
+
() => Effect.sync(() => {
|
|
735
|
+
const registered = readers.get(streamOptions.runId);
|
|
736
|
+
registered?.delete(reader);
|
|
737
|
+
if (registered?.size === 0) {
|
|
738
|
+
readers.delete(streamOptions.runId);
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
);
|
|
742
|
+
const subscribedLossEpoch = state.lossEpoch;
|
|
743
|
+
const readPages = (initialCursor) => Stream.paginate(initialCursor, (cursor) => Effect.suspend(
|
|
744
|
+
() => state.sinkFailure !== void 0 && state.lossEpoch > subscribedLossEpoch ? Effect.fail(state.sinkFailure) : readPage({
|
|
745
|
+
runId: streamOptions.runId,
|
|
746
|
+
...cursor < 0 ? {} : { after: cursor },
|
|
747
|
+
limit: readPageSize
|
|
748
|
+
})
|
|
749
|
+
).pipe(
|
|
750
|
+
Effect.map((page) => {
|
|
751
|
+
const last = page.entries.at(-1);
|
|
752
|
+
if (last !== void 0) {
|
|
753
|
+
reader.cursor = last.seq;
|
|
754
|
+
}
|
|
755
|
+
return [
|
|
756
|
+
page.entries,
|
|
757
|
+
page.hasMore ? Option.some(reader.cursor) : Option.none()
|
|
758
|
+
];
|
|
759
|
+
})
|
|
760
|
+
));
|
|
761
|
+
const historical = readPages(reader.cursor);
|
|
762
|
+
const live = Stream.fromEffectRepeat(
|
|
763
|
+
Effect.raceFirst(PubSub.take(wake), Effect.sleep("1 second"))
|
|
764
|
+
).pipe(Stream.flatMap(
|
|
765
|
+
() => readPages(reader.cursor)
|
|
766
|
+
));
|
|
767
|
+
return Stream.concat(historical, live);
|
|
768
|
+
})()
|
|
769
|
+
);
|
|
770
|
+
const fenceGuard = (runId, owner) => consensus.guard(runId, owner).pipe(
|
|
771
|
+
Effect.mapError(
|
|
772
|
+
(cause) => cause.code === "fence_lost" ? error("fence_lost", cause.message) : error("sink_failed", "consensus guard failed", cause)
|
|
773
|
+
)
|
|
774
|
+
);
|
|
775
|
+
const selectExisting = (queued) => Effect.gen(function* () {
|
|
776
|
+
const existing = yield* sql`
|
|
777
|
+
SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
778
|
+
event_type, payload_json, meta_json, NULL AS content_hash
|
|
779
|
+
FROM flows_journal_events
|
|
780
|
+
WHERE event_id = ${queued.eventId}
|
|
781
|
+
OR (
|
|
782
|
+
run_id = ${queued.runId}
|
|
783
|
+
AND source_id = ${queued.sourceId}
|
|
784
|
+
AND source_seq = ${queued.sourceSeq}
|
|
785
|
+
)
|
|
786
|
+
UNION ALL
|
|
787
|
+
SELECT run_id, seq, event_id, source_id, source_seq, 0 AS emitted_at_ms,
|
|
788
|
+
'' AS event_type, '' AS payload_json, '' AS meta_json, content_hash
|
|
789
|
+
FROM flows_journal_dedup
|
|
790
|
+
WHERE event_id = ${queued.eventId}
|
|
791
|
+
OR (
|
|
792
|
+
run_id = ${queued.runId}
|
|
793
|
+
AND source_id = ${queued.sourceId}
|
|
794
|
+
AND source_seq = ${queued.sourceSeq}
|
|
795
|
+
)
|
|
796
|
+
ORDER BY seq ASC
|
|
797
|
+
LIMIT 1
|
|
798
|
+
`;
|
|
799
|
+
if (existing.length === 0) {
|
|
800
|
+
return void 0;
|
|
801
|
+
}
|
|
802
|
+
const row = existing[0];
|
|
803
|
+
if (queued.dedupe !== "identity" && (row.content_hash === null ? row.event_type !== queued.eventType || row.payload_json !== queued.payloadJson || row.meta_json !== queued.metaJson : row.content_hash !== (yield* contentFingerprint(queued.eventType, queued.payloadJson, queued.metaJson)))) {
|
|
804
|
+
return yield* Effect.fail(
|
|
805
|
+
error(
|
|
806
|
+
"idempotency_conflict",
|
|
807
|
+
`source event ${queued.sourceId}:${queued.sourceSeq} for run ${queued.runId} was reused with different content`
|
|
808
|
+
)
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
entry: row.content_hash === null ? yield* decodeRow(row) : { seq: Number(row.seq) },
|
|
813
|
+
inserted: false
|
|
814
|
+
};
|
|
815
|
+
});
|
|
816
|
+
const insertOne = (queued, fence, enforceCompactionFloor = false) => Effect.gen(function* () {
|
|
817
|
+
if (fence._tag === "Owned") {
|
|
818
|
+
yield* fenceGuard(queued.runId, fence.owner);
|
|
819
|
+
} else if (!enforceCompactionFloor) {
|
|
820
|
+
const duplicate = yield* selectExisting(queued);
|
|
821
|
+
if (duplicate !== void 0) {
|
|
822
|
+
return duplicate;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const insert = enforceCompactionFloor ? sql`
|
|
826
|
+
INSERT INTO flows_journal_events (
|
|
827
|
+
run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
828
|
+
event_type, payload_json, meta_json
|
|
829
|
+
)
|
|
830
|
+
SELECT
|
|
831
|
+
${queued.runId},
|
|
832
|
+
(SELECT ${Dialect.greatest(sql)}(${queued.seq}, COALESCE(MAX(seq), -1) + 1)
|
|
833
|
+
FROM flows_journal_events WHERE run_id = ${queued.runId}),
|
|
834
|
+
${queued.eventId},
|
|
835
|
+
${queued.sourceId},
|
|
836
|
+
${queued.sourceSeq},
|
|
837
|
+
${queued.emittedAtMs},
|
|
838
|
+
${queued.eventType},
|
|
839
|
+
${queued.payloadJson},
|
|
840
|
+
${queued.metaJson}
|
|
841
|
+
WHERE NOT EXISTS (
|
|
842
|
+
SELECT 1 FROM flows_journal_checkpoints
|
|
843
|
+
WHERE run_id = ${queued.runId}
|
|
844
|
+
AND compacted_at_ms IS NOT NULL
|
|
845
|
+
AND seq >= ${queued.seq}
|
|
846
|
+
) AND NOT EXISTS (
|
|
847
|
+
SELECT 1 FROM flows_journal_events
|
|
848
|
+
WHERE run_id = ${queued.runId} AND seq >= ${Number.MAX_SAFE_INTEGER - 1}
|
|
849
|
+
)
|
|
850
|
+
ON CONFLICT DO NOTHING
|
|
851
|
+
RETURNING run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
852
|
+
event_type, payload_json, meta_json
|
|
853
|
+
` : sql`
|
|
854
|
+
INSERT INTO flows_journal_events (
|
|
855
|
+
run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
856
|
+
event_type, payload_json, meta_json
|
|
857
|
+
) VALUES (
|
|
858
|
+
${queued.runId},
|
|
859
|
+
${queued.seq},
|
|
860
|
+
${queued.eventId},
|
|
861
|
+
${queued.sourceId},
|
|
862
|
+
${queued.sourceSeq},
|
|
863
|
+
${queued.emittedAtMs},
|
|
864
|
+
${queued.eventType},
|
|
865
|
+
${queued.payloadJson},
|
|
866
|
+
${queued.metaJson}
|
|
867
|
+
)
|
|
868
|
+
ON CONFLICT DO NOTHING
|
|
869
|
+
RETURNING run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
870
|
+
event_type, payload_json, meta_json
|
|
871
|
+
`;
|
|
872
|
+
const inserted = yield* insert;
|
|
873
|
+
if (inserted.length > 0) {
|
|
874
|
+
return {
|
|
875
|
+
entry: yield* decodeRow(inserted[0]),
|
|
876
|
+
inserted: true
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
const racedDuplicate = yield* selectExisting(queued);
|
|
880
|
+
if (racedDuplicate !== void 0) {
|
|
881
|
+
return racedDuplicate;
|
|
882
|
+
}
|
|
883
|
+
{
|
|
884
|
+
const floor = yield* compactionFloor(queued.runId);
|
|
885
|
+
if (floor !== void 0 && queued.seq <= floor) {
|
|
886
|
+
return yield* Effect.fail(
|
|
887
|
+
new import_Journal.JournalError({
|
|
888
|
+
code: "compacted",
|
|
889
|
+
message: `queued sequence ${queued.seq} for run ${queued.runId} was dropped below compaction floor ${floor}`,
|
|
890
|
+
checkpointSeq: floor
|
|
891
|
+
})
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return yield* Effect.fail(
|
|
896
|
+
error(
|
|
897
|
+
"invalid_event",
|
|
898
|
+
"journal sequence is outside the allocatable safe integer range"
|
|
899
|
+
)
|
|
900
|
+
);
|
|
901
|
+
});
|
|
902
|
+
const insertBatch = (batch) => Effect.gen(function* () {
|
|
903
|
+
const results = yield* Effect.forEach(
|
|
904
|
+
batch,
|
|
905
|
+
(queued) => Effect.map(Effect.result(insertOne(queued, unfenced, true)), (result) => ({ queued, result }))
|
|
906
|
+
);
|
|
907
|
+
const commits = [];
|
|
908
|
+
const losses = [];
|
|
909
|
+
for (const settled of results) {
|
|
910
|
+
if (Result.isSuccess(settled.result)) {
|
|
911
|
+
commits.push({ queued: settled.queued, commit: settled.result.success });
|
|
912
|
+
} else if (isJournalError(settled.result.failure)) {
|
|
913
|
+
losses.push({ queued: settled.queued, cause: settled.result.failure });
|
|
914
|
+
} else {
|
|
915
|
+
return yield* Effect.fail(settled.result.failure);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return { commits, losses };
|
|
919
|
+
});
|
|
920
|
+
const persistBatch = (batch) => writer.write(withRunPermits(
|
|
921
|
+
batch.map((queued) => queued.runId),
|
|
922
|
+
insertBatch(batch)
|
|
923
|
+
)).pipe(
|
|
924
|
+
// Every per-entry `JournalError` is settled inside the transaction
|
|
925
|
+
// above, so the only failure that escapes is the transaction itself:
|
|
926
|
+
// a database outage, which is what `sink_failed` names.
|
|
927
|
+
Effect.mapError((cause) => error("sink_failed", "journal sink failed", cause))
|
|
928
|
+
);
|
|
929
|
+
const publish = (commits) => Effect.forEach(
|
|
930
|
+
commits,
|
|
931
|
+
(commit) => {
|
|
932
|
+
if (!commit.inserted) {
|
|
933
|
+
return Effect.void;
|
|
934
|
+
}
|
|
935
|
+
return PubSub.publish(changes, freezePublished(commit.entry)).pipe(
|
|
936
|
+
Effect.andThen(
|
|
937
|
+
Effect.forEach(
|
|
938
|
+
wakes.get(commit.entry.runId) ?? [],
|
|
939
|
+
(wake) => PubSub.publish(wake, void 0),
|
|
940
|
+
{ discard: true }
|
|
941
|
+
)
|
|
942
|
+
),
|
|
943
|
+
Effect.asVoid
|
|
944
|
+
);
|
|
945
|
+
},
|
|
946
|
+
{ discard: true }
|
|
947
|
+
);
|
|
948
|
+
const rememberCommitted = (queued, seq) => {
|
|
949
|
+
retain(sourceEventKey(queued.runId, queued.sourceId, queued.sourceSeq), {
|
|
950
|
+
seq,
|
|
951
|
+
eventType: queued.eventType,
|
|
952
|
+
payloadJson: queued.payloadJson,
|
|
953
|
+
metaJson: queued.metaJson,
|
|
954
|
+
status: "committed"
|
|
955
|
+
});
|
|
956
|
+
raiseSequenceFloor(queued.runId, seq);
|
|
957
|
+
raiseSourceSequenceFloor(queued.runId, queued.sourceId, queued.sourceSeq);
|
|
958
|
+
};
|
|
959
|
+
const nextDurable = (column, runId, sourceId) => Effect.map(
|
|
960
|
+
column === "seq" ? sql`
|
|
961
|
+
SELECT MAX(seq) + 1 AS next FROM flows_journal_events WHERE run_id = ${runId}
|
|
962
|
+
` : sql`
|
|
963
|
+
SELECT MAX(source_seq) + 1 AS next FROM (
|
|
964
|
+
SELECT MAX(source_seq) AS source_seq FROM flows_journal_events
|
|
965
|
+
WHERE run_id = ${runId} AND source_id = ${sourceId}
|
|
966
|
+
UNION ALL
|
|
967
|
+
SELECT MAX(source_seq) AS source_seq FROM flows_journal_dedup
|
|
968
|
+
WHERE run_id = ${runId} AND source_id = ${sourceId}
|
|
969
|
+
)
|
|
970
|
+
`,
|
|
971
|
+
(rows) => Number(rows[0]?.next ?? 0)
|
|
972
|
+
);
|
|
973
|
+
const ensureFloors = (input) => Effect.suspend(() => {
|
|
974
|
+
const runId = input.runId;
|
|
975
|
+
const key = sourceKey(runId, input.sourceId);
|
|
976
|
+
const seq = state.sequences.get(runId);
|
|
977
|
+
const sourceSeq = input.sourceSeq === void 0 ? state.sourceSequences.get(key) : 0;
|
|
978
|
+
if (seq !== void 0 && sourceSeq !== void 0) {
|
|
979
|
+
return Effect.succeed({ seq, sourceSeq });
|
|
980
|
+
}
|
|
981
|
+
return Effect.all({
|
|
982
|
+
seq: seq === void 0 ? nextDurable("seq", runId, void 0) : Effect.succeed(seq),
|
|
983
|
+
sourceSeq: sourceSeq === void 0 ? nextDurable("source_seq", runId, input.sourceId) : Effect.succeed(sourceSeq)
|
|
984
|
+
}).pipe(
|
|
985
|
+
Effect.mapError((cause) => error("sink_failed", "could not read journal allocation floor", cause)),
|
|
986
|
+
Effect.flatMap((floors) => {
|
|
987
|
+
if (!Number.isSafeInteger(floors.seq) || floors.seq < 0 || !Number.isSafeInteger(floors.sourceSeq) || floors.sourceSeq < 0) {
|
|
988
|
+
return Effect.fail(
|
|
989
|
+
error("invalid_event", "journal sequence is outside the allocatable safe integer range")
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
return Effect.succeed(floors);
|
|
993
|
+
})
|
|
994
|
+
);
|
|
995
|
+
});
|
|
996
|
+
const settleCommit = (queued, commit, restoreMaintenance) => Effect.gen(function* () {
|
|
997
|
+
const mandatory = Effect.sync(() => rememberCommitted(queued, commit.entry.seq)).pipe(
|
|
998
|
+
Effect.andThen(publish([commit]))
|
|
999
|
+
);
|
|
1000
|
+
const maintenance = restoreMaintenance(noteCommitted(queued.runId, commit.inserted ? 1 : 0));
|
|
1001
|
+
const enclosing = yield* Effect.serviceOption(sql.transactionService);
|
|
1002
|
+
if (Option.isNone(enclosing)) {
|
|
1003
|
+
yield* mandatory;
|
|
1004
|
+
return maintenance;
|
|
1005
|
+
}
|
|
1006
|
+
yield* (0, import_DurableWriter.afterCommit)(mandatory.pipe(Effect.andThen(maintenance)), sql);
|
|
1007
|
+
return Effect.void;
|
|
1008
|
+
});
|
|
1009
|
+
const transact = (effect) => writer.write(effect).pipe(
|
|
1010
|
+
Effect.catchIf(
|
|
1011
|
+
(cause) => cause instanceof import_DurableWriter.DatabaseError,
|
|
1012
|
+
(cause) => Effect.fail(error("sink_failed", "journal transaction failed", cause))
|
|
1013
|
+
)
|
|
1014
|
+
);
|
|
1015
|
+
const writeDurable = (input, fence) => Effect.uninterruptibleMask(
|
|
1016
|
+
(restoreMaintenance) => restoreMaintenance(Effect.gen(function* () {
|
|
1017
|
+
yield* Effect.annotateCurrentSpan({
|
|
1018
|
+
runId: input.runId,
|
|
1019
|
+
sourceId: input.sourceId,
|
|
1020
|
+
eventType: input.eventType
|
|
1021
|
+
});
|
|
1022
|
+
const emittedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
|
|
1023
|
+
const prepared = yield* Effect.fromResult(prepare(input, emittedAtMs));
|
|
1024
|
+
const committed = yield* Effect.gen(function* () {
|
|
1025
|
+
const { metaJson, payloadJson, validated } = prepared;
|
|
1026
|
+
return yield* Effect.uninterruptibleMask(
|
|
1027
|
+
(restore) => restore(writer.write(withActiveRunWrite(
|
|
1028
|
+
prepared.validated.runId,
|
|
1029
|
+
Effect.gen(function* () {
|
|
1030
|
+
const durableSourceSeq = validated.sourceSeq === void 0 ? yield* nextDurable("source_seq", validated.runId, validated.sourceId) : 0;
|
|
1031
|
+
const durableSeq = yield* nextDurable("seq", validated.runId, void 0);
|
|
1032
|
+
const reserved = yield* allocation.withPermit(Effect.fromResult(Result.gen(function* () {
|
|
1033
|
+
const key = sourceKey(validated.runId, validated.sourceId);
|
|
1034
|
+
const sourceSeq2 = validated.sourceSeq ?? Math.max(
|
|
1035
|
+
durableSourceSeq,
|
|
1036
|
+
state.sourceSequences.get(key) ?? 0
|
|
1037
|
+
);
|
|
1038
|
+
if (!Number.isSafeInteger(sourceSeq2) || sourceSeq2 < 0 || sourceSeq2 === Number.MAX_SAFE_INTEGER) {
|
|
1039
|
+
return yield* Result.fail(
|
|
1040
|
+
error("invalid_event", "journal sequence is outside the allocatable safe integer range")
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
const seq2 = Math.max(
|
|
1044
|
+
durableSeq,
|
|
1045
|
+
state.sequences.get(validated.runId) ?? 0
|
|
1046
|
+
);
|
|
1047
|
+
if (!Number.isSafeInteger(seq2) || seq2 === Number.MAX_SAFE_INTEGER) {
|
|
1048
|
+
return yield* Result.fail(
|
|
1049
|
+
error("invalid_event", "journal sequence is outside the allocatable safe integer range")
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
raiseSequenceFloor(validated.runId, seq2);
|
|
1053
|
+
raiseSourceSequenceFloor(validated.runId, validated.sourceId, sourceSeq2);
|
|
1054
|
+
return { seq: seq2, sourceSeq: sourceSeq2 };
|
|
1055
|
+
})));
|
|
1056
|
+
const { seq, sourceSeq } = reserved;
|
|
1057
|
+
const queued = {
|
|
1058
|
+
runId: validated.runId,
|
|
1059
|
+
seq,
|
|
1060
|
+
eventId: (0, import_JournalEvent.makeEventId)(validated.runId, validated.sourceId, sourceSeq),
|
|
1061
|
+
sourceId: validated.sourceId,
|
|
1062
|
+
sourceSeq,
|
|
1063
|
+
emittedAtMs,
|
|
1064
|
+
eventType: validated.eventType,
|
|
1065
|
+
payloadJson,
|
|
1066
|
+
metaJson,
|
|
1067
|
+
dedupe: validated.dedupe ?? "content"
|
|
1068
|
+
};
|
|
1069
|
+
const commit = yield* insertOne(queued, fence);
|
|
1070
|
+
return { commit, queued, sourceSeq };
|
|
1071
|
+
})
|
|
1072
|
+
))).pipe(
|
|
1073
|
+
/**
|
|
1074
|
+
* `writer.write` is a retrying transaction: its body replays on
|
|
1075
|
+
* `SQLITE_BUSY(_SNAPSHOT)` and can still abort at COMMIT after the
|
|
1076
|
+
* body succeeded. Cache mutation and publication therefore happen
|
|
1077
|
+
* strictly after the transaction returns, so subscribers never
|
|
1078
|
+
* observe a rolled-back entry and a replayed body never publishes
|
|
1079
|
+
* twice. Mirrors the queued path, which publishes in a `.tap`
|
|
1080
|
+
* outside `persistBatch`. Only the transaction is restored: an
|
|
1081
|
+
* interruption that lands after it returns waits for the index
|
|
1082
|
+
* update and publication, so a committed row always reaches
|
|
1083
|
+
* `changes`.
|
|
1084
|
+
*
|
|
1085
|
+
* Under `transact` "after the transaction returns" is not yet
|
|
1086
|
+
* "after COMMIT": this write is a savepoint of the caller's
|
|
1087
|
+
* transaction, so `settleCommit` parks both effects until the
|
|
1088
|
+
* outermost transaction commits. The active-write reference
|
|
1089
|
+
* is released before that outer commit, which lets automatic
|
|
1090
|
+
* compaction drain the run before its maintenance transaction.
|
|
1091
|
+
*/
|
|
1092
|
+
Effect.flatMap(
|
|
1093
|
+
(written) => Effect.gen(function* () {
|
|
1094
|
+
const maintenance = yield* settleCommit(written.queued, written.commit, restoreMaintenance);
|
|
1095
|
+
const receipt = written.commit.inserted ? { _tag: "Accepted", seq: written.commit.entry.seq, sourceSeq: written.sourceSeq } : {
|
|
1096
|
+
_tag: "Duplicate",
|
|
1097
|
+
seq: written.commit.entry.seq,
|
|
1098
|
+
sourceSeq: written.sourceSeq,
|
|
1099
|
+
status: "committed"
|
|
1100
|
+
};
|
|
1101
|
+
yield* Metric.update(JournalMetrics.durable[receipt._tag], 1);
|
|
1102
|
+
return { maintenance, receipt };
|
|
1103
|
+
})
|
|
1104
|
+
)
|
|
1105
|
+
)
|
|
1106
|
+
);
|
|
1107
|
+
}).pipe(
|
|
1108
|
+
Effect.mapError(
|
|
1109
|
+
(cause) => isJournalError(cause) ? cause : error("sink_failed", "durable journal write failed", cause)
|
|
1110
|
+
)
|
|
1111
|
+
);
|
|
1112
|
+
yield* committed.maintenance;
|
|
1113
|
+
return committed.receipt;
|
|
1114
|
+
}))
|
|
1115
|
+
);
|
|
1116
|
+
const emitDurable = Effect.fn("Journal.emitDurable")(
|
|
1117
|
+
(input, owner) => Effect.flatMap(
|
|
1118
|
+
Effect.fromResult(requireFence(owner, "emitDurable")),
|
|
1119
|
+
(fence) => writeDurable(input, fence)
|
|
1120
|
+
)
|
|
1121
|
+
);
|
|
1122
|
+
const emitDurableUnfenced = Effect.fn("Journal.emitDurableUnfenced")((input) => writeDurable(input, unfenced));
|
|
1123
|
+
const emitLossy = queuedEmit;
|
|
1124
|
+
const checkpointInternal = (checkpointOptions, fence) => Effect.gen(function* () {
|
|
1125
|
+
yield* Effect.annotateCurrentSpan({
|
|
1126
|
+
runId: checkpointOptions.runId,
|
|
1127
|
+
seq: checkpointOptions.seq
|
|
1128
|
+
});
|
|
1129
|
+
yield* Effect.fromResult(Result.mapError(
|
|
1130
|
+
decodeCheckpointOptions(checkpointOptions),
|
|
1131
|
+
(cause) => error("invalid_event", "checkpoint options violate the journal contract", cause)
|
|
1132
|
+
));
|
|
1133
|
+
const stateJson = yield* Effect.fromResult(encodeJson(checkpointOptions.state, "state"));
|
|
1134
|
+
const receiptState = yield* Schema.decodeUnknownEffect(UnknownFromJsonString)(stateJson).pipe(
|
|
1135
|
+
Effect.mapError(
|
|
1136
|
+
/* v8 ignore next -- encodeJson produced these valid JSON bytes immediately above */
|
|
1137
|
+
(cause) => error("decode_failed", "could not decode persisted checkpoint state", cause)
|
|
1138
|
+
)
|
|
1139
|
+
);
|
|
1140
|
+
const createdAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
|
|
1141
|
+
return yield* writer.write(Effect.gen(function* () {
|
|
1142
|
+
if (fence._tag === "Owned") {
|
|
1143
|
+
yield* fenceGuard(checkpointOptions.runId, fence.owner);
|
|
1144
|
+
}
|
|
1145
|
+
const target = yield* sql`
|
|
1146
|
+
SELECT 1 AS ok FROM flows_journal_events
|
|
1147
|
+
WHERE run_id = ${checkpointOptions.runId} AND seq = ${checkpointOptions.seq}
|
|
1148
|
+
`;
|
|
1149
|
+
if (target.length === 0) {
|
|
1150
|
+
return yield* Effect.fail(error(
|
|
1151
|
+
"checkpoint_invalid",
|
|
1152
|
+
`checkpoint sequence ${checkpointOptions.seq} names no committed entry of run ${checkpointOptions.runId}`
|
|
1153
|
+
));
|
|
1154
|
+
}
|
|
1155
|
+
const floor = yield* compactionFloor(checkpointOptions.runId);
|
|
1156
|
+
if (floor !== void 0 && checkpointOptions.seq <= floor) {
|
|
1157
|
+
return yield* Effect.fail(
|
|
1158
|
+
new import_Journal.JournalError({
|
|
1159
|
+
code: "checkpoint_invalid",
|
|
1160
|
+
message: `run ${checkpointOptions.runId} is already compacted through sequence ${floor}`,
|
|
1161
|
+
checkpointSeq: floor
|
|
1162
|
+
})
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
yield* sql`
|
|
1166
|
+
INSERT INTO flows_journal_checkpoints (run_id, seq, state_json, created_at_ms)
|
|
1167
|
+
VALUES (${checkpointOptions.runId}, ${checkpointOptions.seq}, ${stateJson}, ${createdAtMs})
|
|
1168
|
+
ON CONFLICT (run_id, seq) DO UPDATE SET
|
|
1169
|
+
state_json = excluded.state_json,
|
|
1170
|
+
created_at_ms = excluded.created_at_ms
|
|
1171
|
+
`;
|
|
1172
|
+
return new import_Journal.Checkpoint({
|
|
1173
|
+
runId: checkpointOptions.runId,
|
|
1174
|
+
seq: checkpointOptions.seq,
|
|
1175
|
+
state: receiptState,
|
|
1176
|
+
createdAtMs,
|
|
1177
|
+
compactedAtMs: null
|
|
1178
|
+
});
|
|
1179
|
+
})).pipe(
|
|
1180
|
+
Effect.mapError(
|
|
1181
|
+
(cause) => isJournalError(cause) ? cause : error("sink_failed", "durable checkpoint write failed", cause)
|
|
1182
|
+
)
|
|
1183
|
+
);
|
|
1184
|
+
});
|
|
1185
|
+
const checkpoint = Effect.fn("Journal.checkpoint")(
|
|
1186
|
+
(checkpointOptions, owner) => Effect.flatMap(
|
|
1187
|
+
Effect.fromResult(requireFence(owner, "checkpoint")),
|
|
1188
|
+
(fence) => checkpointInternal(checkpointOptions, fence)
|
|
1189
|
+
)
|
|
1190
|
+
);
|
|
1191
|
+
const latestCheckpoint = Effect.fn("Journal.latestCheckpoint")(
|
|
1192
|
+
(runId) => Effect.gen(function* () {
|
|
1193
|
+
yield* Effect.annotateCurrentSpan({ runId });
|
|
1194
|
+
yield* Effect.fromResult(Result.mapError(
|
|
1195
|
+
decodeRunId(runId),
|
|
1196
|
+
(cause) => error("invalid_event", "runId violates the journal identifier contract", cause)
|
|
1197
|
+
));
|
|
1198
|
+
const rows = yield* sql`
|
|
1199
|
+
SELECT run_id, seq, state_json, created_at_ms, compacted_at_ms
|
|
1200
|
+
FROM flows_journal_checkpoints
|
|
1201
|
+
WHERE run_id = ${runId}
|
|
1202
|
+
ORDER BY seq DESC
|
|
1203
|
+
LIMIT 1
|
|
1204
|
+
`.pipe(Effect.mapError((cause) => error("read_failed", "durable checkpoint read failed", cause)));
|
|
1205
|
+
const row = rows[0];
|
|
1206
|
+
return row === void 0 ? Option.none() : Option.some(yield* decodeCheckpointRow(row));
|
|
1207
|
+
})
|
|
1208
|
+
);
|
|
1209
|
+
const compactInternal = (compactOptions, fence) => Effect.gen(function* () {
|
|
1210
|
+
yield* Effect.annotateCurrentSpan({
|
|
1211
|
+
runId: compactOptions.runId,
|
|
1212
|
+
...compactOptions.upTo === void 0 ? {} : { upTo: compactOptions.upTo }
|
|
1213
|
+
});
|
|
1214
|
+
yield* Effect.fromResult(Result.mapError(
|
|
1215
|
+
decodeCompactOptions(compactOptions),
|
|
1216
|
+
(cause) => error("invalid_event", "compact options violate the journal contract", cause)
|
|
1217
|
+
));
|
|
1218
|
+
const compactedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
|
|
1219
|
+
return yield* withCompactionBarrier(
|
|
1220
|
+
compactOptions.runId,
|
|
1221
|
+
Effect.gen(function* () {
|
|
1222
|
+
if (fence._tag === "Owned") {
|
|
1223
|
+
yield* fenceGuard(compactOptions.runId, fence.owner);
|
|
1224
|
+
}
|
|
1225
|
+
const upTo = compactOptions.upTo;
|
|
1226
|
+
const rows = upTo === void 0 ? yield* sql`
|
|
1227
|
+
SELECT run_id, seq, state_json, created_at_ms, compacted_at_ms
|
|
1228
|
+
FROM flows_journal_checkpoints
|
|
1229
|
+
WHERE run_id = ${compactOptions.runId}
|
|
1230
|
+
ORDER BY seq DESC
|
|
1231
|
+
LIMIT 1
|
|
1232
|
+
` : yield* sql`
|
|
1233
|
+
SELECT run_id, seq, state_json, created_at_ms, compacted_at_ms
|
|
1234
|
+
FROM flows_journal_checkpoints
|
|
1235
|
+
WHERE run_id = ${compactOptions.runId} AND seq = ${upTo}
|
|
1236
|
+
`;
|
|
1237
|
+
const row = rows[0];
|
|
1238
|
+
if (row === void 0) {
|
|
1239
|
+
return yield* Effect.fail(error(
|
|
1240
|
+
"checkpoint_invalid",
|
|
1241
|
+
`run ${compactOptions.runId} has no checkpoint${upTo === void 0 ? "" : ` at sequence ${upTo}`} to compact to`
|
|
1242
|
+
));
|
|
1243
|
+
}
|
|
1244
|
+
const checkpointSeq = Number(row.seq);
|
|
1245
|
+
if (row.compacted_at_ms !== null) {
|
|
1246
|
+
return { runId: compactOptions.runId, checkpointSeq, deleted: 0 };
|
|
1247
|
+
}
|
|
1248
|
+
for (const reader of readers.get(compactOptions.runId) ?? []) {
|
|
1249
|
+
if (reader.cursor < checkpointSeq - 1) {
|
|
1250
|
+
return yield* Effect.fail(
|
|
1251
|
+
new import_Journal.JournalError({
|
|
1252
|
+
code: "reader_behind",
|
|
1253
|
+
message: `a live stream of run ${compactOptions.runId} still needs sequences below checkpoint ${checkpointSeq}`,
|
|
1254
|
+
checkpointSeq
|
|
1255
|
+
})
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
const doomed = yield* sql`
|
|
1260
|
+
SELECT COUNT(*) AS total FROM flows_journal_events
|
|
1261
|
+
WHERE run_id = ${compactOptions.runId} AND seq < ${checkpointSeq}
|
|
1262
|
+
`;
|
|
1263
|
+
let after = -1;
|
|
1264
|
+
while (true) {
|
|
1265
|
+
const retiring = yield* sql`
|
|
1266
|
+
SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
|
|
1267
|
+
event_type, payload_json, meta_json
|
|
1268
|
+
FROM flows_journal_events
|
|
1269
|
+
WHERE run_id = ${compactOptions.runId} AND seq > ${after} AND seq < ${checkpointSeq}
|
|
1270
|
+
ORDER BY seq ASC LIMIT 256
|
|
1271
|
+
`;
|
|
1272
|
+
if (retiring.length === 0) break;
|
|
1273
|
+
for (const entry of retiring) {
|
|
1274
|
+
const fingerprint = yield* contentFingerprint(entry.event_type, entry.payload_json, entry.meta_json);
|
|
1275
|
+
yield* sql`
|
|
1276
|
+
INSERT INTO flows_journal_dedup (run_id, source_id, source_seq, event_id, seq, content_hash)
|
|
1277
|
+
VALUES (${entry.run_id}, ${entry.source_id}, ${entry.source_seq}, ${entry.event_id},
|
|
1278
|
+
${entry.seq}, ${fingerprint})
|
|
1279
|
+
`;
|
|
1280
|
+
}
|
|
1281
|
+
after = Number(retiring[retiring.length - 1].seq);
|
|
1282
|
+
}
|
|
1283
|
+
yield* sql`
|
|
1284
|
+
DELETE FROM flows_journal_events
|
|
1285
|
+
WHERE run_id = ${compactOptions.runId} AND seq < ${checkpointSeq}
|
|
1286
|
+
`;
|
|
1287
|
+
yield* sql`
|
|
1288
|
+
DELETE FROM flows_journal_checkpoints
|
|
1289
|
+
WHERE run_id = ${compactOptions.runId} AND seq < ${checkpointSeq}
|
|
1290
|
+
`;
|
|
1291
|
+
yield* sql`
|
|
1292
|
+
UPDATE flows_journal_checkpoints
|
|
1293
|
+
SET compacted_at_ms = ${compactedAtMs}
|
|
1294
|
+
WHERE run_id = ${compactOptions.runId} AND seq = ${checkpointSeq}
|
|
1295
|
+
`;
|
|
1296
|
+
return {
|
|
1297
|
+
runId: compactOptions.runId,
|
|
1298
|
+
checkpointSeq,
|
|
1299
|
+
deleted: Number(doomed[0]?.total ?? 0)
|
|
1300
|
+
};
|
|
1301
|
+
})
|
|
1302
|
+
).pipe(
|
|
1303
|
+
Effect.mapError(
|
|
1304
|
+
(cause) => isJournalError(cause) ? cause : error("sink_failed", "journal compaction failed", cause)
|
|
1305
|
+
)
|
|
1306
|
+
);
|
|
1307
|
+
});
|
|
1308
|
+
const compact = Effect.fn("Journal.compact")(
|
|
1309
|
+
(compactOptions, owner) => Effect.flatMap(
|
|
1310
|
+
Effect.fromResult(requireFence(owner, "compact")),
|
|
1311
|
+
(fence) => compactInternal(compactOptions, fence)
|
|
1312
|
+
)
|
|
1313
|
+
);
|
|
1314
|
+
const compactionPolicy = options.compaction;
|
|
1315
|
+
const countEntries = (runId) => Effect.map(
|
|
1316
|
+
sql`
|
|
1317
|
+
SELECT COUNT(*) AS total FROM flows_journal_events WHERE run_id = ${runId}
|
|
1318
|
+
`,
|
|
1319
|
+
(rows) => Number(rows[0]?.total ?? 0)
|
|
1320
|
+
);
|
|
1321
|
+
const policyCompact = (policy, runId) => Effect.gen(function* () {
|
|
1322
|
+
const tail = yield* sql`
|
|
1323
|
+
SELECT MAX(seq) AS last FROM flows_journal_events WHERE run_id = ${runId}
|
|
1324
|
+
`;
|
|
1325
|
+
const last = tail[0]?.last;
|
|
1326
|
+
if (last === null || last === void 0) {
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
const upTo = Number(last);
|
|
1330
|
+
const captured = yield* Effect.timeoutOrElse(policy.capture(runId, upTo), {
|
|
1331
|
+
duration: compactionCaptureTimeout,
|
|
1332
|
+
orElse: () => Effect.fail(
|
|
1333
|
+
new import_JournalInternalFault.JournalInternalFault({
|
|
1334
|
+
code: "compaction_capture_timeout",
|
|
1335
|
+
message: `journal compaction capture for run ${runId} exceeded ${compactionCaptureTimeout}`
|
|
1336
|
+
})
|
|
1337
|
+
)
|
|
1338
|
+
});
|
|
1339
|
+
yield* checkpointInternal({ runId, seq: upTo, state: captured }, unfenced);
|
|
1340
|
+
yield* compactInternal({ runId, upTo }, unfenced);
|
|
1341
|
+
compactionCounts.set(runId, yield* countEntries(runId));
|
|
1342
|
+
}).pipe(
|
|
1343
|
+
Effect.catchCause(
|
|
1344
|
+
(cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.sync(() => {
|
|
1345
|
+
compactionCounts.set(runId, 0);
|
|
1346
|
+
}).pipe(
|
|
1347
|
+
Effect.andThen(
|
|
1348
|
+
Effect.logWarning("journal auto-compaction failed; retrying after the next threshold", cause)
|
|
1349
|
+
)
|
|
1350
|
+
)
|
|
1351
|
+
)
|
|
1352
|
+
);
|
|
1353
|
+
const noteCommitted = (runId, committed, background = false) => {
|
|
1354
|
+
const policy = compactionPolicy;
|
|
1355
|
+
if (policy === void 0 || committed <= 0) {
|
|
1356
|
+
return Effect.void;
|
|
1357
|
+
}
|
|
1358
|
+
return Effect.uninterruptibleMask(
|
|
1359
|
+
(restore) => withRunBarrier(runId, (barrier) => restore(barrier.maintenance.withPermit(
|
|
1360
|
+
Effect.gen(function* () {
|
|
1361
|
+
if (compactingRuns.has(runId)) {
|
|
1362
|
+
return false;
|
|
1363
|
+
}
|
|
1364
|
+
const known = compactionCounts.get(runId);
|
|
1365
|
+
const current = known === void 0 ? yield* countEntries(runId) : known + committed;
|
|
1366
|
+
compactionCounts.set(runId, current);
|
|
1367
|
+
if (current < policy.entryThreshold) {
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1370
|
+
compactingRuns.add(runId);
|
|
1371
|
+
pendingMaintenance += 1;
|
|
1372
|
+
return true;
|
|
1373
|
+
})
|
|
1374
|
+
)).pipe(
|
|
1375
|
+
Effect.flatMap((triggered) => {
|
|
1376
|
+
if (!triggered) return Effect.void;
|
|
1377
|
+
const attempt = policyCompact(policy, runId).pipe(Effect.ensuring(Effect.sync(() => {
|
|
1378
|
+
compactingRuns.delete(runId);
|
|
1379
|
+
pendingMaintenance -= 1;
|
|
1380
|
+
if (state.pending === 0 && pendingMaintenance === 0) completeFlushWaiters(Effect.void);
|
|
1381
|
+
})));
|
|
1382
|
+
return background ? Effect.asVoid(Effect.forkIn(Effect.interruptible(attempt), maintenanceScope)) : restore(attempt);
|
|
1383
|
+
}),
|
|
1384
|
+
Effect.catchCause(
|
|
1385
|
+
(cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("journal compaction policy bookkeeping failed", cause)
|
|
1386
|
+
)
|
|
1387
|
+
))
|
|
1388
|
+
);
|
|
1389
|
+
};
|
|
1390
|
+
const recordCommits = (commits) => {
|
|
1391
|
+
for (const { commit, queued } of commits) {
|
|
1392
|
+
rememberCommitted(queued, commit.entry.seq);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
const settle = (count) => {
|
|
1396
|
+
state.pending = Math.max(0, state.pending - count);
|
|
1397
|
+
if (state.pending === 0 && pendingMaintenance === 0) {
|
|
1398
|
+
completeFlushWaiters(Effect.void);
|
|
1399
|
+
}
|
|
1400
|
+
};
|
|
1401
|
+
const reportLoss = (cause, batch) => {
|
|
1402
|
+
for (const queued of batch) {
|
|
1403
|
+
const identity = sourceEventKey(queued.runId, queued.sourceId, queued.sourceSeq);
|
|
1404
|
+
state.sourceEvents.delete(identity);
|
|
1405
|
+
}
|
|
1406
|
+
state.sinkFailure = cause;
|
|
1407
|
+
state.lossEpoch += 1;
|
|
1408
|
+
state.pending = Math.max(0, state.pending - batch.length);
|
|
1409
|
+
if (state.flushWaiters.size > 0) {
|
|
1410
|
+
state.flushedLossEpoch = state.lossEpoch;
|
|
1411
|
+
}
|
|
1412
|
+
completeFlushWaiters(Effect.fail(cause));
|
|
1413
|
+
for (const subscribers of wakes.values()) {
|
|
1414
|
+
for (const wake of subscribers) {
|
|
1415
|
+
PubSub.publishUnsafe(wake, void 0);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
const observeLoss = (cause, batch) => Effect.andThen(
|
|
1420
|
+
Metric.update(JournalMetrics.lost(cause.code), batch.length),
|
|
1421
|
+
Effect.logWarning(
|
|
1422
|
+
`journal lost ${batch.length} lossy entries (${cause.code}) for runs ${[...new Set(batch.map((queued) => queued.runId))].join(", ")}`,
|
|
1423
|
+
cause
|
|
1424
|
+
)
|
|
1425
|
+
);
|
|
1426
|
+
const failSink = (cause, batch) => {
|
|
1427
|
+
settleRunPending(batch);
|
|
1428
|
+
reportLoss(cause, batch);
|
|
1429
|
+
};
|
|
1430
|
+
const writeBatch = Queue.takeBetween(queue, 1, batchSize).pipe(
|
|
1431
|
+
Effect.flatMap(
|
|
1432
|
+
(batch) => persistBatch(batch).pipe(
|
|
1433
|
+
Effect.tap((outcome) => Effect.sync(() => recordCommits(outcome.commits))),
|
|
1434
|
+
Effect.tap((outcome) => publish(outcome.commits.map(({ commit }) => commit))),
|
|
1435
|
+
// The transaction has committed and publication is complete. Drop
|
|
1436
|
+
// the barrier counts before policy compaction takes this run's
|
|
1437
|
+
// permit. Maintenance has its own global flush count.
|
|
1438
|
+
Effect.tap(() => Effect.sync(() => settleRunPending(batch))),
|
|
1439
|
+
// Register maintenance BEFORE settling the batch, so flush cannot
|
|
1440
|
+
// observe a gap between queued work and its policy attempt.
|
|
1441
|
+
Effect.tap((outcome) => {
|
|
1442
|
+
const perRun = /* @__PURE__ */ new Map();
|
|
1443
|
+
outcome.commits.forEach(({ commit, queued }) => {
|
|
1444
|
+
if (!commit.inserted) {
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
perRun.set(queued.runId, (perRun.get(queued.runId) ?? 0) + 1);
|
|
1448
|
+
});
|
|
1449
|
+
return Effect.forEach(perRun, ([runId, committed]) => noteCommitted(runId, committed, true), {
|
|
1450
|
+
discard: true
|
|
1451
|
+
});
|
|
1452
|
+
}),
|
|
1453
|
+
Effect.tap(
|
|
1454
|
+
(outcome) => (
|
|
1455
|
+
// Observed before it is reported, so a flush the report wakes
|
|
1456
|
+
// already sees the loss in the log and the registry.
|
|
1457
|
+
Effect.uninterruptible(Effect.andThen(
|
|
1458
|
+
Effect.forEach(outcome.losses, (loss) => observeLoss(loss.cause, [loss.queued]), { discard: true }),
|
|
1459
|
+
Effect.sync(() => {
|
|
1460
|
+
for (const loss of outcome.losses) {
|
|
1461
|
+
reportLoss(loss.cause, [loss.queued]);
|
|
1462
|
+
}
|
|
1463
|
+
settle(outcome.commits.length);
|
|
1464
|
+
})
|
|
1465
|
+
))
|
|
1466
|
+
)
|
|
1467
|
+
),
|
|
1468
|
+
Effect.catch(
|
|
1469
|
+
(cause) => Effect.uninterruptible(
|
|
1470
|
+
Effect.andThen(observeLoss(cause, batch), Effect.sync(() => failSink(cause, batch)))
|
|
1471
|
+
)
|
|
1472
|
+
),
|
|
1473
|
+
// Defects only: an interruption is scope closure, and it must end
|
|
1474
|
+
// the writer rather than be reported as a lost batch.
|
|
1475
|
+
Effect.catchDefect((defect) => {
|
|
1476
|
+
const cause = error("sink_failed", "journal writer failed", Cause.die(defect));
|
|
1477
|
+
return Effect.uninterruptible(
|
|
1478
|
+
Effect.andThen(observeLoss(cause, batch), Effect.sync(() => failSink(cause, batch)))
|
|
1479
|
+
);
|
|
1480
|
+
})
|
|
1481
|
+
)
|
|
1482
|
+
)
|
|
1483
|
+
);
|
|
1484
|
+
const drain = Effect.forever(writeBatch);
|
|
1485
|
+
yield* Effect.forkScoped(drain);
|
|
1486
|
+
yield* Effect.addFinalizer(
|
|
1487
|
+
() => Effect.gen(function* () {
|
|
1488
|
+
yield* Effect.sync(() => {
|
|
1489
|
+
state.status = "closing";
|
|
1490
|
+
});
|
|
1491
|
+
yield* flushInternal.pipe(
|
|
1492
|
+
Effect.catch((cause) => Effect.logWarning("journal final flush failed while closing", cause))
|
|
1493
|
+
);
|
|
1494
|
+
yield* Effect.sync(() => {
|
|
1495
|
+
state.status = "closed";
|
|
1496
|
+
});
|
|
1497
|
+
yield* Queue.shutdown(queue);
|
|
1498
|
+
yield* PubSub.shutdown(changes);
|
|
1499
|
+
wakes.clear();
|
|
1500
|
+
})
|
|
1501
|
+
);
|
|
1502
|
+
const project = (projection, streamOptions) => Stream.unwrap(
|
|
1503
|
+
Effect.fn("Journal.project")(
|
|
1504
|
+
(activeProjection, activeOptions) => Effect.annotateCurrentSpan({
|
|
1505
|
+
projection: activeProjection.name,
|
|
1506
|
+
runId: activeOptions.runId,
|
|
1507
|
+
...activeOptions.afterSequence === void 0 ? {} : { afterSequence: activeOptions.afterSequence }
|
|
1508
|
+
}).pipe(Effect.andThen(Effect.succeed(
|
|
1509
|
+
stream(activeOptions).pipe(
|
|
1510
|
+
Stream.scanEffect(activeProjection.initial, (state2, entry) => Effect.suspend(() => activeProjection.reduce(state2, entry)).pipe(
|
|
1511
|
+
Effect.catchCause(
|
|
1512
|
+
(cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.fail(
|
|
1513
|
+
error(
|
|
1514
|
+
"projection_failed",
|
|
1515
|
+
`projection ${activeProjection.name} failed`,
|
|
1516
|
+
cause
|
|
1517
|
+
)
|
|
1518
|
+
)
|
|
1519
|
+
)
|
|
1520
|
+
))
|
|
1521
|
+
)
|
|
1522
|
+
)))
|
|
1523
|
+
)(projection, streamOptions)
|
|
1524
|
+
);
|
|
1525
|
+
yield* JournalGeneration.onTruncate((runIds) => {
|
|
1526
|
+
for (const runId of runIds) {
|
|
1527
|
+
state.sequences.delete(runId);
|
|
1528
|
+
const prefix = `${runId.length}:${runId}`;
|
|
1529
|
+
for (const key of state.sourceSequences.keys()) {
|
|
1530
|
+
if (key.startsWith(prefix)) state.sourceSequences.delete(key);
|
|
1531
|
+
}
|
|
1532
|
+
for (const key of state.sourceEvents.keys()) {
|
|
1533
|
+
if (key.startsWith(prefix)) state.sourceEvents.delete(key);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
return (0, import_Journal.make)({
|
|
1538
|
+
generation: (runId) => sql`
|
|
1539
|
+
SELECT generation, after_seq AS "afterSeq" FROM flows_journal_generations WHERE run_id = ${runId}
|
|
1540
|
+
`.pipe(
|
|
1541
|
+
Effect.map((rows) => rows[0] ?? { generation: 0, afterSeq: -1 }),
|
|
1542
|
+
Effect.mapError((cause) => error("read_failed", "could not read journal generation", cause))
|
|
1543
|
+
),
|
|
1544
|
+
emitLossy,
|
|
1545
|
+
emitDurable,
|
|
1546
|
+
emitDurableUnfenced,
|
|
1547
|
+
transact,
|
|
1548
|
+
whenCommitted: (update) => Effect.flatMap(
|
|
1549
|
+
Effect.serviceOption(sql.transactionService),
|
|
1550
|
+
(transaction) => Option.isNone(transaction) ? Effect.as(update, true) : (0, import_DurableWriter.afterCommit)(update, sql)
|
|
1551
|
+
),
|
|
1552
|
+
stream,
|
|
1553
|
+
entries: readPage,
|
|
1554
|
+
changes: PubSub.subscribe(changes),
|
|
1555
|
+
project,
|
|
1556
|
+
flush: Effect.fn("Journal.flush")(
|
|
1557
|
+
() => Effect.suspend(() => Effect.annotateCurrentSpan({ pending: state.pending })).pipe(
|
|
1558
|
+
Effect.andThen(flushInternal)
|
|
1559
|
+
)
|
|
1560
|
+
)(),
|
|
1561
|
+
checkpoint,
|
|
1562
|
+
latestCheckpoint,
|
|
1563
|
+
compact
|
|
1564
|
+
});
|
|
1565
|
+
});
|
|
1566
|
+
const layerWith = (options) => Layer.effect(import_Journal.Journal, make(options));
|
|
1567
|
+
const layer = (options) => layerWith(options).pipe(Layer.provide(SqlConsensus.layer));
|
|
1568
|
+
//# sourceMappingURL=SqlJournal.js.map
|