@sgtbeatdown/pi-tasks 0.1.13 → 0.1.15

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/src/task-store.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { TASK_WORK_LIMITS } from "./task-work-scope";
1
2
  import { TERMINAL_INTENT_OPERATION, TaskProtocolError } from "./task-protocol";
2
3
  import type { RelayTransportBinding, RelayEnvelope, TaskEndpoint, TaskEvent, TaskRecord, TaskSnapshot, TerminalDeliveryState, TerminalTaskIntentType } from "./task-protocol";
3
4
 
@@ -25,6 +26,12 @@ export interface TaskStore {
25
26
  appendEvent(event: TaskEvent): boolean;
26
27
  putOutbox(envelope: RelayEnvelope): void;
27
28
  outbox(state: OutboxRecord["state"]): readonly OutboxRecord[];
29
+ /** Indexed lookup, fair rotation and cooldown are required store capabilities. */
30
+ getOutboxRecord(envelopeId: string): OutboxRecord | undefined;
31
+ hasPendingOutbox(): boolean;
32
+ selectPendingOutbox(limit: number, now: number, maxInspections?: number): readonly OutboxRecord[];
33
+ outboxRetryAt(envelopeId: string): number;
34
+ postponeOutbox(envelopeId: string, until: number): void;
28
35
  markOutboxAccepted(envelopeId: string): void;
29
36
  quarantineOutbox(envelopeId: string, input: OutboxQuarantineInput): void;
30
37
  quarantinedOutbox(): readonly OutboxQuarantineRecord[];
@@ -33,10 +40,13 @@ export interface TaskStore {
33
40
  setReceiveCursor(cursor: string): void;
34
41
  trackRelayDeliveries(endpoint: TaskEndpoint, deliveries: readonly { readonly cursor: string; readonly envelopeId: string }[]): void;
35
42
  pendingRelayEnvelopeId(endpoint: TaskEndpoint, cursor: string): string | undefined;
36
- requestRelayAcknowledgement(endpoint: TaskEndpoint, cursor: string): void;
37
- requestedRelayAcknowledgements(endpoint: TaskEndpoint): readonly string[];
43
+ requestRelayAcknowledgement(endpoint: TaskEndpoint, cursor: string, retryAt?: number): void;
44
+ requestedRelayAcknowledgements(endpoint: TaskEndpoint, limit?: number, now?: number): readonly string[];
45
+ relayAcknowledgementRetryAt(endpoint: TaskEndpoint, cursor: string): number;
38
46
  acknowledgeRelayCursor(endpoint: TaskEndpoint, cursor: string): void;
39
47
  putIntent(intentId: string, taskId: string, envelopeId: string): void;
48
+ /** Locally submitted intent only; retained through acceptance or quarantine in this RAM lifetime. */
49
+ getIntentEnvelope(intentId: string): RelayEnvelope | undefined;
40
50
  putInsertionReceipt(taskId: string, eventId: string): boolean;
41
51
  reserveTaskOperation(input: TaskOperationRecord): { readonly created: boolean; readonly record: TaskOperationRecord };
42
52
  getEndpointGeneration(): string | undefined;
@@ -52,7 +62,7 @@ export interface TaskStore {
52
62
  const LIMITS = { entries: 16_384, bytes: 32 * 1024 * 1024 };
53
63
  type Cell = { readonly text: string; readonly bytes: number; readonly order: number };
54
64
  type Undo = { readonly table: Map<string, Cell>; readonly key: string; readonly prior: Cell | undefined };
55
- interface Checkpoint { frontier: string; highWater: string; pending: Record<string, string>; requested: Record<string, true>; }
65
+ interface Checkpoint { frontier: string; highWater: string; pending: Record<string, string>; requested: Record<string, number>; }
56
66
 
57
67
  /** One endpoint lifetime. No SQLite, files, migrations, startup reads or replay. */
58
68
  export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
@@ -61,6 +71,14 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
61
71
  if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > LIMITS.entries || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > LIMITS.bytes) throw new TypeError("invalid task memory limit");
62
72
  const tables = new Map<string, Map<string, Cell>>();
63
73
  const frames: Map<string, Undo>[] = [];
74
+ // ID-only retry rotation, bounded by the existing pending outbox; no payload copy.
75
+ const pendingOutbox = new Map<string, number>();
76
+ const syncPendingOutbox = (id: string) => {
77
+ const row = tables.get("outbox")?.get(id);
78
+ if (row && (JSON.parse(row.text) as OutboxRecord).state === "pending") {
79
+ if (!pendingOutbox.has(id)) pendingOutbox.set(id, 0);
80
+ } else pendingOutbox.delete(id);
81
+ };
64
82
  let bytes = 0, entries = 0, sequence = 0, closed = false;
65
83
  const remember = (name: string, key: string, target: Map<string, Cell>, prior: Cell | undefined) => {
66
84
  const frame = frames.at(-1), identity = JSON.stringify([name, key]);
@@ -116,7 +134,11 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
116
134
  const result = operation();
117
135
  if (result instanceof Promise) throw new TypeError("task transactions must be synchronous");
118
136
  const parent = frames.at(-2);
119
- if (parent) for (const [key, item] of frame) if (!parent.has(key)) parent.set(key, item);
137
+ if (parent) {
138
+ for (const [key, item] of frame) if (!parent.has(key)) parent.set(key, item);
139
+ } else {
140
+ for (const item of frame.values()) if (item.table === tables.get("outbox")) syncPendingOutbox(item.key);
141
+ }
120
142
  return result;
121
143
  } catch (error) {
122
144
  for (const item of [...frame.values()].reverse()) { if (item.prior) item.table.set(item.key, item.prior); else item.table.delete(item.key); }
@@ -133,9 +155,28 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
133
155
  },
134
156
  setStatus(id, status) { const task = get<Omit<TaskRecord, "events">>("tasks", id); if (task) put("tasks", id, { ...task, status }); },
135
157
  appendEvent(event) { requireTask(event.taskId); return put("events", event.eventId, event, true); },
136
- putOutbox(envelope) { put("outbox", envelope.envelopeId, { envelope, state: "pending" }, true); },
158
+ putOutbox(envelope) { put("outbox", envelope.envelopeId, { envelope, state: "pending" }, true); if (!frames.length) syncPendingOutbox(envelope.envelopeId); },
137
159
  outbox(status) { return rows<OutboxRecord>("outbox").filter(item => item.state === status); },
138
- markOutboxAccepted(id) { const prior = get<OutboxRecord>("outbox", id); if (prior) put("outbox", id, { ...prior, state: "accepted" }); },
160
+ getOutboxRecord(id) { return get<OutboxRecord>("outbox", id); },
161
+ hasPendingOutbox() { check(); return pendingOutbox.size > 0; },
162
+ selectPendingOutbox(limit, now, maxInspections = TASK_WORK_LIMITS.retryInspections) {
163
+ check();
164
+ const selected: OutboxRecord[] = [];
165
+ // Also bound inspection when the queue is cooling down. Rotate even skipped IDs.
166
+ const inspections = Math.min(pendingOutbox.size, TASK_WORK_LIMITS.retryInspections, maxInspections);
167
+ for (let i = 0; i < inspections && selected.length < limit; i++) {
168
+ const [id, retryAt] = pendingOutbox.entries().next().value!;
169
+ pendingOutbox.delete(id); pendingOutbox.set(id, retryAt);
170
+ if (retryAt <= now) {
171
+ const record = get<OutboxRecord>("outbox", id);
172
+ if (record?.state === "pending") selected.push(record);
173
+ }
174
+ }
175
+ return selected;
176
+ },
177
+ outboxRetryAt(id) { check(); return pendingOutbox.get(id) ?? 0; },
178
+ postponeOutbox(id, until) { check(); if (pendingOutbox.has(id)) pendingOutbox.set(id, until); },
179
+ markOutboxAccepted(id) { const prior = get<OutboxRecord>("outbox", id); if (prior) put("outbox", id, { ...prior, state: "accepted" }); if (!frames.length) syncPendingOutbox(id); },
139
180
  quarantineOutbox(id, input) {
140
181
  store.transaction(() => { const prior = get<OutboxRecord>("outbox", id); if (!prior) return;
141
182
  put("quarantine", id, { ...input, envelope: prior.envelope, priorState: prior.state }, true); remove("outbox", id);
@@ -156,12 +197,16 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
156
197
  writeCheckpoint(endpoint, value);
157
198
  },
158
199
  pendingRelayEnvelopeId(endpoint, cursor) { return checkpoint(endpoint).pending[cursor]; },
159
- requestRelayAcknowledgement(endpoint, cursor) {
200
+ requestRelayAcknowledgement(endpoint, cursor, retryAt = 0) {
160
201
  const value = checkpoint(endpoint);
161
202
  if (value.pending[cursor] === undefined) throw new TaskProtocolError("INVALID_CURSOR", "relay delivery is not pending in this endpoint scope");
162
- value.requested[cursor] = true; writeCheckpoint(endpoint, value);
203
+ value.requested[cursor] = retryAt; writeCheckpoint(endpoint, value);
163
204
  },
164
- requestedRelayAcknowledgements(endpoint) { return Object.keys(checkpoint(endpoint).requested); },
205
+ requestedRelayAcknowledgements(endpoint, limit = Number.MAX_SAFE_INTEGER, now = Date.now()) {
206
+ const value = checkpoint(endpoint);
207
+ return Object.keys(value.requested).filter(cursor => value.requested[cursor]! <= now).slice(0, limit);
208
+ },
209
+ relayAcknowledgementRetryAt(endpoint, cursor) { return checkpoint(endpoint).requested[cursor] ?? 0; },
165
210
  acknowledgeRelayCursor(endpoint, cursor) {
166
211
  store.transaction(() => {
167
212
  const value = checkpoint(endpoint); if (value.pending[cursor] === undefined) return;
@@ -173,6 +218,12 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
173
218
  });
174
219
  },
175
220
  putIntent(intentId, taskId, envelopeId) { put("intents", intentId, { taskId, envelopeId }, true); },
221
+ getIntentEnvelope(intentId) {
222
+ const intent = get<{ taskId: string; envelopeId: string }>("intents", intentId);
223
+ if (!intent) return undefined;
224
+ const envelope = (get<OutboxRecord>("outbox", intent.envelopeId) ?? get<OutboxQuarantineRecord>("quarantine", intent.envelopeId))?.envelope;
225
+ return envelope?.taskId === intent.taskId ? envelope : undefined;
226
+ },
176
227
  putInsertionReceipt(taskId, eventId) { requireTask(taskId); return put("insertions", pair(taskId, eventId), true, true); },
177
228
  reserveTaskOperation(input) { requireTask(input.taskId); const key = pair(input.taskId, input.operation); const created = put("operations", key, input, true); return { created, record: get<TaskOperationRecord>("operations", key)! }; },
178
229
  getEndpointGeneration() { return state<string>("generation"); },
@@ -181,7 +232,7 @@ export function createTaskStore(options: TaskStoreOptions = {}): TaskStore {
181
232
  setEndpointBinding(value) { put("state", "endpoint", value); },
182
233
  getRelayTransportBinding() { return state<RelayTransportBinding>("transport"); },
183
234
  setRelayTransportBinding(value) { put("state", "transport", value); },
184
- clear() { check(); if (frames.length) throw new Error("cannot clear task state during a transaction"); tables.clear(); bytes = entries = sequence = 0; },
235
+ clear() { check(); if (frames.length) throw new Error("cannot clear task state during a transaction"); tables.clear(); pendingOutbox.clear(); bytes = entries = sequence = 0; },
185
236
  close() { if (closed) return; store.clear(); closed = true; },
186
237
  };
187
238
  return store;
@@ -0,0 +1,63 @@
1
+ import { TaskProtocolError } from "./task-protocol";
2
+
3
+ export const TASK_WORK_LIMITS = { scopedSends: 56, retries: 8, acknowledgements: 48, pageItems: 50,
4
+ deadlineMs: 2_000, retrySliceMs: 250, ackRepairSliceMs: 250, capacityCooldownMs: 5_000, retryInspections: 64 } as const;
5
+
6
+ /** Shared counts for nested receipts/fanout; background I/O also has one finite window. */
7
+ export interface TaskWorkScope {
8
+ readonly signal: AbortSignal;
9
+ readonly attemptedSends: Set<string>;
10
+ readonly attemptedAcknowledgements: Set<string>;
11
+ readonly blockedRetryTargets: Set<string>;
12
+ retryStarted: boolean;
13
+ retryAllowed: boolean;
14
+ claimSend(envelopeId: string, retry: boolean): boolean;
15
+ claimAcknowledgement(cursor: string): boolean;
16
+ canAcknowledge(): boolean;
17
+ canRetry(): boolean;
18
+ canRepairAcknowledgement(): boolean;
19
+ backgroundSignal(): AbortSignal;
20
+ }
21
+
22
+ export function createTaskWorkScope(signal?: AbortSignal, mode: "background" | "foreground" = "background"): TaskWorkScope {
23
+ const caller = signal ?? new AbortController().signal;
24
+ let background: AbortSignal | undefined;
25
+ const backgroundSignal = () => background ??= AbortSignal.any([caller, AbortSignal.timeout(TASK_WORK_LIMITS.deadlineMs)]);
26
+ // Foreground requests retain the transport/caller timeout. Their speculative
27
+ // retries start a bounded background window only after required sends finish.
28
+ const active = mode === "background" ? backgroundSignal() : caller;
29
+ const attemptedSends = new Set<string>(), attemptedAcknowledgements = new Set<string>(), blockedRetryTargets = new Set<string>();
30
+ let scopedSends = 0, retrySends = 0, acknowledgements = 0;
31
+ let retryUntil: number | undefined, repairUntil: number | undefined;
32
+ return {
33
+ signal: active, attemptedSends, attemptedAcknowledgements, blockedRetryTargets, retryStarted: false, retryAllowed: true,
34
+ backgroundSignal,
35
+ claimSend(id, retry) {
36
+ if ((retry ? backgroundSignal() : active).aborted || attemptedSends.has(id) || (retry ? retrySends >= TASK_WORK_LIMITS.retries : scopedSends >= TASK_WORK_LIMITS.scopedSends)) return false;
37
+ attemptedSends.add(id);
38
+ if (retry) retrySends++; else scopedSends++;
39
+ return true;
40
+ },
41
+ claimAcknowledgement(cursor) {
42
+ if (active.aborted || attemptedAcknowledgements.has(cursor) || acknowledgements >= TASK_WORK_LIMITS.acknowledgements) return false;
43
+ attemptedAcknowledgements.add(cursor); acknowledgements++;
44
+ return true;
45
+ },
46
+ canAcknowledge() { return !active.aborted && acknowledgements < TASK_WORK_LIMITS.acknowledgements; },
47
+ // Slices stop admission, not completion: an admitted request gets the
48
+ // remaining background window, including cancellation and late fences.
49
+ canRetry() { return !backgroundSignal().aborted && performance.now() < (retryUntil ??= performance.now() + TASK_WORK_LIMITS.retrySliceMs); },
50
+ canRepairAcknowledgement() { return !backgroundSignal().aborted && performance.now() < (repairUntil ??= performance.now() + TASK_WORK_LIMITS.ackRepairSliceMs); },
51
+ };
52
+ }
53
+
54
+ export function deferredTaskDelivery(details?: Record<string, unknown>): TaskProtocolError {
55
+ return new TaskProtocolError("TASK_DELIVERY_DEFERRED", "task delivery deferred by the local work budget or retry cooldown", { retryable: true, details });
56
+ }
57
+
58
+ /** Known admission backpressure gets at least one normal polling interval, without another timer. */
59
+ export function capacityRetryAt(error: unknown): number | undefined {
60
+ if (!(error instanceof TaskProtocolError) || error.code !== "RELAY_CAPACITY" || !error.retryable) return undefined;
61
+ const hint = error.details?.retryAfterMs;
62
+ return Date.now() + Math.max(TASK_WORK_LIMITS.capacityCooldownMs, typeof hint === "number" && Number.isFinite(hint) && hint >= 0 ? hint : 0);
63
+ }