@danypops/vehicle-core 0.3.0 → 0.5.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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/vehicle-contract.d.ts +11 -0
- package/dist/vehicle-contract.js +31 -0
- package/dist/vehicle-errors.d.ts +1 -1
- package/dist/vehicle-jobs.d.ts +114 -0
- package/dist/vehicle-jobs.js +182 -0
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/vehicle-contract.ts +44 -0
- package/src/vehicle-errors.ts +5 -1
- package/src/vehicle-jobs.ts +255 -0
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { VehicleJobWakeBudget } from "./vehicle-jobs.js";
|
|
1
2
|
export type JsonPrimitive = string | number | boolean | null;
|
|
2
3
|
export type JsonValue = JsonPrimitive | readonly JsonValue[] | {
|
|
3
4
|
readonly [key: string]: JsonValue;
|
|
@@ -85,6 +86,12 @@ export interface VehicleFailureDescriptor {
|
|
|
85
86
|
readonly code: string;
|
|
86
87
|
readonly description: string;
|
|
87
88
|
}
|
|
89
|
+
/** Declares an operation safe to run as a Vehicle Job (detached, polled/tailed/canceled by id). Absent means live-invoke only. */
|
|
90
|
+
export interface VehicleBackgroundCapability {
|
|
91
|
+
readonly supported: true;
|
|
92
|
+
readonly defaultWakeBudget: VehicleJobWakeBudget;
|
|
93
|
+
readonly maxWakeBudget: VehicleJobWakeBudget;
|
|
94
|
+
}
|
|
88
95
|
export interface VehicleOperationDescriptor {
|
|
89
96
|
readonly name: string;
|
|
90
97
|
readonly version: number;
|
|
@@ -98,6 +105,7 @@ export interface VehicleOperationDescriptor {
|
|
|
98
105
|
readonly longRunning: boolean;
|
|
99
106
|
readonly limits: VehicleLimits;
|
|
100
107
|
readonly errors: readonly VehicleFailureDescriptor[];
|
|
108
|
+
readonly background?: VehicleBackgroundCapability;
|
|
101
109
|
}
|
|
102
110
|
export interface VehicleOperation<Input, Output> {
|
|
103
111
|
readonly descriptor: VehicleOperationDescriptor;
|
|
@@ -117,6 +125,7 @@ export interface DefineVehicleOperationOptions<Input, Output> {
|
|
|
117
125
|
readonly longRunning?: boolean;
|
|
118
126
|
readonly limits: VehicleLimits;
|
|
119
127
|
readonly errors?: readonly VehicleFailureDescriptor[];
|
|
128
|
+
readonly background?: VehicleBackgroundCapability;
|
|
120
129
|
}
|
|
121
130
|
export interface VehiclePrincipal {
|
|
122
131
|
readonly id: string;
|
|
@@ -145,6 +154,8 @@ export interface VehicleOperationContext<Input> {
|
|
|
145
154
|
readonly idempotencyKey?: string;
|
|
146
155
|
readonly expectedRevision?: string | number;
|
|
147
156
|
readonly approvalCapability?: string;
|
|
157
|
+
/** Set only for a job execution (VehicleJobStore.submit()); undefined for a plain invoke(). A handler that wants mid-flight input opts in with `for await (const input of context.steerInputs ?? [])`. */
|
|
158
|
+
readonly steerInputs?: AsyncIterable<unknown>;
|
|
148
159
|
reportProgress(progress: unknown): void;
|
|
149
160
|
}
|
|
150
161
|
export type VehicleOperationHandler<Input, Output> = (context: VehicleOperationContext<Input>) => Promise<Output>;
|
package/dist/vehicle-contract.js
CHANGED
|
@@ -84,6 +84,15 @@ export function defineVehicleOperation(options) {
|
|
|
84
84
|
longRunning: options.longRunning ?? false,
|
|
85
85
|
limits: Object.freeze({ ...options.limits }),
|
|
86
86
|
errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
|
|
87
|
+
...(options.background
|
|
88
|
+
? {
|
|
89
|
+
background: Object.freeze({
|
|
90
|
+
supported: true,
|
|
91
|
+
defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
|
|
92
|
+
maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
|
|
93
|
+
}),
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
87
96
|
});
|
|
88
97
|
return Object.freeze({ descriptor, input: options.input, output: options.output });
|
|
89
98
|
}
|
|
@@ -114,6 +123,28 @@ function validateOperationMetadata(options) {
|
|
|
114
123
|
(!Number.isSafeInteger(options.idempotency.retentionMs) || options.idempotency.retentionMs < 1)) {
|
|
115
124
|
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
116
125
|
}
|
|
126
|
+
if (options.background) {
|
|
127
|
+
if (!options.longRunning) {
|
|
128
|
+
throw new Error("Vehicle operation with a background capability must also set longRunning: true");
|
|
129
|
+
}
|
|
130
|
+
for (const [budgetName, budget] of [
|
|
131
|
+
["defaultWakeBudget", options.background.defaultWakeBudget],
|
|
132
|
+
["maxWakeBudget", options.background.maxWakeBudget],
|
|
133
|
+
]) {
|
|
134
|
+
if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
|
|
135
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
|
|
138
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
|
|
142
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
|
|
143
|
+
}
|
|
144
|
+
if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
|
|
145
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
117
148
|
}
|
|
118
149
|
function cloneJson(value) {
|
|
119
150
|
const serialized = JSON.stringify(value);
|
package/dist/vehicle-errors.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { JsonValue, VehicleSchemaIssue } from "./vehicle-contract.js";
|
|
2
2
|
export type VehicleFailureCategory = "validation" | "not_found" | "conflict" | "authorization" | "capacity" | "timeout" | "cancelled" | "unavailable" | "internal";
|
|
3
|
-
export type VehicleCoreErrorCode = "duplicate-owner" | "not-found" | "invalid-input" | "invalid-output" | "permission-denied" | "request-too-large" | "response-too-large" | "cancelled" | "deadline-exceeded" | "handler-failed" | "policy-failed" | "idempotency-key-required" | "client-closed" | "operation-unavailable";
|
|
3
|
+
export type VehicleCoreErrorCode = "duplicate-owner" | "not-found" | "invalid-input" | "invalid-output" | "permission-denied" | "request-too-large" | "response-too-large" | "cancelled" | "deadline-exceeded" | "handler-failed" | "policy-failed" | "idempotency-key-required" | "client-closed" | "operation-unavailable" | "background-not-supported" | "job-not-found" | "job-not-steerable" | "job-steer-queue-full";
|
|
4
4
|
export interface VehicleRecovery {
|
|
5
5
|
readonly operation?: string;
|
|
6
6
|
readonly message: string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
+
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
3
|
+
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. "orphaned" is a restart-reconciliation outcome: a job that was still "running" when its process died, so nothing ever really failed or succeeded -- the record's own status just goes stale. */
|
|
4
|
+
export declare const VEHICLE_JOB_TERMINATION_PRECEDENCE: readonly ["canceled", "timeout", "orphaned", "failed", "succeeded"];
|
|
5
|
+
export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
|
|
6
|
+
export declare function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason;
|
|
7
|
+
/** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
|
|
8
|
+
export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
|
|
9
|
+
export interface VehicleJobWakeBudget {
|
|
10
|
+
readonly maxCount: number;
|
|
11
|
+
readonly maxBytes: number;
|
|
12
|
+
}
|
|
13
|
+
export type VehicleJobWakeDropReason = "count-budget-exhausted" | "byte-budget-exhausted" | "deduplicated-transition" | "superseded-by-first-only";
|
|
14
|
+
export interface VehicleJobWakeEntry {
|
|
15
|
+
readonly seq: number;
|
|
16
|
+
readonly at: number;
|
|
17
|
+
readonly progress: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface VehicleJobWakeAppendResult {
|
|
20
|
+
readonly accepted: boolean;
|
|
21
|
+
readonly entry?: VehicleJobWakeEntry;
|
|
22
|
+
readonly dropReason?: VehicleJobWakeDropReason;
|
|
23
|
+
}
|
|
24
|
+
export interface VehicleJobWakeLogOptions {
|
|
25
|
+
readonly notifyMode: VehicleJobNotifyMode;
|
|
26
|
+
readonly budget: VehicleJobWakeBudget;
|
|
27
|
+
/** Defaults to Date.now. */
|
|
28
|
+
readonly now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
31
|
+
export declare class VehicleJobWakeLog {
|
|
32
|
+
private readonly options;
|
|
33
|
+
private readonly entries;
|
|
34
|
+
private usedBytes;
|
|
35
|
+
private nextSeq;
|
|
36
|
+
private lastHash;
|
|
37
|
+
private acceptedFirst;
|
|
38
|
+
private readonly now;
|
|
39
|
+
constructor(options: VehicleJobWakeLogOptions);
|
|
40
|
+
append(progress: unknown): VehicleJobWakeAppendResult;
|
|
41
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
42
|
+
since(cursor: number): readonly VehicleJobWakeEntry[];
|
|
43
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
44
|
+
get cursor(): number;
|
|
45
|
+
}
|
|
46
|
+
/** Read-only replay side of a wake log -- both a live VehicleJobWakeLog and a restored (no-longer-appendable) job satisfy this with the same tail() semantics. */
|
|
47
|
+
export interface VehicleJobWakeLogReader {
|
|
48
|
+
since(cursor: number): readonly VehicleJobWakeEntry[];
|
|
49
|
+
readonly cursor: number;
|
|
50
|
+
}
|
|
51
|
+
/** Wraps a fixed, already-finalized list of entries (e.g. restored from disk) in the same reader shape a live VehicleJobWakeLog exposes, so VehicleJobStore.tail() doesn't need to special-case a restored job. */
|
|
52
|
+
export declare function createStaticVehicleJobWakeLog(entries: readonly VehicleJobWakeEntry[]): VehicleJobWakeLogReader;
|
|
53
|
+
/**
|
|
54
|
+
* A job's mid-flight input channel -- the "steer" primitive. Bounded FIFO:
|
|
55
|
+
* push() while a handler isn't yet reading buffers up to maxQueueSize, then
|
|
56
|
+
* refuses further input rather than growing unboundedly or silently
|
|
57
|
+
* overwriting an unread entry. A handler consumes it via `for await (const
|
|
58
|
+
* input of context.steerInputs)`, which ends cleanly once close() is
|
|
59
|
+
* called (VehicleJobStore does this at job finalization).
|
|
60
|
+
*/
|
|
61
|
+
export interface VehicleJobSteerPushResult {
|
|
62
|
+
readonly accepted: boolean;
|
|
63
|
+
readonly dropReason?: "queue-full" | "channel-closed";
|
|
64
|
+
}
|
|
65
|
+
export declare class VehicleJobSteerChannel implements AsyncIterable<unknown> {
|
|
66
|
+
private readonly maxQueueSize;
|
|
67
|
+
private readonly buffer;
|
|
68
|
+
private readonly waiters;
|
|
69
|
+
private closed;
|
|
70
|
+
constructor(maxQueueSize?: number);
|
|
71
|
+
push(value: unknown): VehicleJobSteerPushResult;
|
|
72
|
+
/** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
|
|
73
|
+
close(): void;
|
|
74
|
+
[Symbol.asyncIterator](): AsyncIterator<unknown>;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Vehicle Jobs run as in-process promises, not child processes -- there is
|
|
78
|
+
* no PID to reuse, but the same identity-confusion risk vstack's
|
|
79
|
+
* {pid, startToken, comm} design guards against still applies in a
|
|
80
|
+
* generalized form: a persisted job record written by one process
|
|
81
|
+
* instance must never be mistaken for one this (possibly restarted)
|
|
82
|
+
* instance can still resolve. Each VehicleJobStore construction gets a
|
|
83
|
+
* fresh random instanceToken; a persisted record's own stamped token only
|
|
84
|
+
* ever matches the instance that wrote it. A mismatch means "the original
|
|
85
|
+
* run is gone", the same conclusion vstack's identityMatches() reaches by
|
|
86
|
+
* comparing a live process's actual pid/start-time/command against a
|
|
87
|
+
* stored snapshot -- this is that same check with no process to inspect.
|
|
88
|
+
*/
|
|
89
|
+
export declare function vehicleJobIdentityMatches(recordInstanceToken: string, currentInstanceToken: string): boolean;
|
|
90
|
+
/** Minimal shape selectVehicleJobsForEviction needs from a job record -- kept separate from VehicleJobSnapshot so vehicle-server doesn't have to construct a full snapshot just to ask "should this be swept". */
|
|
91
|
+
export interface VehicleJobEvictionCandidate {
|
|
92
|
+
readonly jobId: string;
|
|
93
|
+
readonly status: VehicleJobStatus;
|
|
94
|
+
readonly delivered: boolean;
|
|
95
|
+
readonly updatedAt: number;
|
|
96
|
+
}
|
|
97
|
+
export interface VehicleJobRetentionOptions {
|
|
98
|
+
/** Hard cap on total retained job records (of any status). A running job is never evicted regardless of this cap. */
|
|
99
|
+
readonly maxRetainedJobs: number;
|
|
100
|
+
/** A delivered terminal job becomes eligible for eviction once this many ms have passed since it was delivered (== updatedAt at delivery time). */
|
|
101
|
+
readonly deliveredRetentionMs: number;
|
|
102
|
+
readonly now: number;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
106
|
+
* bookkeeping so the bounded-retention rule is independently testable.
|
|
107
|
+
* Preference order: (1) delivered and past deliveredRetentionMs, oldest
|
|
108
|
+
* first; (2) once still over maxRetainedJobs, any delivered terminal job,
|
|
109
|
+
* oldest first; (3) only as a last resort, an undelivered terminal job,
|
|
110
|
+
* oldest first -- a real loss (a caller may still want that result), but
|
|
111
|
+
* an unbounded store is a worse failure mode. A running job is never a
|
|
112
|
+
* candidate.
|
|
113
|
+
*/
|
|
114
|
+
export declare function selectVehicleJobsForEviction(candidates: readonly VehicleJobEvictionCandidate[], options: VehicleJobRetentionOptions): readonly string[];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
+
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. "orphaned" is a restart-reconciliation outcome: a job that was still "running" when its process died, so nothing ever really failed or succeeded -- the record's own status just goes stale. */
|
|
3
|
+
export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "orphaned", "failed", "succeeded"];
|
|
4
|
+
export function resolveVehicleJobTerminationReason(candidates) {
|
|
5
|
+
if (candidates.length === 0)
|
|
6
|
+
throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
|
|
7
|
+
for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
|
|
8
|
+
if (candidates.includes(reason))
|
|
9
|
+
return reason;
|
|
10
|
+
}
|
|
11
|
+
throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
|
|
12
|
+
}
|
|
13
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
14
|
+
export class VehicleJobWakeLog {
|
|
15
|
+
options;
|
|
16
|
+
entries = [];
|
|
17
|
+
usedBytes = 0;
|
|
18
|
+
nextSeq = 1;
|
|
19
|
+
lastHash;
|
|
20
|
+
acceptedFirst = false;
|
|
21
|
+
now;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.now = options.now ?? Date.now;
|
|
25
|
+
}
|
|
26
|
+
append(progress) {
|
|
27
|
+
if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
|
|
28
|
+
return { accepted: false, dropReason: "superseded-by-first-only" };
|
|
29
|
+
}
|
|
30
|
+
const serialized = safeJsonStringify(progress);
|
|
31
|
+
if (this.options.notifyMode === "transition") {
|
|
32
|
+
const hash = fnv1aHash(serialized);
|
|
33
|
+
if (hash === this.lastHash)
|
|
34
|
+
return { accepted: false, dropReason: "deduplicated-transition" };
|
|
35
|
+
this.lastHash = hash;
|
|
36
|
+
}
|
|
37
|
+
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
38
|
+
if (this.entries.length >= this.options.budget.maxCount)
|
|
39
|
+
return { accepted: false, dropReason: "count-budget-exhausted" };
|
|
40
|
+
if (this.usedBytes + bytes > this.options.budget.maxBytes)
|
|
41
|
+
return { accepted: false, dropReason: "byte-budget-exhausted" };
|
|
42
|
+
const entry = { seq: this.nextSeq++, at: this.now(), progress };
|
|
43
|
+
this.entries.push(entry);
|
|
44
|
+
this.usedBytes += bytes;
|
|
45
|
+
this.acceptedFirst = true;
|
|
46
|
+
return { accepted: true, entry };
|
|
47
|
+
}
|
|
48
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
49
|
+
since(cursor) {
|
|
50
|
+
return this.entries.filter((entry) => entry.seq > cursor);
|
|
51
|
+
}
|
|
52
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
53
|
+
get cursor() {
|
|
54
|
+
return this.nextSeq - 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function safeJsonStringify(value) {
|
|
58
|
+
let serialized;
|
|
59
|
+
try {
|
|
60
|
+
serialized = JSON.stringify(value);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
|
|
64
|
+
}
|
|
65
|
+
if (serialized === undefined)
|
|
66
|
+
throw new Error("Vehicle job progress value is not JSON-serializable");
|
|
67
|
+
return serialized;
|
|
68
|
+
}
|
|
69
|
+
/** Non-cryptographic (FNV-1a) -- dedup only. */
|
|
70
|
+
function fnv1aHash(value) {
|
|
71
|
+
let hash = 0x811c9dc5;
|
|
72
|
+
for (let i = 0; i < value.length; i++) {
|
|
73
|
+
hash ^= value.charCodeAt(i);
|
|
74
|
+
hash = Math.imul(hash, 0x01000193);
|
|
75
|
+
}
|
|
76
|
+
return (hash >>> 0).toString(16);
|
|
77
|
+
}
|
|
78
|
+
/** Wraps a fixed, already-finalized list of entries (e.g. restored from disk) in the same reader shape a live VehicleJobWakeLog exposes, so VehicleJobStore.tail() doesn't need to special-case a restored job. */
|
|
79
|
+
export function createStaticVehicleJobWakeLog(entries) {
|
|
80
|
+
const sorted = [...entries].sort((a, b) => a.seq - b.seq);
|
|
81
|
+
const cursor = sorted.length > 0 ? sorted[sorted.length - 1].seq : 0;
|
|
82
|
+
return {
|
|
83
|
+
since: (cursorArg) => sorted.filter((entry) => entry.seq > cursorArg),
|
|
84
|
+
cursor,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export class VehicleJobSteerChannel {
|
|
88
|
+
maxQueueSize;
|
|
89
|
+
buffer = [];
|
|
90
|
+
waiters = [];
|
|
91
|
+
closed = false;
|
|
92
|
+
constructor(maxQueueSize = 64) {
|
|
93
|
+
this.maxQueueSize = maxQueueSize;
|
|
94
|
+
}
|
|
95
|
+
push(value) {
|
|
96
|
+
if (this.closed)
|
|
97
|
+
return { accepted: false, dropReason: "channel-closed" };
|
|
98
|
+
const waiter = this.waiters.shift();
|
|
99
|
+
if (waiter) {
|
|
100
|
+
waiter({ value, done: false });
|
|
101
|
+
return { accepted: true };
|
|
102
|
+
}
|
|
103
|
+
if (this.buffer.length >= this.maxQueueSize)
|
|
104
|
+
return { accepted: false, dropReason: "queue-full" };
|
|
105
|
+
this.buffer.push(value);
|
|
106
|
+
return { accepted: true };
|
|
107
|
+
}
|
|
108
|
+
/** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
|
|
109
|
+
close() {
|
|
110
|
+
if (this.closed)
|
|
111
|
+
return;
|
|
112
|
+
this.closed = true;
|
|
113
|
+
for (const waiter of this.waiters.splice(0))
|
|
114
|
+
waiter({ value: undefined, done: true });
|
|
115
|
+
}
|
|
116
|
+
[Symbol.asyncIterator]() {
|
|
117
|
+
return {
|
|
118
|
+
next: () => {
|
|
119
|
+
if (this.buffer.length > 0)
|
|
120
|
+
return Promise.resolve({ value: this.buffer.shift(), done: false });
|
|
121
|
+
if (this.closed)
|
|
122
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
123
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Vehicle Jobs run as in-process promises, not child processes -- there is
|
|
130
|
+
* no PID to reuse, but the same identity-confusion risk vstack's
|
|
131
|
+
* {pid, startToken, comm} design guards against still applies in a
|
|
132
|
+
* generalized form: a persisted job record written by one process
|
|
133
|
+
* instance must never be mistaken for one this (possibly restarted)
|
|
134
|
+
* instance can still resolve. Each VehicleJobStore construction gets a
|
|
135
|
+
* fresh random instanceToken; a persisted record's own stamped token only
|
|
136
|
+
* ever matches the instance that wrote it. A mismatch means "the original
|
|
137
|
+
* run is gone", the same conclusion vstack's identityMatches() reaches by
|
|
138
|
+
* comparing a live process's actual pid/start-time/command against a
|
|
139
|
+
* stored snapshot -- this is that same check with no process to inspect.
|
|
140
|
+
*/
|
|
141
|
+
export function vehicleJobIdentityMatches(recordInstanceToken, currentInstanceToken) {
|
|
142
|
+
return recordInstanceToken === currentInstanceToken;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
146
|
+
* bookkeeping so the bounded-retention rule is independently testable.
|
|
147
|
+
* Preference order: (1) delivered and past deliveredRetentionMs, oldest
|
|
148
|
+
* first; (2) once still over maxRetainedJobs, any delivered terminal job,
|
|
149
|
+
* oldest first; (3) only as a last resort, an undelivered terminal job,
|
|
150
|
+
* oldest first -- a real loss (a caller may still want that result), but
|
|
151
|
+
* an unbounded store is a worse failure mode. A running job is never a
|
|
152
|
+
* candidate.
|
|
153
|
+
*/
|
|
154
|
+
export function selectVehicleJobsForEviction(candidates, options) {
|
|
155
|
+
const terminal = candidates.filter((candidate) => candidate.status !== "running");
|
|
156
|
+
const byAgeAscending = (a, b) => a.updatedAt - b.updatedAt;
|
|
157
|
+
const evicted = new Set();
|
|
158
|
+
for (const candidate of terminal) {
|
|
159
|
+
if (candidate.delivered && options.now - candidate.updatedAt >= options.deliveredRetentionMs)
|
|
160
|
+
evicted.add(candidate.jobId);
|
|
161
|
+
}
|
|
162
|
+
const remainingCount = () => candidates.length - evicted.size;
|
|
163
|
+
if (remainingCount() > options.maxRetainedJobs) {
|
|
164
|
+
const deliveredOldestFirst = terminal.filter((candidate) => candidate.delivered && !evicted.has(candidate.jobId)).sort(byAgeAscending);
|
|
165
|
+
for (const candidate of deliveredOldestFirst) {
|
|
166
|
+
if (remainingCount() <= options.maxRetainedJobs)
|
|
167
|
+
break;
|
|
168
|
+
evicted.add(candidate.jobId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (remainingCount() > options.maxRetainedJobs) {
|
|
172
|
+
const undeliveredOldestFirst = terminal
|
|
173
|
+
.filter((candidate) => !candidate.delivered && !evicted.has(candidate.jobId))
|
|
174
|
+
.sort(byAgeAscending);
|
|
175
|
+
for (const candidate of undeliveredOldestFirst) {
|
|
176
|
+
if (remainingCount() <= options.maxRetainedJobs)
|
|
177
|
+
break;
|
|
178
|
+
evicted.add(candidate.jobId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [...evicted];
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/vehicle-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Vehicle's runtime-neutral wire contract: operation descriptors, schema codecs, failure shapes. Zero runtime dependencies, zero Bun-specific code -- the one thing every Vehicle client and server package depends on.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
package/src/vehicle-contract.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { VehicleJobWakeBudget } from "./vehicle-jobs.js";
|
|
2
|
+
|
|
1
3
|
export type JsonPrimitive = string | number | boolean | null;
|
|
2
4
|
export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };
|
|
3
5
|
export type JsonSchema = Readonly<Record<string, JsonValue>>;
|
|
@@ -137,6 +139,13 @@ export interface VehicleFailureDescriptor {
|
|
|
137
139
|
readonly description: string;
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
/** Declares an operation safe to run as a Vehicle Job (detached, polled/tailed/canceled by id). Absent means live-invoke only. */
|
|
143
|
+
export interface VehicleBackgroundCapability {
|
|
144
|
+
readonly supported: true;
|
|
145
|
+
readonly defaultWakeBudget: VehicleJobWakeBudget;
|
|
146
|
+
readonly maxWakeBudget: VehicleJobWakeBudget;
|
|
147
|
+
}
|
|
148
|
+
|
|
140
149
|
export interface VehicleOperationDescriptor {
|
|
141
150
|
readonly name: string;
|
|
142
151
|
readonly version: number;
|
|
@@ -150,6 +159,7 @@ export interface VehicleOperationDescriptor {
|
|
|
150
159
|
readonly longRunning: boolean;
|
|
151
160
|
readonly limits: VehicleLimits;
|
|
152
161
|
readonly errors: readonly VehicleFailureDescriptor[];
|
|
162
|
+
readonly background?: VehicleBackgroundCapability;
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
export interface VehicleOperation<Input, Output> {
|
|
@@ -171,6 +181,7 @@ export interface DefineVehicleOperationOptions<Input, Output> {
|
|
|
171
181
|
readonly longRunning?: boolean;
|
|
172
182
|
readonly limits: VehicleLimits;
|
|
173
183
|
readonly errors?: readonly VehicleFailureDescriptor[];
|
|
184
|
+
readonly background?: VehicleBackgroundCapability;
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
export interface VehiclePrincipal {
|
|
@@ -202,6 +213,8 @@ export interface VehicleOperationContext<Input> {
|
|
|
202
213
|
readonly idempotencyKey?: string;
|
|
203
214
|
readonly expectedRevision?: string | number;
|
|
204
215
|
readonly approvalCapability?: string;
|
|
216
|
+
/** Set only for a job execution (VehicleJobStore.submit()); undefined for a plain invoke(). A handler that wants mid-flight input opts in with `for await (const input of context.steerInputs ?? [])`. */
|
|
217
|
+
readonly steerInputs?: AsyncIterable<unknown>;
|
|
205
218
|
reportProgress(progress: unknown): void;
|
|
206
219
|
}
|
|
207
220
|
|
|
@@ -260,6 +273,15 @@ export function defineVehicleOperation<Input, Output>(
|
|
|
260
273
|
longRunning: options.longRunning ?? false,
|
|
261
274
|
limits: Object.freeze({ ...options.limits }),
|
|
262
275
|
errors: Object.freeze((options.errors ?? []).map((failure) => Object.freeze({ ...failure }))),
|
|
276
|
+
...(options.background
|
|
277
|
+
? {
|
|
278
|
+
background: Object.freeze({
|
|
279
|
+
supported: true as const,
|
|
280
|
+
defaultWakeBudget: Object.freeze({ ...options.background.defaultWakeBudget }),
|
|
281
|
+
maxWakeBudget: Object.freeze({ ...options.background.maxWakeBudget }),
|
|
282
|
+
}),
|
|
283
|
+
}
|
|
284
|
+
: {}),
|
|
263
285
|
});
|
|
264
286
|
return Object.freeze({ descriptor, input: options.input, output: options.output });
|
|
265
287
|
}
|
|
@@ -293,6 +315,28 @@ function validateOperationMetadata<Input, Output>(options: DefineVehicleOperatio
|
|
|
293
315
|
) {
|
|
294
316
|
throw new Error("Vehicle keyed idempotency retentionMs must be a positive integer");
|
|
295
317
|
}
|
|
318
|
+
if (options.background) {
|
|
319
|
+
if (!options.longRunning) {
|
|
320
|
+
throw new Error("Vehicle operation with a background capability must also set longRunning: true");
|
|
321
|
+
}
|
|
322
|
+
for (const [budgetName, budget] of [
|
|
323
|
+
["defaultWakeBudget", options.background.defaultWakeBudget],
|
|
324
|
+
["maxWakeBudget", options.background.maxWakeBudget],
|
|
325
|
+
] as const) {
|
|
326
|
+
if (!Number.isSafeInteger(budget.maxCount) || budget.maxCount < 1) {
|
|
327
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxCount must be a positive integer`);
|
|
328
|
+
}
|
|
329
|
+
if (!Number.isSafeInteger(budget.maxBytes) || budget.maxBytes < 1) {
|
|
330
|
+
throw new Error(`Vehicle operation background.${budgetName}.maxBytes must be a positive integer`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (options.background.defaultWakeBudget.maxCount > options.background.maxWakeBudget.maxCount) {
|
|
334
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxCount must not exceed maxWakeBudget.maxCount");
|
|
335
|
+
}
|
|
336
|
+
if (options.background.defaultWakeBudget.maxBytes > options.background.maxWakeBudget.maxBytes) {
|
|
337
|
+
throw new Error("Vehicle operation background.defaultWakeBudget.maxBytes must not exceed maxWakeBudget.maxBytes");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
296
340
|
}
|
|
297
341
|
|
|
298
342
|
function cloneJson<T extends JsonValue>(value: T): T {
|
package/src/vehicle-errors.ts
CHANGED
|
@@ -25,7 +25,11 @@ export type VehicleCoreErrorCode =
|
|
|
25
25
|
| "policy-failed"
|
|
26
26
|
| "idempotency-key-required"
|
|
27
27
|
| "client-closed"
|
|
28
|
-
| "operation-unavailable"
|
|
28
|
+
| "operation-unavailable"
|
|
29
|
+
| "background-not-supported"
|
|
30
|
+
| "job-not-found"
|
|
31
|
+
| "job-not-steerable"
|
|
32
|
+
| "job-steer-queue-full";
|
|
29
33
|
|
|
30
34
|
export interface VehicleRecovery {
|
|
31
35
|
readonly operation?: string;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/** Pure pieces of Vehicle Jobs: a termination-reason resolver and a bounded wake-log accumulator. Orchestration lives in vehicle-server's VehicleJobStore. */
|
|
2
|
+
|
|
3
|
+
export type VehicleJobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
4
|
+
|
|
5
|
+
/** Highest precedence first -- an explicit cancel always wins even if the handler also settled around the same time. "orphaned" is a restart-reconciliation outcome: a job that was still "running" when its process died, so nothing ever really failed or succeeded -- the record's own status just goes stale. */
|
|
6
|
+
export const VEHICLE_JOB_TERMINATION_PRECEDENCE = ["canceled", "timeout", "orphaned", "failed", "succeeded"] as const;
|
|
7
|
+
export type VehicleJobTerminationReason = (typeof VEHICLE_JOB_TERMINATION_PRECEDENCE)[number];
|
|
8
|
+
|
|
9
|
+
export function resolveVehicleJobTerminationReason(candidates: readonly VehicleJobTerminationReason[]): VehicleJobTerminationReason {
|
|
10
|
+
if (candidates.length === 0) throw new Error("resolveVehicleJobTerminationReason requires at least one candidate");
|
|
11
|
+
for (const reason of VEHICLE_JOB_TERMINATION_PRECEDENCE) {
|
|
12
|
+
if (candidates.includes(reason)) return reason;
|
|
13
|
+
}
|
|
14
|
+
throw new Error(`Unrecognized Vehicle job termination candidate(s): ${candidates.join(", ")}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** "always" keeps every notification; "transition" drops one identical to the last (hash dedup); "first-only" keeps just the first. */
|
|
18
|
+
export type VehicleJobNotifyMode = "always" | "transition" | "first-only";
|
|
19
|
+
|
|
20
|
+
export interface VehicleJobWakeBudget {
|
|
21
|
+
readonly maxCount: number;
|
|
22
|
+
readonly maxBytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type VehicleJobWakeDropReason =
|
|
26
|
+
| "count-budget-exhausted"
|
|
27
|
+
| "byte-budget-exhausted"
|
|
28
|
+
| "deduplicated-transition"
|
|
29
|
+
| "superseded-by-first-only";
|
|
30
|
+
|
|
31
|
+
export interface VehicleJobWakeEntry {
|
|
32
|
+
readonly seq: number;
|
|
33
|
+
readonly at: number;
|
|
34
|
+
readonly progress: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface VehicleJobWakeAppendResult {
|
|
38
|
+
readonly accepted: boolean;
|
|
39
|
+
readonly entry?: VehicleJobWakeEntry;
|
|
40
|
+
readonly dropReason?: VehicleJobWakeDropReason;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface VehicleJobWakeLogOptions {
|
|
44
|
+
readonly notifyMode: VehicleJobNotifyMode;
|
|
45
|
+
readonly budget: VehicleJobWakeBudget;
|
|
46
|
+
/** Defaults to Date.now. */
|
|
47
|
+
readonly now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Bounds a job's accumulated progress notifications by count+bytes, same discipline as enforcePayloadSize but across a job's whole lifetime. */
|
|
51
|
+
export class VehicleJobWakeLog {
|
|
52
|
+
private readonly entries: VehicleJobWakeEntry[] = [];
|
|
53
|
+
private usedBytes = 0;
|
|
54
|
+
private nextSeq = 1;
|
|
55
|
+
private lastHash: string | undefined;
|
|
56
|
+
private acceptedFirst = false;
|
|
57
|
+
private readonly now: () => number;
|
|
58
|
+
|
|
59
|
+
constructor(private readonly options: VehicleJobWakeLogOptions) {
|
|
60
|
+
this.now = options.now ?? Date.now;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
append(progress: unknown): VehicleJobWakeAppendResult {
|
|
64
|
+
if (this.options.notifyMode === "first-only" && this.acceptedFirst) {
|
|
65
|
+
return { accepted: false, dropReason: "superseded-by-first-only" };
|
|
66
|
+
}
|
|
67
|
+
const serialized = safeJsonStringify(progress);
|
|
68
|
+
if (this.options.notifyMode === "transition") {
|
|
69
|
+
const hash = fnv1aHash(serialized);
|
|
70
|
+
if (hash === this.lastHash) return { accepted: false, dropReason: "deduplicated-transition" };
|
|
71
|
+
this.lastHash = hash;
|
|
72
|
+
}
|
|
73
|
+
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
74
|
+
if (this.entries.length >= this.options.budget.maxCount) return { accepted: false, dropReason: "count-budget-exhausted" };
|
|
75
|
+
if (this.usedBytes + bytes > this.options.budget.maxBytes) return { accepted: false, dropReason: "byte-budget-exhausted" };
|
|
76
|
+
|
|
77
|
+
const entry: VehicleJobWakeEntry = { seq: this.nextSeq++, at: this.now(), progress };
|
|
78
|
+
this.entries.push(entry);
|
|
79
|
+
this.usedBytes += bytes;
|
|
80
|
+
this.acceptedFirst = true;
|
|
81
|
+
return { accepted: true, entry };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Entries with seq strictly greater than `cursor`. */
|
|
85
|
+
since(cursor: number): readonly VehicleJobWakeEntry[] {
|
|
86
|
+
return this.entries.filter((entry) => entry.seq > cursor);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Highest seq issued so far (0 if none accepted yet). */
|
|
90
|
+
get cursor(): number {
|
|
91
|
+
return this.nextSeq - 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function safeJsonStringify(value: unknown): string {
|
|
96
|
+
let serialized: string | undefined;
|
|
97
|
+
try {
|
|
98
|
+
serialized = JSON.stringify(value);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error("Vehicle job progress value is not JSON-serializable", { cause: error });
|
|
101
|
+
}
|
|
102
|
+
if (serialized === undefined) throw new Error("Vehicle job progress value is not JSON-serializable");
|
|
103
|
+
return serialized;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Non-cryptographic (FNV-1a) -- dedup only. */
|
|
107
|
+
function fnv1aHash(value: string): string {
|
|
108
|
+
let hash = 0x811c9dc5;
|
|
109
|
+
for (let i = 0; i < value.length; i++) {
|
|
110
|
+
hash ^= value.charCodeAt(i);
|
|
111
|
+
hash = Math.imul(hash, 0x01000193);
|
|
112
|
+
}
|
|
113
|
+
return (hash >>> 0).toString(16);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Read-only replay side of a wake log -- both a live VehicleJobWakeLog and a restored (no-longer-appendable) job satisfy this with the same tail() semantics. */
|
|
117
|
+
export interface VehicleJobWakeLogReader {
|
|
118
|
+
since(cursor: number): readonly VehicleJobWakeEntry[];
|
|
119
|
+
readonly cursor: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wraps a fixed, already-finalized list of entries (e.g. restored from disk) in the same reader shape a live VehicleJobWakeLog exposes, so VehicleJobStore.tail() doesn't need to special-case a restored job. */
|
|
123
|
+
export function createStaticVehicleJobWakeLog(entries: readonly VehicleJobWakeEntry[]): VehicleJobWakeLogReader {
|
|
124
|
+
const sorted = [...entries].sort((a, b) => a.seq - b.seq);
|
|
125
|
+
const cursor = sorted.length > 0 ? sorted[sorted.length - 1]!.seq : 0;
|
|
126
|
+
return {
|
|
127
|
+
since: (cursorArg) => sorted.filter((entry) => entry.seq > cursorArg),
|
|
128
|
+
cursor,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A job's mid-flight input channel -- the "steer" primitive. Bounded FIFO:
|
|
134
|
+
* push() while a handler isn't yet reading buffers up to maxQueueSize, then
|
|
135
|
+
* refuses further input rather than growing unboundedly or silently
|
|
136
|
+
* overwriting an unread entry. A handler consumes it via `for await (const
|
|
137
|
+
* input of context.steerInputs)`, which ends cleanly once close() is
|
|
138
|
+
* called (VehicleJobStore does this at job finalization).
|
|
139
|
+
*/
|
|
140
|
+
export interface VehicleJobSteerPushResult {
|
|
141
|
+
readonly accepted: boolean;
|
|
142
|
+
readonly dropReason?: "queue-full" | "channel-closed";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class VehicleJobSteerChannel implements AsyncIterable<unknown> {
|
|
146
|
+
private readonly buffer: unknown[] = [];
|
|
147
|
+
private readonly waiters: ((result: IteratorResult<unknown>) => void)[] = [];
|
|
148
|
+
private closed = false;
|
|
149
|
+
|
|
150
|
+
constructor(private readonly maxQueueSize: number = 64) {}
|
|
151
|
+
|
|
152
|
+
push(value: unknown): VehicleJobSteerPushResult {
|
|
153
|
+
if (this.closed) return { accepted: false, dropReason: "channel-closed" };
|
|
154
|
+
const waiter = this.waiters.shift();
|
|
155
|
+
if (waiter) {
|
|
156
|
+
waiter({ value, done: false });
|
|
157
|
+
return { accepted: true };
|
|
158
|
+
}
|
|
159
|
+
if (this.buffer.length >= this.maxQueueSize) return { accepted: false, dropReason: "queue-full" };
|
|
160
|
+
this.buffer.push(value);
|
|
161
|
+
return { accepted: true };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Ends every pending and future iteration with done:true; further push() calls report "channel-closed". Idempotent. */
|
|
165
|
+
close(): void {
|
|
166
|
+
if (this.closed) return;
|
|
167
|
+
this.closed = true;
|
|
168
|
+
for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
|
172
|
+
return {
|
|
173
|
+
next: (): Promise<IteratorResult<unknown>> => {
|
|
174
|
+
if (this.buffer.length > 0) return Promise.resolve({ value: this.buffer.shift(), done: false });
|
|
175
|
+
if (this.closed) return Promise.resolve({ value: undefined, done: true });
|
|
176
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Vehicle Jobs run as in-process promises, not child processes -- there is
|
|
184
|
+
* no PID to reuse, but the same identity-confusion risk vstack's
|
|
185
|
+
* {pid, startToken, comm} design guards against still applies in a
|
|
186
|
+
* generalized form: a persisted job record written by one process
|
|
187
|
+
* instance must never be mistaken for one this (possibly restarted)
|
|
188
|
+
* instance can still resolve. Each VehicleJobStore construction gets a
|
|
189
|
+
* fresh random instanceToken; a persisted record's own stamped token only
|
|
190
|
+
* ever matches the instance that wrote it. A mismatch means "the original
|
|
191
|
+
* run is gone", the same conclusion vstack's identityMatches() reaches by
|
|
192
|
+
* comparing a live process's actual pid/start-time/command against a
|
|
193
|
+
* stored snapshot -- this is that same check with no process to inspect.
|
|
194
|
+
*/
|
|
195
|
+
export function vehicleJobIdentityMatches(recordInstanceToken: string, currentInstanceToken: string): boolean {
|
|
196
|
+
return recordInstanceToken === currentInstanceToken;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Minimal shape selectVehicleJobsForEviction needs from a job record -- kept separate from VehicleJobSnapshot so vehicle-server doesn't have to construct a full snapshot just to ask "should this be swept". */
|
|
200
|
+
export interface VehicleJobEvictionCandidate {
|
|
201
|
+
readonly jobId: string;
|
|
202
|
+
readonly status: VehicleJobStatus;
|
|
203
|
+
readonly delivered: boolean;
|
|
204
|
+
readonly updatedAt: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface VehicleJobRetentionOptions {
|
|
208
|
+
/** Hard cap on total retained job records (of any status). A running job is never evicted regardless of this cap. */
|
|
209
|
+
readonly maxRetainedJobs: number;
|
|
210
|
+
/** A delivered terminal job becomes eligible for eviction once this many ms have passed since it was delivered (== updatedAt at delivery time). */
|
|
211
|
+
readonly deliveredRetentionMs: number;
|
|
212
|
+
readonly now: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Pure eviction-selection policy, kept separate from VehicleJobStore's own
|
|
217
|
+
* bookkeeping so the bounded-retention rule is independently testable.
|
|
218
|
+
* Preference order: (1) delivered and past deliveredRetentionMs, oldest
|
|
219
|
+
* first; (2) once still over maxRetainedJobs, any delivered terminal job,
|
|
220
|
+
* oldest first; (3) only as a last resort, an undelivered terminal job,
|
|
221
|
+
* oldest first -- a real loss (a caller may still want that result), but
|
|
222
|
+
* an unbounded store is a worse failure mode. A running job is never a
|
|
223
|
+
* candidate.
|
|
224
|
+
*/
|
|
225
|
+
export function selectVehicleJobsForEviction(
|
|
226
|
+
candidates: readonly VehicleJobEvictionCandidate[],
|
|
227
|
+
options: VehicleJobRetentionOptions,
|
|
228
|
+
): readonly string[] {
|
|
229
|
+
const terminal = candidates.filter((candidate) => candidate.status !== "running");
|
|
230
|
+
const byAgeAscending = (a: VehicleJobEvictionCandidate, b: VehicleJobEvictionCandidate) => a.updatedAt - b.updatedAt;
|
|
231
|
+
|
|
232
|
+
const evicted = new Set<string>();
|
|
233
|
+
for (const candidate of terminal) {
|
|
234
|
+
if (candidate.delivered && options.now - candidate.updatedAt >= options.deliveredRetentionMs) evicted.add(candidate.jobId);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const remainingCount = () => candidates.length - evicted.size;
|
|
238
|
+
if (remainingCount() > options.maxRetainedJobs) {
|
|
239
|
+
const deliveredOldestFirst = terminal.filter((candidate) => candidate.delivered && !evicted.has(candidate.jobId)).sort(byAgeAscending);
|
|
240
|
+
for (const candidate of deliveredOldestFirst) {
|
|
241
|
+
if (remainingCount() <= options.maxRetainedJobs) break;
|
|
242
|
+
evicted.add(candidate.jobId);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (remainingCount() > options.maxRetainedJobs) {
|
|
246
|
+
const undeliveredOldestFirst = terminal
|
|
247
|
+
.filter((candidate) => !candidate.delivered && !evicted.has(candidate.jobId))
|
|
248
|
+
.sort(byAgeAscending);
|
|
249
|
+
for (const candidate of undeliveredOldestFirst) {
|
|
250
|
+
if (remainingCount() <= options.maxRetainedJobs) break;
|
|
251
|
+
evicted.add(candidate.jobId);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [...evicted];
|
|
255
|
+
}
|