@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.
@@ -16,6 +16,7 @@ import { Edit } from "./history.js";
16
16
  import { makeOntologyRuntime } from "./sql-runtime.js";
17
17
  import { AgentFileRecords, resolveFileRecord } from "./files.js";
18
18
  import { validate } from "./validation.js";
19
+ import { ACTION_TRANSACTION_LIMITS, ActionTransactionDeadline, ActionTransactions, } from "./action-transactions.js";
19
20
  /** How long `prepare` may take, connection calls included; each call has its own 30 s deadline. */
20
21
  export const PREPARE_TIMEOUT = Duration.seconds(60);
21
22
  export const ActionValidation = Schema.Struct({
@@ -173,6 +174,7 @@ export function runAction(ontology, request) {
173
174
  ...(request.branchId ? { branchId: request.branchId } : {}),
174
175
  ...(request.options?.createIntent ? { createIntent: request.options.createIntent } : {}),
175
176
  onEdit: (edit) => edits.push(edit),
177
+ replicationInitialized: true,
176
178
  });
177
179
  const handle = action.handler;
178
180
  const handler = Effect.suspend(() => handle({ ...context, prepared }));
@@ -209,9 +211,21 @@ export function runAction(ontology, request) {
209
211
  }
210
212
  return result;
211
213
  });
212
- const result = yield* sql
213
- .withTransaction(beginReplicationWrite(sql).pipe(Effect.andThen(transactionalHandler)))
214
- .pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
214
+ const admission = Option.getOrUndefined(yield* Effect.serviceOption(ActionTransactions));
215
+ const limits = admission?.limits ?? ACTION_TRANSACTION_LIMITS;
216
+ const transactionBody = Effect.gen(function* () {
217
+ // Server deadlines also protect the reserved connection if client cancellation cannot arrive.
218
+ yield* sql.unsafe(`SELECT set_config('statement_timeout', $1, true), set_config('lock_timeout', $2, true)`, [`${limits.statementTimeoutMs}ms`, `${limits.lockTimeoutMs}ms`]);
219
+ yield* beginReplicationWrite(sql);
220
+ return yield* transactionalHandler;
221
+ }).pipe(Effect.timeoutOrElse({
222
+ duration: Duration.millis(limits.handlerTimeoutMs),
223
+ orElse: () => Effect.logWarning("ontology.action.transaction_deadline").pipe(Effect.annotateLogs({ deadlineMs: limits.handlerTimeoutMs }), Effect.andThen(Effect.die(new ActionTransactionDeadline(`${qualified} exceeded its transaction deadline.`)))),
224
+ }));
225
+ // Time out the interruptible body only. The SQL manager must finish rollback/commit and
226
+ // release its connection; a lost COMMIT response must never cause an automatic replay.
227
+ const transaction = sql.withTransaction(transactionBody);
228
+ const result = yield* (admission ? admission.admit(transaction) : transaction).pipe(Effect.annotateLogs({ action: qualified, actionId, requestId }), Effect.catchIf(isSqlError, (error) => Effect.die(error)));
215
229
  if (replay) {
216
230
  return replay;
217
231
  }
@@ -14,6 +14,7 @@ import { runAction } from "./pipeline.js";
14
14
  import { checkSignIn, resolvePrincipalByApiKey, resolvePrincipalByIdentity, resolvePipelineWorker, } from "./principals.js";
15
15
  import { postgresTls } from "./postgres-tls.js";
16
16
  import { makeOntologyRuntime } from "./sql-runtime.js";
17
+ import { ActionTransactions, makeActionTransactions } from "./action-transactions.js";
17
18
  /** Connections one service instance holds to the database's pooler. */
18
19
  const MAX_CONNECTIONS = 5;
19
20
  /**
@@ -28,6 +29,7 @@ export function backendLayer(ontology, blobStorage) {
28
29
  return Layer.effect(OntologyBackend, Effect.gen(function* () {
29
30
  const sql = yield* SqlClient.SqlClient;
30
31
  const runtime = makeOntologyRuntime(ontology, sql);
32
+ const actionTransactions = makeActionTransactions();
31
33
  const withSql = (effect) => Effect.provideService(effect, SqlClient.SqlClient, sql);
32
34
  const binding = blobStorage ? Redacted.value(blobStorage) : undefined;
33
35
  const provider = binding
@@ -49,7 +51,7 @@ export function backendLayer(ontology, blobStorage) {
49
51
  afterCommit: input.afterCommit === undefined ? [] : [input.afterCommit],
50
52
  ...(input.requestId === undefined ? {} : { requestId: input.requestId }),
51
53
  ...(input.connections === undefined ? {} : { connections: input.connections }),
52
- })).pipe(Effect.catchTag("UnknownAction", (error) => Effect.die(error))),
54
+ })).pipe(Effect.provideService(ActionTransactions, actionTransactions), Effect.catchTag("UnknownAction", (error) => Effect.die(error))),
53
55
  get: (input) => runtime.get(input),
54
56
  find: (input) => runtime.query(input),
55
57
  history: (input) => runtime.history(input),
@@ -87,6 +89,7 @@ export function postgresLayer(databaseUrl) {
87
89
  url: databaseUrl,
88
90
  ssl: postgresTls(databaseUrl),
89
91
  maxConnections: MAX_CONNECTIONS,
92
+ connectTimeout: "5 seconds",
90
93
  applicationName: "segmentstream-ontology",
91
94
  });
92
95
  }
@@ -11,6 +11,8 @@ import type { Edit, HistoryEntry, HistoryScope } from "./history.js";
11
11
  * `created_by`/`updated_by` to its principal.
12
12
  */
