@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/README.md +146 -58
- package/docs/protocol-reference.md +111 -0
- package/package.json +2 -1
- package/src/extension.ts +23 -19
- package/src/task-core.ts +200 -91
- package/src/task-inbox.ts +75 -62
- package/src/task-store.ts +61 -10
- package/src/task-work-scope.ts +63 -0
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,
|
|
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)
|
|
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
|
-
|
|
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] =
|
|
203
|
+
value.requested[cursor] = retryAt; writeCheckpoint(endpoint, value);
|
|
163
204
|
},
|
|
164
|
-
requestedRelayAcknowledgements(endpoint
|
|
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
|
+
}
|