@hraness/sponge 0.3.4 → 0.4.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/README.md +16 -7
- package/dist/migrations/011.d.ts +3 -0
- package/dist/migrations/011.js +9 -0
- package/dist/migrations/index.js +2 -0
- package/dist/src/annotations/quote.d.ts +1 -0
- package/dist/src/annotations/quote.js +11 -1
- package/dist/src/cli/commands.js +51 -6
- package/dist/src/cli/runtimes.js +6 -2
- package/dist/src/cli/worker.js +5 -1
- package/dist/src/digest/endpoint.d.ts +3 -2
- package/dist/src/digest/endpoint.js +3 -2
- package/dist/src/digest/pipeline.js +6 -3
- package/dist/src/digest/runner.d.ts +33 -0
- package/dist/src/digest/runner.js +82 -0
- package/dist/src/digest/schema.js +6 -2
- package/dist/src/digest/server.d.ts +40 -0
- package/dist/src/digest/server.js +166 -0
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +2 -0
- package/dist/src/importers/custody.d.ts +2 -5
- package/dist/src/importers/custody.js +3 -51
- package/dist/src/models/artifacts.d.ts +5 -0
- package/dist/src/models/artifacts.js +92 -17
- package/dist/src/models/digest.d.ts +64 -0
- package/dist/src/models/digest.js +122 -0
- package/dist/src/portability/database.js +2 -0
- package/dist/src/search/query.js +7 -54
- package/dist/src/store/digest-jobs.d.ts +55 -0
- package/dist/src/store/digest-jobs.js +164 -0
- package/dist/src/store/library.d.ts +3 -0
- package/dist/src/store/library.js +5 -1
- package/dist/src/store/queue.d.ts +9 -5
- package/dist/src/store/queue.js +14 -9
- package/dist/src/store/revisions.js +4 -0
- package/dist/src/worker/custody.d.ts +5 -0
- package/dist/src/worker/custody.js +7 -4
- package/dist/src/worker/transient.d.ts +17 -0
- package/dist/src/worker/transient.js +60 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { text } from './files.js';
|
|
3
|
+
import { checkOwner } from './lease.js';
|
|
4
|
+
import { now, transaction } from './context.js';
|
|
5
|
+
import { SpongeError } from '../errors.js';
|
|
6
|
+
/** Automatic retries a retryable failure gets before it stays failed. */
|
|
7
|
+
const maxAttempts = 3;
|
|
8
|
+
const alias = /^[\w.:/@+-]{1,256}$/;
|
|
9
|
+
/** Queue a digest of a newly captured revision when auto-digest is on. Runs inside the capture commit's transaction. */
|
|
10
|
+
export function enqueueDigest(db, itemId, revisionId) {
|
|
11
|
+
if (db.prepare('SELECT auto FROM digest_settings WHERE id=1').get()?.auto !== 1)
|
|
12
|
+
return;
|
|
13
|
+
db.prepare("INSERT INTO digest_jobs(id,item_id,revision_id,state,created_at) VALUES(?,?,?,'queued',?) ON CONFLICT(revision_id) DO NOTHING").run(randomUUID(), itemId, revisionId, now());
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Digest jobs under the shared worker lease. A job names one revision; when its item moves on or is deleted before the
|
|
17
|
+
* worker reaches it, the job is cancelled as `superseded`, since the newer revision has a job of its own. The drafting
|
|
18
|
+
* server's process custody is the worker's filesystem journal (`withDigestServer`), so the database holds no attempt
|
|
19
|
+
* rows or staging, and owner recovery returns a crashed owner's running job to the queue.
|
|
20
|
+
*/
|
|
21
|
+
export class DigestJobs {
|
|
22
|
+
c;
|
|
23
|
+
jobs;
|
|
24
|
+
constructor(c) {
|
|
25
|
+
this.c = c;
|
|
26
|
+
const db = c.db;
|
|
27
|
+
this.jobs = {
|
|
28
|
+
owns: (id) => !!db.prepare('SELECT 1 FROM digest_jobs WHERE id=?').get(id),
|
|
29
|
+
list: () => db
|
|
30
|
+
.prepare("SELECT d.*,'digest' AS operation FROM digest_jobs d JOIN items i ON i.id=d.item_id WHERE i.deleted_at IS NULL ORDER BY d.created_at,d.id")
|
|
31
|
+
.all(),
|
|
32
|
+
attempts: () => [],
|
|
33
|
+
cancel: (id) => transaction(db, () => {
|
|
34
|
+
const job = this.row(id);
|
|
35
|
+
if (['succeeded', 'cancelled'].includes(String(job.state)))
|
|
36
|
+
return;
|
|
37
|
+
db.prepare("UPDATE digest_jobs SET cancel_requested=1,state=CASE WHEN state='running' THEN state ELSE 'cancelled' END,finished_at=CASE WHEN state='running' THEN finished_at ELSE ? END WHERE id=?").run(now(), id);
|
|
38
|
+
}),
|
|
39
|
+
retry: (id, options) => {
|
|
40
|
+
if (options.automatic || options.notBefore !== undefined)
|
|
41
|
+
throw new SpongeError('invalid-input');
|
|
42
|
+
transaction(db, () => {
|
|
43
|
+
const job = this.row(id);
|
|
44
|
+
if (job.state !== 'failed' || job.cancel_requested)
|
|
45
|
+
throw new SpongeError('unavailable');
|
|
46
|
+
db.prepare("UPDATE digest_jobs SET state='queued',reason=NULL,retryable=0,not_before=NULL,attempt_count=0,finished_at=NULL WHERE id=?").run(id);
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
cancelRequested: (id) => this.row(id).cancel_requested === 1,
|
|
50
|
+
recover: () => {
|
|
51
|
+
throw new SpongeError('integrity');
|
|
52
|
+
},
|
|
53
|
+
release: (owner) => {
|
|
54
|
+
db.prepare("UPDATE digest_jobs SET state=CASE WHEN cancel_requested=1 THEN 'cancelled' ELSE 'queued' END,owner_incarnation=NULL,fence=NULL WHERE state='running' AND owner_incarnation=? AND fence=?").run(owner.ownerIncarnation, owner.fence);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
settings() {
|
|
59
|
+
const row = this.c.db.prepare('SELECT auto,model FROM digest_settings WHERE id=1').get();
|
|
60
|
+
return { auto: row.auto === 1, model: row.model === null ? null : String(row.model) };
|
|
61
|
+
}
|
|
62
|
+
/** Turn auto-digest on or off. Turning it on queues nothing already captured; turning it off leaves queued jobs. */
|
|
63
|
+
configure(settings) {
|
|
64
|
+
if (settings.auto !== undefined && typeof settings.auto !== 'boolean')
|
|
65
|
+
throw new SpongeError('invalid-input');
|
|
66
|
+
if (settings.model !== undefined && settings.model !== null && !alias.test(String(settings.model)))
|
|
67
|
+
throw new SpongeError('invalid-input');
|
|
68
|
+
transaction(this.c.db, () => {
|
|
69
|
+
if (settings.auto !== undefined)
|
|
70
|
+
this.c.db.prepare('UPDATE digest_settings SET auto=? WHERE id=1').run(Number(settings.auto));
|
|
71
|
+
if (settings.model !== undefined)
|
|
72
|
+
this.c.db.prepare('UPDATE digest_settings SET model=? WHERE id=1').run(settings.model);
|
|
73
|
+
});
|
|
74
|
+
return this.settings();
|
|
75
|
+
}
|
|
76
|
+
/** Whether a job is ready for `claim` now. */
|
|
77
|
+
due() {
|
|
78
|
+
return !!this.c.db
|
|
79
|
+
.prepare("SELECT 1 FROM digest_jobs WHERE state IN ('queued','delayed') AND cancel_requested=0 AND (not_before IS NULL OR not_before<=?) LIMIT 1")
|
|
80
|
+
.get(now());
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Delay every ready job to `notBefore` with `reason`, without counting an attempt: the drafting runtime is missing or
|
|
84
|
+
* could not start, so no job reached the model. Running jobs are untouched. Returns how many were delayed.
|
|
85
|
+
*/
|
|
86
|
+
postpone(reason, notBefore) {
|
|
87
|
+
if (!/^[a-z][a-z-]{0,63}$/.test(reason) || !Number.isFinite(Date.parse(notBefore)))
|
|
88
|
+
throw new SpongeError('invalid-input');
|
|
89
|
+
return Number(this.c.db
|
|
90
|
+
.prepare("UPDATE digest_jobs SET state='delayed',reason=?,retryable=1,not_before=? WHERE state IN ('queued','delayed') AND cancel_requested=0 AND (not_before IS NULL OR not_before<=?)")
|
|
91
|
+
.run(reason, notBefore, now()).changes);
|
|
92
|
+
}
|
|
93
|
+
/** The oldest ready job whose revision is still its item's current one; jobs passed over on the way are superseded. */
|
|
94
|
+
claim(owner) {
|
|
95
|
+
const db = this.c.db;
|
|
96
|
+
return transaction(db, () => {
|
|
97
|
+
checkOwner(db, owner);
|
|
98
|
+
for (;;) {
|
|
99
|
+
const job = db
|
|
100
|
+
.prepare("SELECT d.id,d.item_id,d.revision_id,i.current_revision,i.deleted_at FROM digest_jobs d JOIN items i ON i.id=d.item_id WHERE d.state IN ('queued','delayed') AND d.cancel_requested=0 AND (d.not_before IS NULL OR d.not_before<=?) ORDER BY d.created_at,d.id LIMIT 1")
|
|
101
|
+
.get(now());
|
|
102
|
+
if (!job)
|
|
103
|
+
return null;
|
|
104
|
+
if (job.deleted_at !== null || job.current_revision !== job.revision_id) {
|
|
105
|
+
db.prepare("UPDATE digest_jobs SET state='cancelled',reason='superseded',finished_at=? WHERE id=?").run(now(), String(job.id));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
db.prepare("UPDATE digest_jobs SET state='running',attempt_count=attempt_count+1,owner_incarnation=?,fence=? WHERE id=?").run(owner.ownerIncarnation, owner.fence, String(job.id));
|
|
109
|
+
return {
|
|
110
|
+
...owner,
|
|
111
|
+
jobId: String(job.id),
|
|
112
|
+
itemId: String(job.item_id),
|
|
113
|
+
revisionId: String(job.revision_id),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Settle a claimed job. A retryable failure under the attempt limit is delayed until `notBefore` (default: a backoff
|
|
120
|
+
* from the attempt count); `countAttempt: false` gives the attempt back, for a refusal that never reached the model.
|
|
121
|
+
*/
|
|
122
|
+
finish(claim, outcome) {
|
|
123
|
+
const db = this.c.db;
|
|
124
|
+
if ('reason' in outcome && !/^[a-z][a-z-]{0,63}$/.test(outcome.reason))
|
|
125
|
+
throw new SpongeError('invalid-input');
|
|
126
|
+
if ('reason' in outcome && outcome.notBefore !== undefined && !Number.isFinite(Date.parse(outcome.notBefore)))
|
|
127
|
+
throw new SpongeError('invalid-input');
|
|
128
|
+
transaction(db, () => {
|
|
129
|
+
checkOwner(db, claim);
|
|
130
|
+
const job = this.row(claim.jobId);
|
|
131
|
+
if (job.state !== 'running' || job.owner_incarnation !== claim.ownerIncarnation || job.fence !== claim.fence)
|
|
132
|
+
throw new SpongeError('owner-busy');
|
|
133
|
+
const release = 'UPDATE digest_jobs SET owner_incarnation=NULL,fence=NULL WHERE id=?';
|
|
134
|
+
if (job.cancel_requested) {
|
|
135
|
+
db.prepare("UPDATE digest_jobs SET state='cancelled',derivation_id=?,finished_at=? WHERE id=?").run('derivationId' in outcome ? outcome.derivationId : null, now(), claim.jobId);
|
|
136
|
+
}
|
|
137
|
+
else if ('derivationId' in outcome) {
|
|
138
|
+
text(outcome.derivationId, 256);
|
|
139
|
+
db.prepare("UPDATE digest_jobs SET state='succeeded',reason=NULL,retryable=0,not_before=NULL,derivation_id=?,finished_at=? WHERE id=?").run(outcome.derivationId, now(), claim.jobId);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const attempts = Number(job.attempt_count) - (outcome.countAttempt === false ? 1 : 0);
|
|
143
|
+
if (outcome.retryable && (attempts < maxAttempts || outcome.countAttempt === false)) {
|
|
144
|
+
const due = outcome.notBefore ?? new Date(Date.now() + 60000 * 2 ** Math.max(0, attempts - 1)).toISOString();
|
|
145
|
+
db.prepare("UPDATE digest_jobs SET state='delayed',reason=?,retryable=1,not_before=?,attempt_count=? WHERE id=?").run(outcome.reason, due, attempts, claim.jobId);
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
db.prepare("UPDATE digest_jobs SET state='failed',reason=?,retryable=?,not_before=NULL,attempt_count=?,finished_at=? WHERE id=?").run(outcome.reason, Number(!!outcome.retryable), attempts, now(), claim.jobId);
|
|
149
|
+
}
|
|
150
|
+
db.prepare(release).run(claim.jobId);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** Whether the user asked to cancel the claimed job while it ran. */
|
|
154
|
+
cancelRequested(claim) {
|
|
155
|
+
return this.row(claim.jobId).cancel_requested === 1;
|
|
156
|
+
}
|
|
157
|
+
row(id) {
|
|
158
|
+
text(id, 256);
|
|
159
|
+
const job = this.c.db.prepare('SELECT * FROM digest_jobs WHERE id=?').get(id);
|
|
160
|
+
if (!job)
|
|
161
|
+
throw new SpongeError('unavailable');
|
|
162
|
+
return job;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -6,6 +6,7 @@ import { Revisions } from './revisions.js';
|
|
|
6
6
|
import { Derivations } from './derivations.js';
|
|
7
7
|
import { Annotations } from './annotations.js';
|
|
8
8
|
import { SearchIndex } from './search-index.js';
|
|
9
|
+
import { DigestJobs } from './digest-jobs.js';
|
|
9
10
|
import { Backups } from './backups.js';
|
|
10
11
|
import { Imports } from './imports.js';
|
|
11
12
|
import type { ReadableItem, StoreOptions } from './types.js';
|
|
@@ -32,6 +33,8 @@ export declare class LibraryStore {
|
|
|
32
33
|
readonly annotations: Annotations;
|
|
33
34
|
/** Embedding index generations, index jobs, pruning and hybrid search. */
|
|
34
35
|
readonly index: SearchIndex;
|
|
36
|
+
/** Digest jobs and the auto-digest settings. */
|
|
37
|
+
readonly digests: DigestJobs;
|
|
35
38
|
readonly backups: Backups;
|
|
36
39
|
readonly imports: Imports;
|
|
37
40
|
constructor(root: string, db: DatabaseSync, options: StoreOptions);
|
|
@@ -11,6 +11,7 @@ import { Revisions } from './revisions.js';
|
|
|
11
11
|
import { Derivations } from './derivations.js';
|
|
12
12
|
import { Annotations } from './annotations.js';
|
|
13
13
|
import { SearchIndex } from './search-index.js';
|
|
14
|
+
import { DigestJobs } from './digest-jobs.js';
|
|
14
15
|
import { Backups } from './backups.js';
|
|
15
16
|
import { Imports } from './imports.js';
|
|
16
17
|
import { validateMetadataWarnings } from '../../contracts/validate.js';
|
|
@@ -32,6 +33,8 @@ export class LibraryStore {
|
|
|
32
33
|
annotations;
|
|
33
34
|
/** Embedding index generations, index jobs, pruning and hybrid search. */
|
|
34
35
|
index;
|
|
36
|
+
/** Digest jobs and the auto-digest settings. */
|
|
37
|
+
digests;
|
|
35
38
|
backups;
|
|
36
39
|
imports;
|
|
37
40
|
constructor(root, db, options) {
|
|
@@ -39,7 +42,8 @@ export class LibraryStore {
|
|
|
39
42
|
const c = { root, db, options };
|
|
40
43
|
this.db = db;
|
|
41
44
|
this.index = new SearchIndex(c);
|
|
42
|
-
this.
|
|
45
|
+
this.digests = new DigestJobs(c);
|
|
46
|
+
this.queue = new Queue(c, [this.index.jobs, this.digests.jobs]);
|
|
43
47
|
this.blobs = new Blobs(c);
|
|
44
48
|
this.revisions = new Revisions(c);
|
|
45
49
|
this.derivations = new Derivations(c);
|
|
@@ -6,7 +6,8 @@ export interface RetryOptions {
|
|
|
6
6
|
automatic?: boolean;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
* One kind of job under the shared worker lease. Capture jobs live here; index jobs in `search-index.ts
|
|
9
|
+
* One kind of job under the shared worker lease. Capture jobs live here; index jobs in `search-index.ts`, digest jobs in
|
|
10
|
+
* `digest-jobs.ts`. The queue dispatches
|
|
10
11
|
* listing, cancellation, retry and owner recovery through this interface, so a new job kind plugs in without new branches.
|
|
11
12
|
*/
|
|
12
13
|
export interface JobKind {
|
|
@@ -19,19 +20,22 @@ export interface JobKind {
|
|
|
19
20
|
cancelRequested(jobId: string): boolean;
|
|
20
21
|
/** Inside `recoverOwner`'s transaction, once custody of the attempt is proved: remove its staging and settle it. */
|
|
21
22
|
recover(attempt: OwnedAttempt): void;
|
|
23
|
+
/** Inside `recoverOwner`'s transaction: return work the recovered owner held without attempt rows. */
|
|
24
|
+
release?(owner: OwnerToken): void;
|
|
22
25
|
}
|
|
23
26
|
/** Capture jobs (acquire, extract, commit one source) and the owner lease shared with every other job kind. */
|
|
24
27
|
export declare class Queue {
|
|
25
28
|
private readonly c;
|
|
26
|
-
private readonly
|
|
29
|
+
private readonly kinds;
|
|
27
30
|
private readonly capture;
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
/** `kinds` are every other job kind, in dispatch order. */
|
|
32
|
+
constructor(c: Context, kinds: readonly JobKind[]);
|
|
33
|
+
/** The other kinds are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
|
|
30
34
|
private kindOf;
|
|
31
35
|
acquireOwner(ownerIncarnation: string): OwnerToken;
|
|
32
36
|
releaseOwner(token: OwnerToken): void;
|
|
33
37
|
ownerSnapshot(): OwnerToken | null;
|
|
34
|
-
/** Every attempt the owner claimed, capture first, then
|
|
38
|
+
/** Every attempt the owner claimed, capture first, then the other kinds in order. */
|
|
35
39
|
ownerAttempts(token: OwnerToken): OwnedAttempt[];
|
|
36
40
|
/** Requires external custody proof for EVERY pending attempt, even after its job committed.
|
|
37
41
|
* IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
|
package/dist/src/store/queue.js
CHANGED
|
@@ -12,11 +12,12 @@ const activeStates = "('queued','running','delayed','cleanup-pending')";
|
|
|
12
12
|
/** Capture jobs (acquire, extract, commit one source) and the owner lease shared with every other job kind. */
|
|
13
13
|
export class Queue {
|
|
14
14
|
c;
|
|
15
|
-
|
|
15
|
+
kinds;
|
|
16
16
|
capture;
|
|
17
|
-
|
|
17
|
+
/** `kinds` are every other job kind, in dispatch order. */
|
|
18
|
+
constructor(c, kinds) {
|
|
18
19
|
this.c = c;
|
|
19
|
-
this.
|
|
20
|
+
this.kinds = kinds;
|
|
20
21
|
const db = c.db;
|
|
21
22
|
this.capture = {
|
|
22
23
|
owns: (id) => !!db.prepare('SELECT 1 FROM jobs WHERE id=?').get(id),
|
|
@@ -99,9 +100,9 @@ export class Queue {
|
|
|
99
100
|
},
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
|
-
/**
|
|
103
|
+
/** The other kinds are checked first; any other id is a capture job (which reports `unavailable` when it does not exist). */
|
|
103
104
|
kindOf(jobId) {
|
|
104
|
-
return this.
|
|
105
|
+
return this.kinds.find((kind) => kind.owns(jobId)) ?? this.capture;
|
|
105
106
|
}
|
|
106
107
|
acquireOwner(ownerIncarnation) {
|
|
107
108
|
text(ownerIncarnation, 256);
|
|
@@ -127,10 +128,10 @@ export class Queue {
|
|
|
127
128
|
ownerSnapshot() {
|
|
128
129
|
return ownerSnapshot(this.c.db);
|
|
129
130
|
}
|
|
130
|
-
/** Every attempt the owner claimed, capture first, then
|
|
131
|
+
/** Every attempt the owner claimed, capture first, then the other kinds in order. */
|
|
131
132
|
ownerAttempts(token) {
|
|
132
133
|
checkOwner(this.c.db, token);
|
|
133
|
-
return [
|
|
134
|
+
return [this.capture, ...this.kinds].flatMap((kind) => kind.attempts(token));
|
|
134
135
|
}
|
|
135
136
|
/** Requires external custody proof for EVERY pending attempt, even after its job committed.
|
|
136
137
|
* IDs are acknowledgements from the custody owner, never evidence inferred from PID/timeouts here. */
|
|
@@ -146,7 +147,9 @@ export class Queue {
|
|
|
146
147
|
if (proof.size !== pending.length || pending.some((a) => !proof.has(a.attemptId)))
|
|
147
148
|
throw new SpongeError('owner-busy');
|
|
148
149
|
for (const attempt of pending)
|
|
149
|
-
(attempt.
|
|
150
|
+
this.kindOf(attempt.jobId).recover(attempt);
|
|
151
|
+
for (const kind of this.kinds)
|
|
152
|
+
kind.release?.(expected);
|
|
150
153
|
this.c.db.prepare('UPDATE worker_owner SET incarnation=NULL,fence=fence+1 WHERE id=1').run();
|
|
151
154
|
});
|
|
152
155
|
}
|
|
@@ -217,7 +220,9 @@ export class Queue {
|
|
|
217
220
|
}
|
|
218
221
|
/** Jobs of every kind, oldest first. */
|
|
219
222
|
list() {
|
|
220
|
-
return [
|
|
223
|
+
return [this.capture, ...this.kinds]
|
|
224
|
+
.flatMap((kind) => kind.list())
|
|
225
|
+
.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id)));
|
|
221
226
|
}
|
|
222
227
|
/** Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the synced form. */
|
|
223
228
|
source(jobId) {
|
|
@@ -11,6 +11,7 @@ import { indexReadable } from '../search/lexical.js';
|
|
|
11
11
|
import { trackLibrary } from '../indexing/index.js';
|
|
12
12
|
import { checkAttempt } from './lease.js';
|
|
13
13
|
import { importReceipt } from './imports.js';
|
|
14
|
+
import { enqueueDigest } from './digest-jobs.js';
|
|
14
15
|
import { bumpEpoch, now, transaction } from './context.js';
|
|
15
16
|
import { SpongeError } from '../errors.js';
|
|
16
17
|
/** Captured revisions: content-addressed, immutable once committed, and the item's readable pointer. */
|
|
@@ -151,6 +152,9 @@ export class Revisions {
|
|
|
151
152
|
bumpEpoch(db);
|
|
152
153
|
// Active indexes follow the new revision; only its new chunks are queued for embedding.
|
|
153
154
|
trackLibrary(db);
|
|
155
|
+
// A capture, not an import: imported libraries are digested on purpose, not as a side effect.
|
|
156
|
+
if (!imported)
|
|
157
|
+
enqueueDigest(db, input.itemId, revisionId);
|
|
154
158
|
}
|
|
155
159
|
if (imported) {
|
|
156
160
|
const receipt = importReceipt(db, imported.namespace, imported.sourceKey, imported.sourceHash);
|
|
@@ -7,6 +7,10 @@ export interface WorkerCustody {
|
|
|
7
7
|
export declare function acquireWorkerCustody(root: string): Promise<WorkerCustody>;
|
|
8
8
|
/** Receipts are specific to every pre-launch ticket. Missing receipts never mean absent children. */
|
|
9
9
|
export declare function proveAttemptCleanup(ownerDirectory: string, incarnation: string, attemptId: string): Promise<boolean>;
|
|
10
|
+
/** Attempts outside the job tables: a search query, an import verification, a digest server. Each writes `KIND.json`
|
|
11
|
+
* under its attempt and `staging/KIND-ID/ownership.json`. */
|
|
12
|
+
declare const transientKinds: readonly ["query", "import-verification", "digest-server"];
|
|
13
|
+
export type TransientKind = (typeof transientKinds)[number];
|
|
10
14
|
export type RecoveryFault = 'guarded' | 'recovered';
|
|
11
15
|
export declare function recoverWorker(store: LibraryStore, options?: {
|
|
12
16
|
fault?: (point: RecoveryFault) => void;
|
|
@@ -14,3 +18,4 @@ export declare function recoverWorker(store: LibraryStore, options?: {
|
|
|
14
18
|
recovered: boolean;
|
|
15
19
|
attempts: number;
|
|
16
20
|
}>;
|
|
21
|
+
export {};
|
|
@@ -116,6 +116,9 @@ async function sweepTemps(path, depth = 2) {
|
|
|
116
116
|
if (removed)
|
|
117
117
|
await syncDirectoryAsync(path);
|
|
118
118
|
}
|
|
119
|
+
/** Attempts outside the job tables: a search query, an import verification, a digest server. Each writes `KIND.json`
|
|
120
|
+
* under its attempt and `staging/KIND-ID/ownership.json`. */
|
|
121
|
+
const transientKinds = ['query', 'import-verification', 'digest-server'];
|
|
119
122
|
export async function recoverWorker(store, options = {}) {
|
|
120
123
|
const lock = join(store.root, 'runtime', 'worker.lock');
|
|
121
124
|
let record;
|
|
@@ -171,7 +174,7 @@ export async function recoverWorker(store, options = {}) {
|
|
|
171
174
|
identifier(id);
|
|
172
175
|
if (!attemptIds.has(id)) {
|
|
173
176
|
let marker;
|
|
174
|
-
for (const kind of
|
|
177
|
+
for (const kind of transientKinds) {
|
|
175
178
|
try {
|
|
176
179
|
const candidate = await readJournal(join(ownerDirectory, 'attempts', id, kind + '.json'));
|
|
177
180
|
if (marker || candidate.kind !== kind)
|
|
@@ -189,7 +192,7 @@ export async function recoverWorker(store, options = {}) {
|
|
|
189
192
|
}
|
|
190
193
|
else {
|
|
191
194
|
if (marker.schemaVersion !== 1 ||
|
|
192
|
-
!
|
|
195
|
+
!transientKinds.includes(marker.kind) ||
|
|
193
196
|
marker.attemptId !== id ||
|
|
194
197
|
marker.ownerIncarnation !== record.incarnation)
|
|
195
198
|
throw pending();
|
|
@@ -201,7 +204,7 @@ export async function recoverWorker(store, options = {}) {
|
|
|
201
204
|
}
|
|
202
205
|
const staging = join(store.root, 'staging');
|
|
203
206
|
for (const entry of await readdir(staging)) {
|
|
204
|
-
if (
|
|
207
|
+
if (transientKinds.some((kind) => entry.startsWith(kind + '-'))) {
|
|
205
208
|
const path = join(staging, entry);
|
|
206
209
|
await directory(path);
|
|
207
210
|
let marker;
|
|
@@ -212,7 +215,7 @@ export async function recoverWorker(store, options = {}) {
|
|
|
212
215
|
throw pending();
|
|
213
216
|
}
|
|
214
217
|
if (marker.schemaVersion !== 1 ||
|
|
215
|
-
!
|
|
218
|
+
!transientKinds.includes(marker.kind) ||
|
|
216
219
|
typeof marker.requestId !== 'string' ||
|
|
217
220
|
entry !== marker.kind + '-' + marker.requestId ||
|
|
218
221
|
typeof marker.ownerIncarnation !== 'string')
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type TransientKind } from './custody.js';
|
|
2
|
+
import { type ProcessRunner } from './supervisor.js';
|
|
3
|
+
export interface TransientAttempt {
|
|
4
|
+
attemptId: string;
|
|
5
|
+
/** The custody incarnation, for `queue.acquireOwner` when the attempt also claims jobs. */
|
|
6
|
+
incarnation: string;
|
|
7
|
+
ownerDirectory: string;
|
|
8
|
+
scratchRoot: string;
|
|
9
|
+
/** Runs helpers under the worker supervisor, so their process groups are proved collected. */
|
|
10
|
+
runProcess: ProcessRunner;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One custody-held attempt outside the job tables. It writes `KIND.json` under the attempt and an ownership marker in
|
|
14
|
+
* `staging/KIND-ID` (what `recoverWorker` expects), proves every helper collected, removes the scratch root and
|
|
15
|
+
* releases custody. Unproven cleanup keeps custody and throws `cleanup-required`. Mutates coordination journals only.
|
|
16
|
+
*/
|
|
17
|
+
export declare function withTransientAttempt<T>(libraryRoot: string, kind: TransientKind, fn: (attempt: TransientAttempt) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
4
|
+
import { acquireWorkerCustody, proveAttemptCleanup } from './custody.js';
|
|
5
|
+
import { directory, writeJournal, readJournal } from './journal.js';
|
|
6
|
+
import { supervisedRunner } from './supervisor.js';
|
|
7
|
+
import { syncDirectoryAsync } from '../util/fsync.js';
|
|
8
|
+
import { SpongeError, codeOf } from '../errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* One custody-held attempt outside the job tables. It writes `KIND.json` under the attempt and an ownership marker in
|
|
11
|
+
* `staging/KIND-ID` (what `recoverWorker` expects), proves every helper collected, removes the scratch root and
|
|
12
|
+
* releases custody. Unproven cleanup keeps custody and throws `cleanup-required`. Mutates coordination journals only.
|
|
13
|
+
*/
|
|
14
|
+
export async function withTransientAttempt(libraryRoot, kind, fn) {
|
|
15
|
+
const custody = await acquireWorkerCustody(libraryRoot), attemptId = randomUUID();
|
|
16
|
+
let scratchRoot, cleanupUnknown = false;
|
|
17
|
+
try {
|
|
18
|
+
const attempts = await directory(join(custody.directory, 'attempts', attemptId));
|
|
19
|
+
await writeJournal(join(attempts, kind + '.json'), {
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
kind,
|
|
22
|
+
attemptId,
|
|
23
|
+
ownerIncarnation: custody.incarnation,
|
|
24
|
+
});
|
|
25
|
+
scratchRoot = await directory(join(libraryRoot, 'staging', `${kind}-${attemptId}`));
|
|
26
|
+
await writeJournal(join(scratchRoot, 'ownership.json'), {
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
kind,
|
|
29
|
+
requestId: attemptId,
|
|
30
|
+
ownerIncarnation: custody.incarnation,
|
|
31
|
+
});
|
|
32
|
+
return await fn({
|
|
33
|
+
attemptId,
|
|
34
|
+
incarnation: custody.incarnation,
|
|
35
|
+
ownerDirectory: custody.directory,
|
|
36
|
+
scratchRoot,
|
|
37
|
+
runProcess: supervisedRunner(custody.directory, custody.incarnation, attemptId),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
cleanupUnknown = codeOf(error) === 'cleanup-required';
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
const proved = !cleanupUnknown && (await proveAttemptCleanup(custody.directory, custody.incarnation, attemptId));
|
|
46
|
+
if (!proved)
|
|
47
|
+
throw new SpongeError('cleanup-required');
|
|
48
|
+
if (scratchRoot) {
|
|
49
|
+
const marker = await readJournal(join(scratchRoot, 'ownership.json'));
|
|
50
|
+
if (marker.schemaVersion !== 1 ||
|
|
51
|
+
marker.kind !== kind ||
|
|
52
|
+
marker.requestId !== attemptId ||
|
|
53
|
+
marker.ownerIncarnation !== custody.incarnation)
|
|
54
|
+
throw new SpongeError('cleanup-required');
|
|
55
|
+
await rm(scratchRoot, { recursive: true });
|
|
56
|
+
await syncDirectoryAsync(join(libraryRoot, 'staging'));
|
|
57
|
+
}
|
|
58
|
+
await custody.release();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -27,14 +27,14 @@
|
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
29
|
"build": {
|
|
30
|
-
"asrSourceSha256": "
|
|
30
|
+
"asrSourceSha256": "21a284b38eca324015bc9d03efb14335a5a73f8b3761b0f547180bf851fce2a3",
|
|
31
31
|
"clangVersion": "Apple clang version 21.0.0 (clang-2100.1.1.101)",
|
|
32
|
-
"embeddingSourceSha256": "
|
|
32
|
+
"embeddingSourceSha256": "ff0a27cefa0ddc23e1d8bea7a70a49a112865ee730500a1da69ca9b8e6104adc",
|
|
33
33
|
"swiftVersion": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)"
|
|
34
34
|
},
|
|
35
35
|
"minimumMacOS": "26.0",
|
|
36
|
-
"packageVersion": "0.
|
|
36
|
+
"packageVersion": "0.4.0",
|
|
37
37
|
"platform": "darwin",
|
|
38
38
|
"schemaVersion": 1,
|
|
39
|
-
"sourceCommit": "
|
|
39
|
+
"sourceCommit": "307210823c8523059f896fb64ff92dade3760b59"
|
|
40
40
|
}
|