13
13
  export interface WriteScope extends HistoryScope {
14
+ /** Set only when the enclosing action initialized replication in its outer transaction. */
15
+ readonly replicationInitialized?: boolean;
14
16
  /** Receives the edits of each write that succeeded, in order. */
15
17
  readonly onEdit?: (edit: Edit) => void;
16
18
  }
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect";
1
+ import { Effect, Option } from "effect";
2
2
  import { beginReplicationWrite } from "../replication/transaction.js";
3
3
  import { isSqlError } from "effect/unstable/sql/SqlError";
4
4
  import { Conflict } from "../errors.js";
@@ -100,8 +100,10 @@ export function makeOntologyRuntime(ontology, sql, scope) {
100
100
  }));
101
101
  }
102
102
  const edits = [];
103
+ const enclosingTransaction = yield* Effect.serviceOption(sql.transactionService);
104
+ const initialized = scope.replicationInitialized && Option.isSome(enclosingTransaction);
103
105
  const result = yield* sql
104
- .withTransaction(beginReplicationWrite(sql).pipe(Effect.andThen(operation({ ...context, scope, edits }, entity))))
106
+ .withTransaction((initialized ? Effect.void : beginReplicationWrite(sql)).pipe(Effect.andThen(operation({ ...context, scope, edits }, entity))))
105
107
  .pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error)));
106
108
  for (const edit of edits) {
107
109
  scope.onEdit?.(edit);
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect";
2
2
  import type { OntologyEnvironment } from "./config.js";
3
3
  export type ReplicationKick = (identity: string | undefined) => Effect.Effect<void>;
4
- /** A fast path only. The committed history, not this request, makes delivery durable. */
4
+ /** Coalesce best-effort wakes without adding network latency to committed actions. Durable recovery owns retries. */
5
5
  export declare function deployedReplication(environment: OntologyEnvironment, fetchImpl?: typeof fetch): ReplicationKick;
@@ -1,25 +1,36 @@
1
1
  import { Effect } from "effect";
2
- /** A fast path only. The committed history, not this request, makes delivery durable. */
2
+ /** Coalesce best-effort wakes without adding network latency to committed actions. Durable recovery owns retries. */
3
3
  export function deployedReplication(environment, fetchImpl = fetch) {
4
+ let inFlight = false;
5
+ let nextWakeAt = 0;
4
6
  return (identity) => {
5
7
  if (!identity) {
6
8
  return Effect.void;
7
9
  }
8
- return Effect.tryPromise(async () => {
9
- const response = await fetchImpl(`${environment.segmentstreamUrl}/api/internal/v1/workspaces/` +
10
- `${encodeURIComponent(environment.workspaceId)}/ontology/replication/drain`, {
11
- method: "POST",
12
- redirect: "error",
13
- signal: AbortSignal.timeout(5000),
14
- headers: {
15
- "x-segmentstream-runtime-identity": identity,
16
- "x-segmentstream-organization-id": environment.organizationId,
17
- },
18
- });
19
- await response.body?.cancel();
20
- if (!response.ok) {
21
- throw new Error(`Replication wake-up returned HTTP ${response.status}.`);
10
+ return Effect.suspend(() => {
11
+ if (inFlight || Date.now() < nextWakeAt) {
12
+ return Effect.void;
22
13
  }
23
- }).pipe(Effect.catchCause((cause) => Effect.logWarning("ontology.replication.deferred", cause)));
14
+ inFlight = true;
15
+ nextWakeAt = Date.now() + 1000;
16
+ return Effect.tryPromise(async () => {
17
+ const response = await fetchImpl(`${environment.segmentstreamUrl}/api/internal/v1/workspaces/` +
18
+ `${encodeURIComponent(environment.workspaceId)}/ontology/replication/drain`, {
19
+ method: "POST",
20
+ redirect: "error",
21
+ signal: AbortSignal.timeout(5000),
22
+ headers: {
23
+ "x-segmentstream-runtime-identity": identity,
24
+ "x-segmentstream-organization-id": environment.organizationId,
25
+ },
26
+ });
27
+ await response.body?.cancel();
28
+ if (!response.ok) {
29
+ throw new Error(`Replication wake-up returned HTTP ${response.status}.`);
30
+ }
31
+ }).pipe(Effect.catchCause((cause) => Effect.logWarning("ontology.replication.deferred", cause)), Effect.ensuring(Effect.sync(() => {
32
+ inFlight = false;
33
+ })), Effect.forkDetach, Effect.asVoid);
34
+ });
24
35
  };
25
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",