@segmentstream-cloud/ontology 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/migrate/apply.js +34 -1
- package/dist/replication/baseline.d.ts +18 -4
- package/dist/replication/baseline.js +153 -62
- package/dist/replication/legacy-baseline.d.ts +27 -0
- package/dist/replication/legacy-baseline.js +106 -0
- package/dist/replication/legacy-store.d.ts +23 -0
- package/dist/replication/legacy-store.js +61 -0
- package/dist/replication/schema.d.ts +1 -1
- package/dist/replication/schema.js +51 -5
- package/dist/replication/store.d.ts +14 -11
- package/dist/replication/store.js +251 -62
- package/dist/replication/transaction.d.ts +4 -0
- package/dist/replication/transaction.js +23 -4
- package/dist/runtime/action-transactions.d.ts +35 -0
- package/dist/runtime/action-transactions.js +54 -0
- package/dist/runtime/pipeline.js +17 -3
- package/dist/runtime/service-binding.js +4 -1
- package/dist/runtime/sql-runtime.d.ts +2 -0
- package/dist/runtime/sql-runtime.js +4 -2
- package/dist/service/replication.d.ts +1 -1
- package/dist/service/replication.js +27 -16
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { queryFields } from "../query.js";
|
|
3
3
|
import { columnName, historyTableName, quoteIdentifier as qi, quoteLiteral as ql, relationshipColumnName, SYSTEM_COLUMNS, tableName, joinTableName, } from "../migrate/storage.js";
|
|
4
4
|
import { REPLICATION_RECORD_BYTES } from "./model.js";
|
|
5
|
-
export const REPLICATION_SCHEMA_VERSION =
|
|
5
|
+
export const REPLICATION_SCHEMA_VERSION = 4;
|
|
6
6
|
/** Installed only by the SDK migration runner, never at service startup. */
|
|
7
7
|
export const REPLICATION_DDL = [
|
|
8
8
|
`CREATE TABLE IF NOT EXISTS _ontology_replication (
|
|
@@ -17,6 +17,49 @@ export const REPLICATION_DDL = [
|
|
|
17
17
|
generation uuid PRIMARY KEY, source_id uuid NOT NULL, from_revision bigint NOT NULL,
|
|
18
18
|
manifest jsonb NOT NULL, selection jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
|
19
19
|
)`,
|
|
20
|
+
`DO $$ BEGIN
|
|
21
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
22
|
+
WHERE conrelid = '_ontology_replication'::regclass AND conname = '_ontology_replication_protocol_floor') THEN
|
|
23
|
+
ALTER TABLE _ontology_replication ADD CONSTRAINT _ontology_replication_protocol_floor
|
|
24
|
+
CHECK (version >= 4) NOT VALID;
|
|
25
|
+
END IF;
|
|
26
|
+
END $$`,
|
|
27
|
+
`CREATE SEQUENCE IF NOT EXISTS _ontology_replication_revision AS bigint CACHE 1`,
|
|
28
|
+
`SELECT setval('_ontology_replication_revision', GREATEST(
|
|
29
|
+
(SELECT revision FROM _ontology_replication WHERE id),
|
|
30
|
+
(SELECT last_value FROM _ontology_replication_revision), 1), true)
|
|
31
|
+
WHERE (SELECT version FROM _ontology_replication WHERE id) < 4`,
|
|
32
|
+
`ALTER TABLE _ontology_replication_targets
|
|
33
|
+
ADD COLUMN IF NOT EXISTS protocol integer NOT NULL DEFAULT 3,
|
|
34
|
+
ADD COLUMN IF NOT EXISTS baseline_complete boolean NOT NULL DEFAULT true,
|
|
35
|
+
ADD COLUMN IF NOT EXISTS baseline_cursor jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
36
|
+
ADD COLUMN IF NOT EXISTS initial_revision bigint,
|
|
37
|
+
ADD COLUMN IF NOT EXISTS backfill_complete boolean NOT NULL DEFAULT true,
|
|
38
|
+
ADD COLUMN IF NOT EXISTS backfill_cursor jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
39
|
+
ADD COLUMN IF NOT EXISTS backfill_revision bigint,
|
|
40
|
+
ADD COLUMN IF NOT EXISTS verified_count bigint NOT NULL DEFAULT 0,
|
|
41
|
+
ADD COLUMN IF NOT EXISTS replay_complete boolean NOT NULL DEFAULT true,
|
|
42
|
+
ADD COLUMN IF NOT EXISTS replay_cursor uuid`,
|
|
43
|
+
`CREATE TABLE IF NOT EXISTS _ontology_replication_delivery (
|
|
44
|
+
generation uuid NOT NULL REFERENCES _ontology_replication_targets(generation),
|
|
45
|
+
event_id uuid NOT NULL, history_table text, revision bigint NOT NULL,
|
|
46
|
+
is_baseline boolean NOT NULL DEFAULT false,
|
|
47
|
+
accepted_at timestamptz, verified_at timestamptz,
|
|
48
|
+
next_verify_at timestamptz NOT NULL DEFAULT now(), last_checked_at timestamptz,
|
|
49
|
+
PRIMARY KEY (generation, event_id)
|
|
50
|
+
)`,
|
|
51
|
+
`CREATE INDEX IF NOT EXISTS _ontology_delivery_pending ON _ontology_replication_delivery
|
|
52
|
+
(generation, revision, event_id) WHERE accepted_at IS NULL`,
|
|
53
|
+
`CREATE INDEX IF NOT EXISTS _ontology_delivery_verify ON _ontology_replication_delivery
|
|
54
|
+
(generation, next_verify_at, event_id) WHERE accepted_at IS NOT NULL AND verified_at IS NULL`,
|
|
55
|
+
`CREATE INDEX IF NOT EXISTS _ontology_delivery_unverified ON _ontology_replication_delivery
|
|
56
|
+
(generation, event_id) WHERE verified_at IS NULL`,
|
|
57
|
+
`CREATE INDEX IF NOT EXISTS _ontology_delivery_initial ON _ontology_replication_delivery
|
|
58
|
+
(generation, revision, event_id) WHERE verified_at IS NULL`,
|
|
59
|
+
`UPDATE _ontology_replication_targets SET backfill_complete = false,
|
|
60
|
+
backfill_revision = (SELECT revision FROM _ontology_replication WHERE id),
|
|
61
|
+
initial_revision = (SELECT revision FROM _ontology_replication WHERE id)
|
|
62
|
+
WHERE protocol = 3 AND backfill_revision IS NULL`,
|
|
20
63
|
`CREATE TABLE IF NOT EXISTS _ontology_replication_baselines (
|
|
21
64
|
generation uuid NOT NULL REFERENCES _ontology_replication_targets(generation),
|
|
22
65
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event jsonb NOT NULL
|
|
@@ -29,7 +72,7 @@ export const REPLICATION_DDL = [
|
|
|
29
72
|
`CREATE OR REPLACE FUNCTION _ontology_replication_guard() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
30
73
|
BEGIN
|
|
31
74
|
IF (SELECT enabled FROM _ontology_replication WHERE id) AND
|
|
32
|
-
current_setting('segmentstream.writer', true)
|
|
75
|
+
COALESCE(current_setting('segmentstream.writer', true), '') NOT IN ('1', '2') THEN
|
|
33
76
|
RAISE EXCEPTION 'Replication requires a compatible ontology writer; pause replication before a data migration';
|
|
34
77
|
END IF;
|
|
35
78
|
RETURN NULL;
|
|
@@ -71,7 +114,8 @@ export function replicationEntityDdl(manifest) {
|
|
|
71
114
|
(((replication->>'revision')::bigint)) WHERE replication IS NOT NULL`,
|
|
72
115
|
`CREATE OR REPLACE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
73
116
|
DECLARE state _ontology_replication; policy jsonb; snapshot jsonb; relation text; other_id text;
|
|
74
|
-
record_key text; record_kind text := 'entity';
|
|
117
|
+
event_revision bigint; record_key text; record_kind text := 'entity';
|
|
118
|
+
is_deleted boolean := NEW.kind = 'delete';
|
|
75
119
|
BEGIN
|
|
76
120
|
SELECT * INTO state FROM _ontology_replication WHERE id;
|
|
77
121
|
policy := state.selection->${ql(entity.id)};
|
|
@@ -96,11 +140,13 @@ export function replicationEntityDdl(manifest) {
|
|
|
96
140
|
IF octet_length(snapshot::text) > ${REPLICATION_RECORD_BYTES} THEN
|
|
97
141
|
RAISE EXCEPTION 'Projected ontology record exceeds the replication size limit';
|
|
98
142
|
END IF;
|
|
99
|
-
|
|
143
|
+
event_revision := nextval('_ontology_replication_revision');
|
|
100
144
|
NEW.replication := jsonb_build_object('source_id', state.source_id::text,
|
|
101
145
|
'entity_id', ${ql(entity.id)}, 'record_key', record_key, 'record_kind', record_kind,
|
|
102
|
-
'revision', state.
|
|
146
|
+
'revision', event_revision::text, 'generation', state.active_generation::text, 'deleted', is_deleted,
|
|
103
147
|
'schema_hash', ${ql(manifest.hash)}, 'payload', snapshot::text);
|
|
148
|
+
INSERT INTO _ontology_replication_delivery (generation, event_id, history_table, revision)
|
|
149
|
+
VALUES (state.active_generation, NEW.id, TG_TABLE_NAME, event_revision);
|
|
104
150
|
RETURN NEW;
|
|
105
151
|
END $$`,
|
|
106
152
|
`DROP TRIGGER IF EXISTS _ontology_capture ON ${history}`,
|
|
@@ -1,35 +1,38 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import type { SqlClient } from "effect/unstable/sql";
|
|
3
3
|
import { type ReplicationEvent, type ReplicationSelection, type ReplicationExclusion } from "./model.js";
|
|
4
|
-
import { ReplicationBaselineFailed, type ReplicationTarget } from "./baseline.js";
|
|
4
|
+
import { advanceReplicationBaseline, ReplicationBaselineFailed, type ReplicationTarget } from "./baseline.js";
|
|
5
5
|
export type { ReplicationTarget } from "./baseline.js";
|
|
6
6
|
export declare const startReplication: (sql: SqlClient.SqlClient, input: {
|
|
7
7
|
readonly generation: string;
|
|
8
8
|
readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
|
|
9
9
|
readonly selection?: ReadonlyArray<ReplicationSelection>;
|
|
10
10
|
readonly newSource?: boolean;
|
|
11
|
-
}) => Effect.Effect<ReplicationTarget, import("../index.js").CatalogInvalid |
|
|
11
|
+
}) => Effect.Effect<ReplicationTarget, import("../index.js").CatalogInvalid | import("effect/unstable/sql/SqlError").SqlError | ReplicationBaselineFailed, never>;
|
|
12
12
|
export declare const activeReplicationTarget: (sql: SqlClient.SqlClient) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
13
|
-
export declare const pauseReplication: (sql: SqlClient.SqlClient) => import("effect/unstable/sql/
|
|
13
|
+
export declare const pauseReplication: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
14
14
|
export declare const replicationTarget: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
export { advanceReplicationBaseline };
|
|
16
|
+
/** Retained history is recovery storage; frequent delivery reads only indexed outstanding references. */
|
|
17
17
|
export declare const pendingReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
|
|
18
18
|
readonly historyId: string;
|
|
19
19
|
readonly event: ReplicationEvent;
|
|
20
20
|
}[], import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
21
|
-
/** Call only after Pub/Sub accepts the complete batch. Lost responses deliberately produce duplicates. */
|
|
22
|
-
export declare const acknowledgeReplication: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>) => import("effect/unstable/sql/Statement").Statement<object>;
|
|
23
|
-
export declare const replayReplication: (sql: SqlClient.SqlClient, generation: string) => import("effect/unstable/sql/Statement").Statement<object>;
|
|
24
21
|
export declare const replicationStatus: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
|
|
25
22
|
total: string;
|
|
26
23
|
accepted: string;
|
|
27
24
|
verified: string;
|
|
28
25
|
}, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
26
|
+
/** Initialization is a fixed fence, so continuing writes cannot move the readiness goal. */
|
|
27
|
+
export declare const replicationInitiallyReady: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<boolean, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
29
28
|
export declare const unverifiedReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
|
|
30
29
|
historyId: string;
|
|
31
30
|
eventId: string;
|
|
32
31
|
}[], import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
33
|
-
export declare const
|
|
34
|
-
/**
|
|
35
|
-
export declare const
|
|
32
|
+
export declare const advanceReplicationReplay: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<boolean, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
33
|
+
/** Static pre-upgrade histories have no new inserts: v4 writers atomically enqueue new history. */
|
|
34
|
+
export declare const advanceReplicationBackfill: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<boolean, import("../index.js").CatalogInvalid | import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
35
|
+
export declare const acknowledgeReplication: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>, version?: number) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
36
|
+
export declare const verifyReplicationEvents: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>, version?: number) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
37
|
+
export declare const retryUnverifiedReplication: (sql: SqlClient.SqlClient, generation: string, ids?: ReadonlyArray<string>, version?: number) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
38
|
+
export declare const replayReplication: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import * as legacy from "./legacy-store.js";
|
|
3
|
+
import { createReplicationBaseline as createLegacyBaseline } from "./legacy-baseline.js";
|
|
4
|
+
import { lockReplicationLifecycle, shareReplicationLifecycle } from "./transaction.js";
|
|
2
5
|
import { readCatalog } from "../migrate/apply.js";
|
|
3
6
|
import { historyTableName, quoteIdentifier as qi } from "../migrate/storage.js";
|
|
4
7
|
import { REPLICATION_BATCH_BYTES, REPLICATION_BATCH_SIZE, publishMessage, replicationEventId, resolveReplicationSelection, } from "./model.js";
|
|
5
|
-
import { createReplicationBaseline, ReplicationBaselineFailed, } from "./baseline.js";
|
|
6
|
-
export const startReplication = (sql, input) =>
|
|
7
|
-
yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
|
|
8
|
-
yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
|
|
9
|
-
yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
|
|
8
|
+
import { createReplicationBaseline, advanceReplicationBaseline, ReplicationBaselineFailed, } from "./baseline.js";
|
|
9
|
+
export const startReplication = (sql, input) => Effect.gen(function* () {
|
|
10
10
|
const manifest = yield* readCatalog({ sql });
|
|
11
11
|
if (!manifest) {
|
|
12
12
|
return yield* new ReplicationBaselineFailed({
|
|
@@ -18,76 +18,265 @@ export const startReplication = (sql, input) => sql.withTransaction(Effect.gen(f
|
|
|
18
18
|
try: () => resolveReplicationSelection(manifest, input.exclusions),
|
|
19
19
|
catch: (cause) => new ReplicationBaselineFailed({ message: "Invalid replication exclusions.", cause }),
|
|
20
20
|
}));
|
|
21
|
-
|
|
21
|
+
const version = yield* protocol(sql);
|
|
22
|
+
return yield* version >= 4
|
|
23
|
+
? createReplicationBaseline(sql, { ...input, manifest, selection })
|
|
24
|
+
: createLegacyBaseline(sql, { ...input, manifest, selection }).pipe(Effect.map((target) => target));
|
|
25
|
+
});
|
|
26
|
+
const targetFields = `generation::text, source_id::text, from_revision::text, manifest, selection,
|
|
27
|
+
protocol, baseline_complete, backfill_complete, initial_revision::text, replay_complete, 4 AS delivery_version`;
|
|
28
|
+
const protocol = (sql) => sql
|
|
29
|
+
.unsafe("SELECT version FROM _ontology_replication WHERE id")
|
|
30
|
+
.pipe(Effect.map((rows) => rows[0]?.version ?? 3));
|
|
31
|
+
export const activeReplicationTarget = (sql) => Effect.gen(function* () {
|
|
32
|
+
const version = yield* protocol(sql);
|
|
33
|
+
const fields = version >= 4
|
|
34
|
+
? targetFields
|
|
35
|
+
: "generation::text, source_id::text, from_revision::text, manifest, selection";
|
|
36
|
+
const rows = yield* sql.unsafe(`SELECT ${fields} FROM _ontology_replication_targets
|
|
37
|
+
WHERE generation = (SELECT active_generation FROM _ontology_replication WHERE id)`);
|
|
38
|
+
return rows[0];
|
|
39
|
+
});
|
|
40
|
+
export const pauseReplication = (sql) => sql.withTransaction(Effect.gen(function* () {
|
|
41
|
+
yield* lockReplicationLifecycle(sql);
|
|
42
|
+
yield* sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
|
|
22
43
|
}));
|
|
23
|
-
export const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
export const replicationTarget = (sql, generation) => Effect.gen(function* () {
|
|
45
|
+
const version = yield* protocol(sql);
|
|
46
|
+
const fields = version >= 4
|
|
47
|
+
? targetFields
|
|
48
|
+
: "generation::text, source_id::text, from_revision::text, manifest, selection";
|
|
49
|
+
const rows = yield* sql.unsafe(`SELECT ${fields} FROM _ontology_replication_targets WHERE generation = $1::uuid`, [generation]);
|
|
50
|
+
return rows[0];
|
|
51
|
+
});
|
|
52
|
+
export { advanceReplicationBaseline };
|
|
53
|
+
/** Retained history is recovery storage; frequent delivery reads only indexed outstanding references. */
|
|
54
|
+
export const pendingReplicationEvents = (sql, target) => Effect.gen(function* () {
|
|
55
|
+
if (target.delivery_version !== 4 &&
|
|
56
|
+
target.protocol !== 4) {
|
|
57
|
+
return yield* legacy.pendingReplicationEvents(sql, target);
|
|
58
|
+
}
|
|
59
|
+
const references = yield* sql.unsafe(`SELECT event_id::text AS id, history_table FROM _ontology_replication_delivery
|
|
60
|
+
WHERE generation = $1::uuid AND accepted_at IS NULL
|
|
61
|
+
ORDER BY revision, event_id LIMIT ${REPLICATION_BATCH_SIZE}`, [target.generation]);
|
|
62
|
+
const groups = new Map();
|
|
63
|
+
for (const reference of references) {
|
|
64
|
+
const ids = groups.get(reference.history_table) ?? [];
|
|
65
|
+
ids.push(reference.id);
|
|
66
|
+
groups.set(reference.history_table, ids);
|
|
67
|
+
}
|
|
68
|
+
const events = new Map();
|
|
69
|
+
for (const [table, ids] of groups) {
|
|
70
|
+
const rows = yield* sql.unsafe(table === null
|
|
71
|
+
? `SELECT id::text, event FROM _ontology_replication_baselines
|
|
72
|
+
WHERE generation = $1::uuid AND id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`
|
|
73
|
+
: `SELECT id::text, replication AS event FROM ${qi(table)}
|
|
74
|
+
WHERE $1::uuid IS NOT NULL AND id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [target.generation, JSON.stringify(ids)]);
|
|
75
|
+
for (const row of rows) {
|
|
76
|
+
events.set(row.id, row.event);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
51
79
|
let size = 32;
|
|
52
80
|
const result = [];
|
|
53
|
-
for (const
|
|
81
|
+
for (const reference of references) {
|
|
82
|
+
const payload = events.get(reference.id);
|
|
83
|
+
if (!payload) {
|
|
84
|
+
return yield* Effect.die(new Error("A queued replication event is missing from retained history."));
|
|
85
|
+
}
|
|
54
86
|
const event = {
|
|
55
|
-
...
|
|
87
|
+
...payload,
|
|
56
88
|
generation: target.generation,
|
|
57
|
-
event_id: replicationEventId(target.source_id,
|
|
89
|
+
event_id: replicationEventId(target.source_id, reference.id, target.generation),
|
|
58
90
|
};
|
|
59
91
|
const bytes = Buffer.byteLength(JSON.stringify(publishMessage(event))) + 1;
|
|
60
92
|
if (bytes + 32 > REPLICATION_BATCH_BYTES) {
|
|
61
|
-
|
|
93
|
+
return yield* Effect.die(new Error("A replication event exceeds the publish request limit."));
|
|
62
94
|
}
|
|
63
95
|
if (size + bytes > REPLICATION_BATCH_BYTES) {
|
|
64
96
|
break;
|
|
65
97
|
}
|
|
66
98
|
size += bytes;
|
|
67
|
-
result.push({ historyId:
|
|
99
|
+
result.push({ historyId: reference.id, event });
|
|
68
100
|
}
|
|
69
101
|
return result;
|
|
102
|
+
});
|
|
103
|
+
/** Lost publish responses deliberately leave the same immutable IDs pending. */
|
|
104
|
+
const acknowledgeReplicationV4 = (sql, generation, ids) => sql.withTransaction(Effect.gen(function* () {
|
|
105
|
+
// Receipt foreign keys touch targets after delivery rows; drain before migration table locks.
|
|
106
|
+
yield* shareReplicationLifecycle(sql);
|
|
107
|
+
yield* sql.unsafe(`WITH accepted AS (
|
|
108
|
+
UPDATE _ontology_replication_delivery SET accepted_at = now(), next_verify_at = now()
|
|
109
|
+
WHERE generation = $1::uuid AND accepted_at IS NULL
|
|
110
|
+
AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))
|
|
111
|
+
RETURNING event_id, accepted_at
|
|
112
|
+
) INSERT INTO _ontology_replication_receipts (generation, event_id, accepted_at)
|
|
113
|
+
SELECT $1::uuid, event_id, accepted_at FROM accepted ON CONFLICT DO NOTHING`, [generation, JSON.stringify(ids)]);
|
|
114
|
+
}));
|
|
115
|
+
export const replicationStatus = (sql, target) => target.delivery_version !== 4 &&
|
|
116
|
+
target.protocol !== 4
|
|
117
|
+
? legacy.replicationStatus(sql, target)
|
|
118
|
+
: sql
|
|
119
|
+
.unsafe(`SELECT (t.verified_count + outstanding.total)::text AS total,
|
|
120
|
+
(t.verified_count + outstanding.accepted)::text AS accepted, t.verified_count::text AS verified
|
|
121
|
+
FROM _ontology_replication_targets t CROSS JOIN LATERAL (
|
|
122
|
+
SELECT count(*) AS total, count(accepted_at) AS accepted FROM _ontology_replication_delivery
|
|
123
|
+
WHERE generation = t.generation AND verified_at IS NULL
|
|
124
|
+
) outstanding WHERE t.generation = $1::uuid`, [target.generation])
|
|
125
|
+
.pipe(Effect.map((rows) => rows[0]));
|
|
126
|
+
/** Initialization is a fixed fence, so continuing writes cannot move the readiness goal. */
|
|
127
|
+
export const replicationInitiallyReady = (sql, generation) => sql
|
|
128
|
+
.unsafe(`SELECT baseline_complete AND backfill_complete AND replay_complete AND NOT EXISTS (
|
|
129
|
+
SELECT 1 FROM _ontology_replication_delivery d WHERE d.generation = t.generation
|
|
130
|
+
AND d.verified_at IS NULL AND (d.is_baseline OR d.revision <= t.initial_revision)
|
|
131
|
+
) AS ready FROM _ontology_replication_targets t WHERE generation = $1::uuid`, [generation])
|
|
132
|
+
.pipe(Effect.map((rows) => rows[0]?.ready ?? false));
|
|
133
|
+
export const unverifiedReplicationEvents = (sql, target) => target.delivery_version !== 4 &&
|
|
134
|
+
target.protocol !== 4
|
|
135
|
+
? legacy.unverifiedReplicationEvents(sql, target)
|
|
136
|
+
: sql
|
|
137
|
+
.unsafe(`SELECT event_id::text AS id FROM _ontology_replication_delivery
|
|
138
|
+
WHERE generation = $1::uuid AND accepted_at IS NOT NULL AND verified_at IS NULL
|
|
139
|
+
AND next_verify_at <= now() ORDER BY next_verify_at, event_id LIMIT 1000`, [target.generation])
|
|
140
|
+
.pipe(Effect.map((rows) => rows.map(({ id }) => ({
|
|
141
|
+
historyId: id,
|
|
142
|
+
eventId: replicationEventId(target.source_id, id, target.generation),
|
|
143
|
+
}))));
|
|
144
|
+
const verifyReplicationEventsV4 = (sql, generation, ids) => sql.unsafe(`WITH locked AS MATERIALIZED (
|
|
145
|
+
SELECT generation FROM _ontology_replication_targets WHERE generation = $1::uuid FOR NO KEY UPDATE
|
|
146
|
+
), verified AS (
|
|
147
|
+
UPDATE _ontology_replication_delivery SET verified_at = now(), last_checked_at = now()
|
|
148
|
+
WHERE generation = (SELECT generation FROM locked) AND accepted_at IS NOT NULL AND verified_at IS NULL
|
|
149
|
+
AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))
|
|
150
|
+
RETURNING event_id, verified_at
|
|
151
|
+
), receipts AS (
|
|
152
|
+
UPDATE _ontology_replication_receipts r SET verified_at = v.verified_at FROM verified v
|
|
153
|
+
WHERE r.generation = $1::uuid AND r.event_id = v.event_id
|
|
154
|
+
) UPDATE _ontology_replication_targets SET verified_count = verified_count + (SELECT count(*) FROM verified)
|
|
155
|
+
WHERE generation = $1::uuid`, [generation, JSON.stringify(ids)]);
|
|
156
|
+
/** Retry only IDs actually checked and absent; a verification backlog is never a delivery failure. */
|
|
157
|
+
const retryUnverifiedReplicationV4 = (sql, generation, missingIds = []) => sql.unsafe(`WITH checked AS (
|
|
158
|
+
UPDATE _ontology_replication_delivery SET last_checked_at = now(), next_verify_at = now() + interval '30 seconds',
|
|
159
|
+
accepted_at = CASE WHEN accepted_at < now() - interval '15 minutes' THEN NULL ELSE accepted_at END
|
|
160
|
+
WHERE generation = $1::uuid AND accepted_at IS NOT NULL AND verified_at IS NULL
|
|
161
|
+
AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))
|
|
162
|
+
RETURNING event_id, accepted_at
|
|
163
|
+
) DELETE FROM _ontology_replication_receipts r USING checked c
|
|
164
|
+
WHERE r.generation = $1::uuid AND r.event_id = c.event_id AND c.accepted_at IS NULL`, [generation, JSON.stringify(missingIds)]);
|
|
165
|
+
/** Replay initialization is constant time; reset work is resumed in bounded pages. */
|
|
166
|
+
const replayReplicationV4 = (sql, generation) => sql.withTransaction(Effect.gen(function* () {
|
|
167
|
+
yield* lockReplicationLifecycle(sql);
|
|
168
|
+
yield* sql.unsafe(`UPDATE _ontology_replication_targets SET replay_complete = false, replay_cursor = NULL,
|
|
169
|
+
initial_revision = (SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM _ontology_replication_revision)
|
|
170
|
+
WHERE generation = $1::uuid`, [generation]);
|
|
171
|
+
}));
|
|
172
|
+
export const advanceReplicationReplay = (sql, generation) => sql.withTransaction(Effect.gen(function* () {
|
|
173
|
+
const [target] = yield* sql.unsafe(`SELECT replay_complete, replay_cursor::text, initial_revision::text FROM _ontology_replication_targets
|
|
174
|
+
WHERE generation = $1::uuid FOR NO KEY UPDATE`, [generation]);
|
|
175
|
+
if (!target || target.replay_complete) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
const rows = yield* sql.unsafe(`SELECT event_id::text AS id, verified_at IS NOT NULL AS verified FROM _ontology_replication_delivery
|
|
179
|
+
WHERE generation = $1::uuid AND ($2::uuid IS NULL OR event_id > $2::uuid)
|
|
180
|
+
AND (is_baseline OR revision <= $3::bigint)
|
|
181
|
+
ORDER BY event_id LIMIT 1000`, [generation, target.replay_cursor, target.initial_revision]);
|
|
182
|
+
const ids = JSON.stringify(rows.map((row) => row.id));
|
|
183
|
+
yield* sql.unsafe(`UPDATE _ontology_replication_delivery SET accepted_at = NULL, verified_at = NULL, next_verify_at = now()
|
|
184
|
+
WHERE generation = $1::uuid AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, ids]);
|
|
185
|
+
yield* sql.unsafe(`DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid
|
|
186
|
+
AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, ids]);
|
|
187
|
+
const complete = rows.length < 1000;
|
|
188
|
+
yield* sql.unsafe(`UPDATE _ontology_replication_targets SET replay_cursor = $2::uuid, replay_complete = $3,
|
|
189
|
+
verified_count = verified_count - $4::bigint WHERE generation = $1::uuid`, [
|
|
190
|
+
generation,
|
|
191
|
+
rows.at(-1)?.id ?? target.replay_cursor,
|
|
192
|
+
complete,
|
|
193
|
+
rows.filter((row) => row.verified).length,
|
|
194
|
+
]);
|
|
195
|
+
return complete;
|
|
196
|
+
}));
|
|
197
|
+
/** Static pre-upgrade histories have no new inserts: v4 writers atomically enqueue new history. */
|
|
198
|
+
export const advanceReplicationBackfill = (sql, generation) => sql.withTransaction(Effect.gen(function* () {
|
|
199
|
+
yield* shareReplicationLifecycle(sql);
|
|
200
|
+
const [target] = yield* sql.unsafe(`SELECT ${targetFields}, backfill_cursor, backfill_revision::text
|
|
201
|
+
FROM _ontology_replication_targets WHERE generation = $1::uuid FOR NO KEY UPDATE`, [generation]);
|
|
202
|
+
if (!target || target.backfill_complete) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
const catalog = yield* readCatalog({ sql });
|
|
206
|
+
const tables = [
|
|
207
|
+
null,
|
|
208
|
+
...target.selection.map((item) => {
|
|
209
|
+
const entity = (catalog ?? target.manifest).entities.find((entity) => entity.id === item.entityId) ??
|
|
210
|
+
target.manifest.entities.find((entity) => entity.id === item.entityId);
|
|
211
|
+
return historyTableName(entity.name);
|
|
212
|
+
}),
|
|
213
|
+
];
|
|
214
|
+
const position = target.backfill_cursor.table ?? 0;
|
|
215
|
+
const table = tables[position];
|
|
216
|
+
const cursor = target.backfill_cursor.id ?? null;
|
|
217
|
+
const rows = yield* sql.unsafe(table === null
|
|
218
|
+
? `SELECT id::text FROM _ontology_replication_baselines WHERE generation = $1::uuid
|
|
219
|
+
AND ($2::uuid IS NULL OR id > $2::uuid) ORDER BY id LIMIT 1000`
|
|
220
|
+
: `SELECT id::text FROM ${qi(table)} WHERE replication IS NOT NULL
|
|
221
|
+
AND replication->>'source_id' = $3 AND (replication->>'revision')::bigint > $4::bigint
|
|
222
|
+
AND (replication->>'revision')::bigint <= $5::bigint
|
|
223
|
+
AND ($2::uuid IS NULL OR id > $2::uuid) AND $1::uuid IS NOT NULL ORDER BY id LIMIT 1000`, table === null
|
|
224
|
+
? [generation, cursor]
|
|
225
|
+
: [generation, cursor, target.source_id, target.from_revision, target.backfill_revision]);
|
|
226
|
+
const ids = JSON.stringify(rows.map((row) => row.id));
|
|
227
|
+
const eventSelect = table === null
|
|
228
|
+
? `SELECT id, event FROM _ontology_replication_baselines WHERE generation = $1::uuid`
|
|
229
|
+
: `SELECT id, replication AS event FROM ${qi(table)}`;
|
|
230
|
+
yield* sql.unsafe(`WITH inserted AS (
|
|
231
|
+
INSERT INTO _ontology_replication_delivery
|
|
232
|
+
(generation, event_id, history_table, revision, is_baseline, accepted_at, verified_at, next_verify_at)
|
|
233
|
+
SELECT $1::uuid, e.id, $3::text, (e.event->>'revision')::bigint, $4::boolean,
|
|
234
|
+
r.accepted_at, r.verified_at, now() FROM (${eventSelect}) e
|
|
235
|
+
LEFT JOIN _ontology_replication_receipts r ON r.generation = $1::uuid AND r.event_id = e.id
|
|
236
|
+
WHERE e.id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))
|
|
237
|
+
ON CONFLICT DO NOTHING RETURNING verified_at
|
|
238
|
+
) UPDATE _ontology_replication_targets SET verified_count = verified_count +
|
|
239
|
+
(SELECT count(verified_at) FROM inserted) WHERE generation = $1::uuid`, [generation, ids, table, table === null]);
|
|
240
|
+
const finishedTable = rows.length < 1000;
|
|
241
|
+
const complete = finishedTable && position + 1 >= tables.length;
|
|
242
|
+
const next = finishedTable
|
|
243
|
+
? { table: position + 1 }
|
|
244
|
+
: { table: position, id: rows.at(-1).id };
|
|
245
|
+
yield* sql.unsafe(`UPDATE _ontology_replication_targets SET backfill_cursor = $2::jsonb, backfill_complete = $3
|
|
246
|
+
WHERE generation = $1::uuid`, [generation, JSON.stringify(next), complete]);
|
|
247
|
+
return complete;
|
|
70
248
|
}));
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
export const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
249
|
+
export const acknowledgeReplication = (sql, generation, ids, version) => Effect.gen(function* () {
|
|
250
|
+
const current = version ?? (yield* protocol(sql));
|
|
251
|
+
yield* current >= 4
|
|
252
|
+
? acknowledgeReplicationV4(sql, generation, ids)
|
|
253
|
+
: legacy.acknowledgeReplication(sql, generation, ids);
|
|
254
|
+
});
|
|
255
|
+
export const verifyReplicationEvents = (sql, generation, ids, version) => Effect.gen(function* () {
|
|
256
|
+
const current = version ?? (yield* protocol(sql));
|
|
257
|
+
yield* current >= 4
|
|
258
|
+
? verifyReplicationEventsV4(sql, generation, ids)
|
|
259
|
+
: legacy.verifyReplicationEvents(sql, generation, ids);
|
|
260
|
+
});
|
|
261
|
+
export const retryUnverifiedReplication = (sql, generation, ids = [], version) => Effect.gen(function* () {
|
|
262
|
+
const current = version ?? (yield* protocol(sql));
|
|
263
|
+
if (current >= 4) {
|
|
264
|
+
yield* retryUnverifiedReplicationV4(sql, generation, ids);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Even legacy recovery retries only checked missing IDs, never the unchecked backlog.
|
|
268
|
+
yield* sql.unsafe(`DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid
|
|
269
|
+
AND verified_at IS NULL AND accepted_at < now() - interval '15 minutes'
|
|
270
|
+
AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, JSON.stringify(ids)]);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
export const replayReplication = (sql, generation) => Effect.gen(function* () {
|
|
274
|
+
const current = yield* protocol(sql);
|
|
275
|
+
if (current >= 4) {
|
|
276
|
+
yield* replayReplicationV4(sql, generation);
|
|
277
|
+
yield* advanceReplicationReplay(sql, generation);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
yield* legacy.replayReplication(sql, generation);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import type { SqlClient } from "effect/unstable/sql";
|
|
3
|
+
export declare const REPLICATION_LIFECYCLE_LOCK = "segmentstream.ontology.replication.lifecycle";
|
|
4
|
+
/** Legacy writers take the singleton first; every lifecycle operation keeps that order. */
|
|
5
|
+
export declare const lockReplicationLifecycle: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
6
|
+
export declare const shareReplicationLifecycle: (sql: SqlClient.SqlClient) => import("effect/unstable/sql/Statement").Statement<object>;
|
|
3
7
|
/** Must run inside the write transaction, before any business row locks. */
|
|
4
8
|
export declare const beginReplicationWrite: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
export const REPLICATION_LIFECYCLE_LOCK = "segmentstream.ontology.replication.lifecycle";
|
|
3
|
+
/** Legacy writers take the singleton first; every lifecycle operation keeps that order. */
|
|
4
|
+
export const lockReplicationLifecycle = (sql) => Effect.gen(function* () {
|
|
5
|
+
yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
|
|
6
|
+
yield* sql.unsafe("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
|
|
7
|
+
REPLICATION_LIFECYCLE_LOCK,
|
|
8
|
+
]);
|
|
9
|
+
});
|
|
10
|
+
export const shareReplicationLifecycle = (sql) => sql.unsafe("SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))", [
|
|
11
|
+
REPLICATION_LIFECYCLE_LOCK,
|
|
12
|
+
]);
|
|
2
13
|
/** Must run inside the write transaction, before any business row locks. */
|
|
3
14
|
export const beginReplicationWrite = (sql) => Effect.gen(function* () {
|
|
4
15
|
const [schema] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
|
|
5
16
|
if (!schema?.present) {
|
|
6
17
|
return;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
18
|
+
}
|
|
19
|
+
// Shared admission permits independent writes while lifecycle changes drain admitted writers.
|
|
20
|
+
const [state] = yield* sql.unsafe("SELECT version FROM _ontology_replication WHERE id");
|
|
21
|
+
if ((state?.version ?? 0) < 4) {
|
|
22
|
+
// A new service may be deployed before its owner applies the protocol upgrade.
|
|
23
|
+
yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
|
|
24
|
+
yield* sql.unsafe("SELECT set_config('segmentstream.writer', '1', true)");
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
yield* shareReplicationLifecycle(sql);
|
|
28
|
+
yield* sql.unsafe("SELECT set_config('segmentstream.writer', '2', true)");
|
|
29
|
+
}
|
|
11
30
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
/** Admission happens before reserving SQL connections; two pool slots remain for other work. */
|
|
3
|
+
export declare const ACTION_TRANSACTION_LIMITS: {
|
|
4
|
+
readonly concurrency: 3;
|
|
5
|
+
readonly maxWaiting: 32;
|
|
6
|
+
readonly queueTimeoutMs: 5000;
|
|
7
|
+
readonly handlerTimeoutMs: 60000;
|
|
8
|
+
readonly statementTimeoutMs: 30000;
|
|
9
|
+
readonly lockTimeoutMs: 5000;
|
|
10
|
+
};
|
|
11
|
+
export interface ActionTransactionLimits {
|
|
12
|
+
readonly handlerTimeoutMs: number;
|
|
13
|
+
readonly statementTimeoutMs: number;
|
|
14
|
+
readonly lockTimeoutMs: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class ActionTransactionDeadline extends Error {
|
|
17
|
+
readonly name = "ActionTransactionDeadline";
|
|
18
|
+
}
|
|
19
|
+
export declare class ActionAdmissionRejected extends Error {
|
|
20
|
+
readonly name = "ActionAdmissionRejected";
|
|
21
|
+
}
|
|
22
|
+
export interface ActionTransactionsService {
|
|
23
|
+
readonly limits: ActionTransactionLimits;
|
|
24
|
+
readonly admit: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
|
|
25
|
+
}
|
|
26
|
+
declare const ActionTransactions_base: Context.ServiceClass<ActionTransactions, "segmentstream/ontology/ActionTransactions", ActionTransactionsService>;
|
|
27
|
+
/** Internal configuration and one admission gate per service instance, never per request. */
|
|
28
|
+
export declare class ActionTransactions extends ActionTransactions_base {
|
|
29
|
+
}
|
|
30
|
+
export declare function makeActionTransactions(options?: ActionTransactionLimits & {
|
|
31
|
+
readonly concurrency: number;
|
|
32
|
+
readonly maxWaiting: number;
|
|
33
|
+
readonly queueTimeoutMs: number;
|
|
34
|
+
}): ActionTransactionsService;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Context, Duration, Effect, Exit, Semaphore } from "effect";
|
|
2
|
+
/** Admission happens before reserving SQL connections; two pool slots remain for other work. */
|
|
3
|
+
export const ACTION_TRANSACTION_LIMITS = {
|
|
4
|
+
concurrency: 3,
|
|
5
|
+
maxWaiting: 32,
|
|
6
|
+
queueTimeoutMs: 5_000,
|
|
7
|
+
handlerTimeoutMs: 60_000,
|
|
8
|
+
statementTimeoutMs: 30_000,
|
|
9
|
+
lockTimeoutMs: 5_000,
|
|
10
|
+
};
|
|
11
|
+
export class ActionTransactionDeadline extends Error {
|
|
12
|
+
name = "ActionTransactionDeadline";
|
|
13
|
+
}
|
|
14
|
+
export class ActionAdmissionRejected extends Error {
|
|
15
|
+
name = "ActionAdmissionRejected";
|
|
16
|
+
}
|
|
17
|
+
/** Internal configuration and one admission gate per service instance, never per request. */
|
|
18
|
+
export class ActionTransactions extends Context.Service()("segmentstream/ontology/ActionTransactions") {
|
|
19
|
+
}
|
|
20
|
+
export function makeActionTransactions(options = ACTION_TRANSACTION_LIMITS) {
|
|
21
|
+
const permits = Semaphore.makeUnsafe(options.concurrency);
|
|
22
|
+
let waiting = 0;
|
|
23
|
+
let active = 0;
|
|
24
|
+
return {
|
|
25
|
+
limits: options,
|
|
26
|
+
admit: (effect) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
|
|
27
|
+
const queuedAt = Date.now();
|
|
28
|
+
if (!(yield* permits.takeIfAvailable(1))) {
|
|
29
|
+
if (waiting >= options.maxWaiting) {
|
|
30
|
+
yield* Effect.logWarning("ontology.action.admission_rejected").pipe(Effect.annotateLogs({ active, waiting, reason: "queue_full" }));
|
|
31
|
+
return yield* Effect.die(new ActionAdmissionRejected("Action write queue is full."));
|
|
32
|
+
}
|
|
33
|
+
waiting += 1;
|
|
34
|
+
yield* restore(permits.take(1).pipe(Effect.timeoutOrElse({
|
|
35
|
+
duration: Duration.millis(options.queueTimeoutMs),
|
|
36
|
+
orElse: () => Effect.logWarning("ontology.action.admission_rejected").pipe(Effect.annotateLogs({ active, waiting, reason: "queue_timeout" }), Effect.andThen(Effect.die(new ActionAdmissionRejected("Action write admission timed out.")))),
|
|
37
|
+
}))).pipe(Effect.ensuring(Effect.sync(() => {
|
|
38
|
+
waiting -= 1;
|
|
39
|
+
})));
|
|
40
|
+
}
|
|
41
|
+
active += 1;
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
return yield* restore(effect).pipe(Effect.onExit((exit) => Effect.logDebug("ontology.action.transaction_completed").pipe(Effect.annotateLogs({
|
|
44
|
+
active,
|
|
45
|
+
waiting,
|
|
46
|
+
queueMs: startedAt - queuedAt,
|
|
47
|
+
transactionMs: Date.now() - startedAt,
|
|
48
|
+
outcome: Exit.isSuccess(exit) ? "success" : "failure",
|
|
49
|
+
}))), Effect.ensuring(Effect.sync(() => {
|
|
50
|
+
active -= 1;
|
|
51
|
+
}).pipe(Effect.andThen(permits.release(1)))));
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
}
|