trajectories 0.0.0 → 0.1.1
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/CHANGELOG.md +23 -0
- package/LICENSE +201 -0
- package/README.md +274 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +8542 -0
- package/dist/convert.d.ts +31 -0
- package/dist/convert.js +431 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8402 -0
- package/dist/log-baseline.d.ts +26 -0
- package/dist/log-baseline.js +104 -0
- package/dist/log-content.d.ts +16 -0
- package/dist/log-content.js +80 -0
- package/dist/log-io.d.ts +10 -0
- package/dist/log-io.js +121 -0
- package/dist/log-read.d.ts +24 -0
- package/dist/log-read.js +117 -0
- package/dist/log-reconcile.d.ts +5 -0
- package/dist/log-reconcile.js +38 -0
- package/dist/log-records.d.ts +35 -0
- package/dist/log-records.js +85 -0
- package/dist/log-recovery.d.ts +18 -0
- package/dist/log-recovery.js +123 -0
- package/dist/log-stage.d.ts +14 -0
- package/dist/log-stage.js +138 -0
- package/dist/log-state.d.ts +89 -0
- package/dist/log-state.js +92 -0
- package/dist/log-switch.d.ts +3 -0
- package/dist/log-switch.js +98 -0
- package/dist/log-types.d.ts +129 -0
- package/dist/log-types.js +8 -0
- package/dist/log.d.ts +8 -0
- package/dist/log.js +62 -0
- package/dist/migrate.d.ts +29 -0
- package/dist/migrate.js +173 -0
- package/dist/normalization-runtime.d.ts +1 -0
- package/dist/normalization-runtime.js +3150 -0
- package/dist/portable.d.ts +9 -0
- package/dist/portable.js +26 -0
- package/dist/readers/canonical.d.ts +13 -0
- package/dist/readers/canonical.js +54 -0
- package/dist/readers/cline.d.ts +10 -0
- package/dist/readers/cline.js +107 -0
- package/dist/readers/cursor.d.ts +20 -0
- package/dist/readers/cursor.js +374 -0
- package/dist/readers/deepagents.d.ts +41 -0
- package/dist/readers/deepagents.js +163 -0
- package/dist/readers/fx.d.ts +11 -0
- package/dist/readers/fx.js +573 -0
- package/dist/readers/github-copilot.d.ts +8 -0
- package/dist/readers/github-copilot.js +216 -0
- package/dist/readers/grok-build.d.ts +12 -0
- package/dist/readers/grok-build.js +391 -0
- package/dist/readers/guards.d.ts +4 -0
- package/dist/readers/guards.js +121 -0
- package/dist/readers/pi-compaction.d.ts +7 -0
- package/dist/readers/pi-compaction.js +112 -0
- package/dist/readers/pi-context.d.ts +13 -0
- package/dist/readers/pi-context.js +64 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.js +218 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/writers/claude-code.d.ts +5 -0
- package/dist/writers/claude-code.js +51 -0
- package/dist/writers/cline.d.ts +3 -0
- package/dist/writers/cline.js +77 -0
- package/dist/writers/codex.d.ts +3 -0
- package/dist/writers/codex.js +32 -0
- package/dist/writers/cursor.d.ts +3 -0
- package/dist/writers/cursor.js +197 -0
- package/dist/writers/deepagents.d.ts +3 -0
- package/dist/writers/deepagents.js +116 -0
- package/dist/writers/entropy.d.ts +5 -0
- package/dist/writers/entropy.js +33 -0
- package/dist/writers/fx.d.ts +3 -0
- package/dist/writers/fx.js +135 -0
- package/dist/writers/github-copilot.d.ts +3 -0
- package/dist/writers/github-copilot.js +71 -0
- package/dist/writers/grok-build.d.ts +3 -0
- package/dist/writers/grok-build.js +43 -0
- package/dist/writers/helpers.d.ts +17 -0
- package/dist/writers/helpers.js +75 -0
- package/dist/writers/omp.d.ts +3 -0
- package/dist/writers/omp.js +7 -0
- package/dist/writers/opencode.d.ts +3 -0
- package/dist/writers/opencode.js +65 -0
- package/dist/writers/pi.d.ts +3 -0
- package/dist/writers/pi.js +45 -0
- package/docs/cline-native-format.md +43 -0
- package/docs/copilot-native-format.md +78 -0
- package/docs/cursor-native-format.md +103 -0
- package/docs/deepagents-native-format.md +98 -0
- package/docs/evidence-api.md +170 -0
- package/docs/fx-native-format.md +87 -0
- package/docs/harness-parity-bootstrap.md +78 -0
- package/docs/harness-support-parity.md +107 -0
- package/docs/inspirations.md +103 -0
- package/docs/ledger-migration.md +141 -0
- package/docs/pi-compaction-context.md +75 -0
- package/docs/pi-provider-context.md +76 -0
- package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
- package/docs/research/ledger/README.md +96 -0
- package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
- package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
- package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
- package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
- package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
- package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
- package/docs/research/ledger/docs/inspirations.md +320 -0
- package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
- package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
- package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
- package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
- package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
- package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
- package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
- package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
- package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
- package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
- package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
- package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
- package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
- package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
- package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
- package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
- package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
- package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
- package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
- package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
- package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
- package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
- package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
- package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
- package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
- package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
- package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
- package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
- package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
- package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
- package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
- package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
- package/docs/research/ledger/manifest.json +540 -0
- package/legacy/NOTICE.md +50 -0
- package/legacy/bin/ledger-core.ts +7 -0
- package/legacy/bin/ledger.ts +7 -0
- package/legacy/bin/traj-bridge.ts +7 -0
- package/legacy/scripts/import-claude-history.ts +130 -0
- package/legacy/scripts/native-history-smoke.ts +52 -0
- package/legacy/scripts/observe-daemon.ts +570 -0
- package/legacy/scripts/retitle-sessions.md +19 -0
- package/legacy/scripts/verify-runtime-rollout.ts +151 -0
- package/legacy/src/canonical.ts +108 -0
- package/legacy/src/cli.ts +1293 -0
- package/legacy/src/db.ts +2254 -0
- package/legacy/src/diagnostics.ts +87 -0
- package/legacy/src/discovery.ts +56 -0
- package/legacy/src/embeddings.ts +382 -0
- package/legacy/src/filter.ts +602 -0
- package/legacy/src/gemini_embeddings.ts +103 -0
- package/legacy/src/identity.ts +9 -0
- package/legacy/src/index.ts +10 -0
- package/legacy/src/ingest.ts +791 -0
- package/legacy/src/managed.ts +178 -0
- package/legacy/src/native/common.ts +68 -0
- package/legacy/src/native/cursor.ts +647 -0
- package/legacy/src/native/grok.ts +540 -0
- package/legacy/src/native/kimi.ts +706 -0
- package/legacy/src/native/opencode.ts +470 -0
- package/legacy/src/native/sqlite-ro.ts +281 -0
- package/legacy/src/native.ts +97 -0
- package/legacy/src/normalizer.ts +457 -0
- package/legacy/src/parentage.ts +562 -0
- package/legacy/src/pressure.ts +162 -0
- package/legacy/src/private-storage.ts +74 -0
- package/legacy/src/reader.ts +1255 -0
- package/legacy/src/scanner.ts +591 -0
- package/legacy/src/schema-gate.ts +263 -0
- package/legacy/src/schema.ts +829 -0
- package/legacy/src/semantic-top-k.ts +71 -0
- package/legacy/src/session-hash.ts +49 -0
- package/legacy/src/store-status.ts +218 -0
- package/legacy/src/sync-preparation.ts +45 -0
- package/legacy/src/sync-truth.ts +843 -0
- package/legacy/src/sync.ts +1219 -0
- package/legacy/src/types.ts +436 -0
- package/legacy/src/vocab.txt +30522 -0
- package/legacy/src/watch.ts +673 -0
- package/legacy/src/writer-lease.ts +310 -0
- package/ops/README.md +120 -0
- package/ops/install-cli.sh +63 -0
- package/ops/install-release.sh +49 -0
- package/ops/linux/install-systemd-user.sh +103 -0
- package/ops/macos/install-launchd.sh +72 -0
- package/ops/sync-settings.sh +37 -0
- package/package.json +19 -5
- package/skills/trajectories/SKILL.md +138 -0
- package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
- package/skills/trajectories/scripts/trajectories_search.sh +26 -0
- package/index.js +0 -1
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { prepareSyncStore, type SyncPreparationJob, type SyncPreparationReply } from "./sync-preparation.js";
|
|
3
|
+
import type { DiscoverySummary } from "./discovery.js";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { TrajectoryDb } from "./db.js";
|
|
7
|
+
import { TrajectoryReader } from "./reader.js";
|
|
8
|
+
import {
|
|
9
|
+
APPEND_TAIL_BYTES,
|
|
10
|
+
DEFAULT_INGEST_RECORD_BYTES,
|
|
11
|
+
IngestBudgetExceeded,
|
|
12
|
+
ingestDiscoveredSession,
|
|
13
|
+
isIncrementalJsonl,
|
|
14
|
+
sessionNeedsIngest,
|
|
15
|
+
sourceFingerprint,
|
|
16
|
+
type IngestWork,
|
|
17
|
+
} from "./ingest.js";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_PRESSURE_STATE,
|
|
20
|
+
adaptPressure,
|
|
21
|
+
samplePressure,
|
|
22
|
+
scaledLimit,
|
|
23
|
+
type AdaptivePressureState,
|
|
24
|
+
type PressureSnapshot,
|
|
25
|
+
} from "./pressure.js";
|
|
26
|
+
import { scanSessions, type SessionScanOptions } from "./scanner.js";
|
|
27
|
+
import type { DiscoveredSession, IngestCursor, IngestQueueEntry } from "./types.js";
|
|
28
|
+
import {
|
|
29
|
+
acquireWriterLease,
|
|
30
|
+
DEFAULT_WRITER_LEASE_WAIT_MS,
|
|
31
|
+
describeLeaseHolder,
|
|
32
|
+
isMemoryStorePath,
|
|
33
|
+
readWriterLease,
|
|
34
|
+
writerLockPath,
|
|
35
|
+
WriterLeaseBusyError,
|
|
36
|
+
type WriterLease,
|
|
37
|
+
type WriterLeaseMetadata,
|
|
38
|
+
} from "./writer-lease.js";
|
|
39
|
+
|
|
40
|
+
export const SYNC_PROTOCOL_VERSION = 2;
|
|
41
|
+
export const DEFAULT_SYNC_SESSION_BUDGET = 25;
|
|
42
|
+
export const DEFAULT_SYNC_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
43
|
+
export const DEFAULT_SYNC_WALL_MS = 120_000;
|
|
44
|
+
export const DEFAULT_SYNC_RETRY_BUDGET = 3;
|
|
45
|
+
export const DEFAULT_SYNC_SESSION_ATTEMPTS = 8;
|
|
46
|
+
export const DEFAULT_SYNC_READ_BYTES_PER_SECOND = 8 * 1024 * 1024;
|
|
47
|
+
export const DEFAULT_SYNC_WORKER_MS = 30_000;
|
|
48
|
+
export const DEFAULT_SYNC_WORKERS = 2;
|
|
49
|
+
export const DEFAULT_SYNC_FRESH_SESSIONS = 5;
|
|
50
|
+
export const DEFAULT_SYNC_FRESH_WINDOW_MS = 60 * 60 * 1000;
|
|
51
|
+
export const DEFAULT_SYNC_CHUNK_BYTES = 1024 * 1024;
|
|
52
|
+
export const DEFAULT_SYNC_MAINTENANCE_MS = 5_000;
|
|
53
|
+
export const DEFAULT_SYNC_FTS_MERGE_PAGES = 64;
|
|
54
|
+
export const DEFAULT_WAL_AUTOCHECKPOINT_PAGES = 1000;
|
|
55
|
+
|
|
56
|
+
/** Grace between SIGTERM and SIGKILL for a disposable child the deadline stopped. */
|
|
57
|
+
export const WORKER_KILL_GRACE_MS = 250;
|
|
58
|
+
/**
|
|
59
|
+
* How long an expired worker wave may take to finish reaping its children: the
|
|
60
|
+
* kill grace plus process exit, so the lease is never released underneath a
|
|
61
|
+
* worker that is still dying. `LEDGER_WAVE_REAP_MS` overrides it for heavily
|
|
62
|
+
* loaded volumes (strict non-negative-integer parse, clamped to at least the
|
|
63
|
+
* kill grace so the window can never undercut the escalation sequence);
|
|
64
|
+
* unparsable values keep the default.
|
|
65
|
+
*/
|
|
66
|
+
const WAVE_REAP_MS = WORKER_KILL_GRACE_MS * 2;
|
|
67
|
+
|
|
68
|
+
export function waveReapMsFor(raw: string | undefined): number {
|
|
69
|
+
if (raw === undefined || !/^\d+$/.test(raw.trim())) return WAVE_REAP_MS;
|
|
70
|
+
return Math.max(Number.parseInt(raw.trim(), 10), WORKER_KILL_GRACE_MS);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readWaveReapMs(): number {
|
|
74
|
+
return waveReapMsFor(process.env.LEDGER_WAVE_REAP_MS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SyncBudgets {
|
|
78
|
+
sessions: number;
|
|
79
|
+
sourceBytes: number;
|
|
80
|
+
wallMs: number;
|
|
81
|
+
retries: number;
|
|
82
|
+
attemptsPerSession: number;
|
|
83
|
+
readBytesPerSecond: number;
|
|
84
|
+
workerMs: number;
|
|
85
|
+
workers: number;
|
|
86
|
+
freshSessions: number;
|
|
87
|
+
freshWindowMs: number;
|
|
88
|
+
chunkBytes: number;
|
|
89
|
+
maintenanceMs: number;
|
|
90
|
+
ftsMergePages: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SyncOptions extends Pick<SessionScanOptions, "sources" | "roots"> {
|
|
94
|
+
dbPath: string;
|
|
95
|
+
scanLimit?: number;
|
|
96
|
+
maxRecordBytes?: number;
|
|
97
|
+
budgets?: Partial<SyncBudgets>;
|
|
98
|
+
pressureMode?: "auto" | "off";
|
|
99
|
+
workerCommand?: string[];
|
|
100
|
+
maintenanceCommand?: string[];
|
|
101
|
+
/** Test/embedding seam; production uses the private leaf preparation verb. */
|
|
102
|
+
preparationCommand?: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SyncWorkerJob {
|
|
106
|
+
protocol_version: typeof SYNC_PROTOCOL_VERSION;
|
|
107
|
+
db_path: string;
|
|
108
|
+
item: DiscoveredSession;
|
|
109
|
+
max_chunk_bytes: number;
|
|
110
|
+
max_record_bytes: number;
|
|
111
|
+
max_source_bytes: number;
|
|
112
|
+
read_bytes_per_second: number;
|
|
113
|
+
max_wall_ms: number;
|
|
114
|
+
max_attempts: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface CursorState {
|
|
118
|
+
session_id: string | null;
|
|
119
|
+
bytes_ingested: number;
|
|
120
|
+
record_count: number;
|
|
121
|
+
status: string;
|
|
122
|
+
prefix_hash: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface SyncWorkerReply {
|
|
126
|
+
protocol_version: typeof SYNC_PROTOCOL_VERSION;
|
|
127
|
+
type: "sync_worker_result";
|
|
128
|
+
outcome: "ok" | "budget_exhausted" | "error";
|
|
129
|
+
results: Record<string, number>;
|
|
130
|
+
attempts: number;
|
|
131
|
+
budget?: "source_bytes" | "wall_time";
|
|
132
|
+
error_code?: "ingest_failed" | "database_busy" | "writer_busy";
|
|
133
|
+
work: IngestWork;
|
|
134
|
+
cursor_before: CursorState | null;
|
|
135
|
+
cursor_after: CursorState | null;
|
|
136
|
+
cursor_changed: boolean;
|
|
137
|
+
needs_ingest_after: boolean;
|
|
138
|
+
stalled: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A disposable child the pass stopped at a deadline: which process, under which bound. */
|
|
142
|
+
export interface WorkerStop {
|
|
143
|
+
pid: number;
|
|
144
|
+
timeout_ms: number;
|
|
145
|
+
label: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface WorkerInvocation {
|
|
149
|
+
status: "ok" | "spawn_failed" | "timed_out" | "protocol_error" | "failed";
|
|
150
|
+
reply?: SyncWorkerReply;
|
|
151
|
+
/** Present when a deadline stopped the child instead of it replying. */
|
|
152
|
+
stop?: WorkerStop;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface SyncMaintenanceJob {
|
|
156
|
+
protocol_version: typeof SYNC_PROTOCOL_VERSION;
|
|
157
|
+
db_path: string;
|
|
158
|
+
fts_merge_pages: number;
|
|
159
|
+
wal_autocheckpoint_pages: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface SyncMaintenanceReply {
|
|
163
|
+
protocol_version: typeof SYNC_PROTOCOL_VERSION;
|
|
164
|
+
type: "sync_maintenance_result";
|
|
165
|
+
fts_changes: number;
|
|
166
|
+
optimize_actions: number;
|
|
167
|
+
wal_busy: number;
|
|
168
|
+
wal_log_frames: number;
|
|
169
|
+
wal_checkpointed_frames: number;
|
|
170
|
+
elapsed_ms: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface MaintenanceInvocation {
|
|
174
|
+
status: "ok" | "spawn_failed" | "timed_out" | "protocol_error" | "failed" | "disabled" | "skipped";
|
|
175
|
+
reply?: SyncMaintenanceReply;
|
|
176
|
+
/** Present when a deadline stopped the child instead of it replying. */
|
|
177
|
+
stop?: WorkerStop;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface SyncDependencies {
|
|
181
|
+
/** Test-only in-process seam. Production discovery must use the disposable child. */
|
|
182
|
+
scan?: typeof scanSessions;
|
|
183
|
+
runWorker?: (
|
|
184
|
+
job: SyncWorkerJob,
|
|
185
|
+
timeoutMs: number,
|
|
186
|
+
command: string[],
|
|
187
|
+
signal?: AbortSignal,
|
|
188
|
+
) => Promise<WorkerInvocation>;
|
|
189
|
+
runMaintenance?: (job: SyncMaintenanceJob, timeoutMs: number, command: string[]) => Promise<MaintenanceInvocation>;
|
|
190
|
+
pressure?: () => PressureSnapshot;
|
|
191
|
+
now?: () => Date;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface SyncSummary {
|
|
195
|
+
type: "sync_summary";
|
|
196
|
+
schema_version: 2;
|
|
197
|
+
/** Completion never implies that deleted, unsupported or unobserved history is archived. */
|
|
198
|
+
completion_scope?: "known_work";
|
|
199
|
+
discovery?: DiscoverySummary;
|
|
200
|
+
preparation_ms?: number;
|
|
201
|
+
outcome: "complete" | "pending" | "pending_budget" | "pending_retry" | "pending_pressure" | "wall_budget_exceeded";
|
|
202
|
+
elapsed_ms: number;
|
|
203
|
+
timing: { scan_ms: number; queue_ms: number; workers_ms: number; maintenance_ms: number; derive_ms: number };
|
|
204
|
+
discovered: number;
|
|
205
|
+
queued_at_start: number;
|
|
206
|
+
sessions_admitted: number;
|
|
207
|
+
lanes: { fresh: number; backlog: number };
|
|
208
|
+
worker_processes: number;
|
|
209
|
+
worker_attempts: number;
|
|
210
|
+
retries_scheduled: number;
|
|
211
|
+
completed: number;
|
|
212
|
+
results: Record<string, number>;
|
|
213
|
+
work: IngestWork & { unavailable_worker_reports: number; source_bytes_reserved: number };
|
|
214
|
+
pending: { total: number; eligible: number; retrying: number };
|
|
215
|
+
budgets: SyncBudgets & {
|
|
216
|
+
exhausted: string[];
|
|
217
|
+
effective: Pick<SyncBudgets, "sessions" | "sourceBytes" | "readBytesPerSecond" | "workers" | "freshSessions">;
|
|
218
|
+
};
|
|
219
|
+
pressure: {
|
|
220
|
+
mode: "auto" | "off";
|
|
221
|
+
initial: PressureSnapshot;
|
|
222
|
+
final: PressureSnapshot;
|
|
223
|
+
state: AdaptivePressureState;
|
|
224
|
+
};
|
|
225
|
+
maintenance: MaintenanceInvocation;
|
|
226
|
+
/** Present only on a scheduled pass that yielded the store to another writer. */
|
|
227
|
+
skipped?: { reason: "writer_busy"; holder: WriterLeaseMetadata | null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function positiveInteger(value: number, name: string): number {
|
|
231
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`);
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function nonnegativeInteger(value: number, name: string): number {
|
|
236
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a nonnegative safe integer`);
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function budgets(input: Partial<SyncBudgets> = {}): SyncBudgets {
|
|
241
|
+
const sessions = positiveInteger(input.sessions ?? DEFAULT_SYNC_SESSION_BUDGET, "session budget");
|
|
242
|
+
return {
|
|
243
|
+
sessions,
|
|
244
|
+
sourceBytes: positiveInteger(input.sourceBytes ?? DEFAULT_SYNC_SOURCE_BYTES, "source byte budget"),
|
|
245
|
+
wallMs: positiveInteger(input.wallMs ?? DEFAULT_SYNC_WALL_MS, "wall budget"),
|
|
246
|
+
retries: nonnegativeInteger(input.retries ?? DEFAULT_SYNC_RETRY_BUDGET, "retry budget"),
|
|
247
|
+
attemptsPerSession: positiveInteger(input.attemptsPerSession ?? DEFAULT_SYNC_SESSION_ATTEMPTS, "per-session attempt budget"),
|
|
248
|
+
readBytesPerSecond: positiveInteger(input.readBytesPerSecond ?? DEFAULT_SYNC_READ_BYTES_PER_SECOND, "read rate"),
|
|
249
|
+
workerMs: positiveInteger(input.workerMs ?? DEFAULT_SYNC_WORKER_MS, "worker deadline"),
|
|
250
|
+
workers: positiveInteger(input.workers ?? DEFAULT_SYNC_WORKERS, "worker concurrency"),
|
|
251
|
+
freshSessions: nonnegativeInteger(input.freshSessions ?? DEFAULT_SYNC_FRESH_SESSIONS, "fresh session budget"),
|
|
252
|
+
freshWindowMs: positiveInteger(input.freshWindowMs ?? DEFAULT_SYNC_FRESH_WINDOW_MS, "fresh window"),
|
|
253
|
+
chunkBytes: positiveInteger(input.chunkBytes ?? DEFAULT_SYNC_CHUNK_BYTES, "sync chunk bytes"),
|
|
254
|
+
maintenanceMs: nonnegativeInteger(input.maintenanceMs ?? DEFAULT_SYNC_MAINTENANCE_MS, "maintenance deadline"),
|
|
255
|
+
ftsMergePages: nonnegativeInteger(input.ftsMergePages ?? DEFAULT_SYNC_FTS_MERGE_PAGES, "FTS merge pages"),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function cursorState(cursor: IngestCursor | null): CursorState | null {
|
|
260
|
+
return cursor && {
|
|
261
|
+
session_id: cursor.session_id,
|
|
262
|
+
bytes_ingested: cursor.bytes_ingested,
|
|
263
|
+
record_count: cursor.record_count,
|
|
264
|
+
status: cursor.status,
|
|
265
|
+
prefix_hash: cursor.prefix_hash ?? null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getCursor(db: TrajectoryDb, item: DiscoveredSession): IngestCursor | null {
|
|
270
|
+
return db.getIngestCursor(item.source, item.id, item.path);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function emptyWork(): IngestWork {
|
|
274
|
+
return { source_bytes_read: 0, source_bytes_hashed: 0, historical_records_hashed: 0 };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function addWork(target: IngestWork, addition: IngestWork): void {
|
|
278
|
+
target.source_bytes_read += addition.source_bytes_read;
|
|
279
|
+
target.source_bytes_hashed += addition.source_bytes_hashed;
|
|
280
|
+
target.historical_records_hashed += addition.historical_records_hashed;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function addCounts(target: Record<string, number>, addition: Record<string, number>): void {
|
|
284
|
+
for (const [key, value] of Object.entries(addition)) target[key] = (target[key] ?? 0) + value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function validateJob(value: unknown): SyncWorkerJob {
|
|
288
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_worker_job");
|
|
289
|
+
const job = value as Record<string, unknown>;
|
|
290
|
+
const item = job.item as Record<string, unknown> | undefined;
|
|
291
|
+
if (job.protocol_version !== SYNC_PROTOCOL_VERSION || typeof job.db_path !== "string" || !job.db_path ||
|
|
292
|
+
!item || typeof item.source !== "string" || !item.source || typeof item.id !== "string" || !item.id ||
|
|
293
|
+
typeof item.path !== "string" || !item.path) throw new Error("invalid_worker_job");
|
|
294
|
+
for (const key of ["max_chunk_bytes", "max_record_bytes", "max_source_bytes", "read_bytes_per_second", "max_wall_ms", "max_attempts"] as const) {
|
|
295
|
+
positiveInteger(job[key] as number, key);
|
|
296
|
+
}
|
|
297
|
+
if ((job.max_record_bytes as number) < (job.max_chunk_bytes as number)) throw new Error("invalid_worker_job");
|
|
298
|
+
return value as SyncWorkerJob;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function executeSyncWorker(
|
|
302
|
+
input: unknown,
|
|
303
|
+
options: { workerChild?: boolean } = {},
|
|
304
|
+
): Promise<SyncWorkerReply> {
|
|
305
|
+
const job = validateJob(input);
|
|
306
|
+
// The parent's lease is this child's write authority; check it before any
|
|
307
|
+
// store handle exists.
|
|
308
|
+
if (options.workerChild) requireParentLease(job.db_path);
|
|
309
|
+
const db = new TrajectoryDb(job.db_path);
|
|
310
|
+
const before = cursorState(getCursor(db, job.item));
|
|
311
|
+
const totalWork = emptyWork();
|
|
312
|
+
const results: Record<string, number> = {};
|
|
313
|
+
const started = performance.now();
|
|
314
|
+
let attempts = 0;
|
|
315
|
+
let needsIngest = true;
|
|
316
|
+
let stalled = false;
|
|
317
|
+
try {
|
|
318
|
+
while (attempts < job.max_attempts && needsIngest) {
|
|
319
|
+
const elapsed = performance.now() - started;
|
|
320
|
+
const remainingWall = Math.floor(job.max_wall_ms - elapsed);
|
|
321
|
+
const remainingBytes = job.max_source_bytes - totalWork.source_bytes_read;
|
|
322
|
+
if (remainingWall <= 0 || remainingBytes <= 0) {
|
|
323
|
+
const after = cursorState(getCursor(db, job.item));
|
|
324
|
+
return {
|
|
325
|
+
protocol_version: SYNC_PROTOCOL_VERSION, type: "sync_worker_result", outcome: "budget_exhausted",
|
|
326
|
+
budget: remainingWall <= 0 ? "wall_time" : "source_bytes", results, attempts, work: totalWork,
|
|
327
|
+
cursor_before: before, cursor_after: after,
|
|
328
|
+
cursor_changed: JSON.stringify(before) !== JSON.stringify(after),
|
|
329
|
+
needs_ingest_after: true, stalled: false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const attemptBefore = cursorState(getCursor(db, job.item));
|
|
333
|
+
attempts++;
|
|
334
|
+
const result = await ingestDiscoveredSession(db, job.item, {
|
|
335
|
+
maxChunkBytes: job.max_chunk_bytes,
|
|
336
|
+
maxRecordBytes: job.max_record_bytes,
|
|
337
|
+
maxSourceBytes: remainingBytes,
|
|
338
|
+
readBytesPerSecond: job.read_bytes_per_second,
|
|
339
|
+
maxWallMs: remainingWall,
|
|
340
|
+
});
|
|
341
|
+
addWork(totalWork, result.work ?? emptyWork());
|
|
342
|
+
results[result.kind] = (results[result.kind] ?? 0) + 1;
|
|
343
|
+
const attemptAfter = cursorState(getCursor(db, job.item));
|
|
344
|
+
needsIngest = sessionNeedsIngest(db, job.item, { maxRecordBytes: job.max_record_bytes });
|
|
345
|
+
if (needsIngest && JSON.stringify(attemptBefore) === JSON.stringify(attemptAfter)) {
|
|
346
|
+
stalled = true;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const after = cursorState(getCursor(db, job.item));
|
|
351
|
+
return {
|
|
352
|
+
protocol_version: SYNC_PROTOCOL_VERSION,
|
|
353
|
+
type: "sync_worker_result",
|
|
354
|
+
outcome: "ok",
|
|
355
|
+
results,
|
|
356
|
+
attempts,
|
|
357
|
+
work: totalWork,
|
|
358
|
+
cursor_before: before,
|
|
359
|
+
cursor_after: after,
|
|
360
|
+
cursor_changed: JSON.stringify(before) !== JSON.stringify(after),
|
|
361
|
+
needs_ingest_after: needsIngest,
|
|
362
|
+
stalled,
|
|
363
|
+
};
|
|
364
|
+
} catch (error) {
|
|
365
|
+
const after = cursorState(getCursor(db, job.item));
|
|
366
|
+
if (error instanceof IngestBudgetExceeded) {
|
|
367
|
+
addWork(totalWork, error.work);
|
|
368
|
+
return {
|
|
369
|
+
protocol_version: SYNC_PROTOCOL_VERSION, type: "sync_worker_result", outcome: "budget_exhausted",
|
|
370
|
+
budget: error.budget, results, attempts, work: totalWork, cursor_before: before, cursor_after: after,
|
|
371
|
+
cursor_changed: JSON.stringify(before) !== JSON.stringify(after), needs_ingest_after: true, stalled: false,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
protocol_version: SYNC_PROTOCOL_VERSION, type: "sync_worker_result", outcome: "error",
|
|
376
|
+
error_code: error instanceof Error && /database is (locked|busy)|SQLITE_BUSY/i.test(error.message)
|
|
377
|
+
? "database_busy" : "ingest_failed",
|
|
378
|
+
results, attempts, work: totalWork, cursor_before: before, cursor_after: after,
|
|
379
|
+
cursor_changed: JSON.stringify(before) !== JSON.stringify(after), needs_ingest_after: true, stalled: true,
|
|
380
|
+
};
|
|
381
|
+
} finally {
|
|
382
|
+
db.close();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function executeSyncWorkerStdio(options: { workerChild?: boolean } = {}): Promise<void> {
|
|
387
|
+
const raw = await readBoundedProtocol(Bun.stdin.stream());
|
|
388
|
+
if (raw.length > 64 * 1024) throw new Error("invalid_worker_job");
|
|
389
|
+
const input = JSON.parse(raw);
|
|
390
|
+
let reply: SyncWorkerReply;
|
|
391
|
+
try {
|
|
392
|
+
reply = await executeSyncWorker(input, options);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (!(error instanceof WriterLeaseBusyError)) throw error;
|
|
395
|
+
// The refusal is a normal protocol reply, so the parent defers the session
|
|
396
|
+
// durably instead of treating it as a crashed child.
|
|
397
|
+
console.error(error.message);
|
|
398
|
+
reply = {
|
|
399
|
+
protocol_version: SYNC_PROTOCOL_VERSION, type: "sync_worker_result", outcome: "error",
|
|
400
|
+
error_code: "writer_busy", results: {}, attempts: 0, work: emptyWork(),
|
|
401
|
+
cursor_before: null, cursor_after: null, cursor_changed: false,
|
|
402
|
+
needs_ingest_after: true, stalled: false,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
process.stdout.write(JSON.stringify(reply) + "\n");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function validWork(value: unknown): value is IngestWork {
|
|
409
|
+
if (!value || typeof value !== "object") return false;
|
|
410
|
+
return ["source_bytes_read", "source_bytes_hashed", "historical_records_hashed"]
|
|
411
|
+
.every((key) => Number.isSafeInteger((value as Record<string, unknown>)[key]) && Number((value as Record<string, unknown>)[key]) >= 0);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function validCounts(value: unknown): value is Record<string, number> {
|
|
415
|
+
return !!value && typeof value === "object" && !Array.isArray(value) &&
|
|
416
|
+
Object.entries(value).every(([key, count]) => key.length > 0 && Number.isSafeInteger(count) && Number(count) >= 0);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function parseWorkerReply(raw: string): SyncWorkerReply | null {
|
|
420
|
+
if (raw.length > 64 * 1024) return null;
|
|
421
|
+
try {
|
|
422
|
+
const reply = JSON.parse(raw) as SyncWorkerReply;
|
|
423
|
+
if (reply.protocol_version !== SYNC_PROTOCOL_VERSION || reply.type !== "sync_worker_result" ||
|
|
424
|
+
!["ok", "budget_exhausted", "error"].includes(reply.outcome) || !validWork(reply.work) ||
|
|
425
|
+
!validCounts(reply.results) || !Number.isSafeInteger(reply.attempts) || reply.attempts < 0 ||
|
|
426
|
+
typeof reply.cursor_changed !== "boolean" || typeof reply.needs_ingest_after !== "boolean" ||
|
|
427
|
+
typeof reply.stalled !== "boolean") return null;
|
|
428
|
+
return reply;
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Kill sequence for a disposable child: SIGTERM for a chance to exit cleanly,
|
|
436
|
+
* SIGKILL after the grace, and the answer is pending until the child is reaped.
|
|
437
|
+
* Callers race it against whatever they were waiting on, so a child that
|
|
438
|
+
* ignores SIGTERM — or a stdout pipe a descendant keeps open — cannot keep the
|
|
439
|
+
* caller's await pending. The kill is pid-scoped: a descendant holding that
|
|
440
|
+
* pipe lives on as its own process, and nothing here waits on it.
|
|
441
|
+
*/
|
|
442
|
+
/**
|
|
443
|
+
* Kill escalation is pid-scoped by design: today's workers are leaf `bun
|
|
444
|
+
* __sync-worker` processes with no descendants, and a process-group kill
|
|
445
|
+
* (setsid + kill(-pid)) would make workers survive the parent's death —
|
|
446
|
+
* trading a real orphan regression for a hypothetical descendant. If a worker
|
|
447
|
+
* ever spawns children, switch this to group semantics and pin the orphan
|
|
448
|
+
* behavior (issue #39 item 1; the recorded trigger is this comment).
|
|
449
|
+
*/
|
|
450
|
+
async function terminateChild(child: Bun.Subprocess<"pipe", "pipe", "ignore">): Promise<void> {
|
|
451
|
+
if (child.exitCode !== null) return;
|
|
452
|
+
try { child.kill("SIGTERM"); } catch { /* already exited */ }
|
|
453
|
+
await sleep(WORKER_KILL_GRACE_MS);
|
|
454
|
+
try { child.kill("SIGKILL"); } catch { /* already exited */ }
|
|
455
|
+
try { await child.exited; } catch { /* reaped elsewhere */ }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function readBoundedProtocol(stream: ReadableStream<Uint8Array>, maxBytes = 64 * 1024): Promise<string> {
|
|
459
|
+
const reader = stream.getReader();
|
|
460
|
+
const parts: Uint8Array[] = [];
|
|
461
|
+
let total = 0;
|
|
462
|
+
try {
|
|
463
|
+
for (;;) {
|
|
464
|
+
const { done, value } = await reader.read();
|
|
465
|
+
if (done) break;
|
|
466
|
+
total += value.byteLength;
|
|
467
|
+
if (total > maxBytes) throw new Error("protocol_output_too_large");
|
|
468
|
+
parts.push(value);
|
|
469
|
+
}
|
|
470
|
+
return Buffer.concat(parts).toString("utf8");
|
|
471
|
+
} finally { reader.releaseLock(); }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function spawnJsonChild<T>(
|
|
475
|
+
input: unknown,
|
|
476
|
+
timeoutMs: number,
|
|
477
|
+
command: string[],
|
|
478
|
+
parse: (raw: string) => T | null,
|
|
479
|
+
options: { label: string; env?: Record<string, string | undefined>; signal?: AbortSignal },
|
|
480
|
+
): Promise<{ status: WorkerInvocation["status"]; reply?: T; stop?: WorkerStop }> {
|
|
481
|
+
let child: Bun.Subprocess<"pipe", "pipe", "ignore">;
|
|
482
|
+
try {
|
|
483
|
+
child = Bun.spawn(command, { stdin: "pipe", stdout: "pipe", stderr: "ignore", env: options.env });
|
|
484
|
+
} catch {
|
|
485
|
+
return { status: "spawn_failed" };
|
|
486
|
+
}
|
|
487
|
+
// Both the child's own deadline and the pass's wall stop it through this one
|
|
488
|
+
// sequence, and the sequence's completion is what settles the call: the reply
|
|
489
|
+
// drain races it, so a stream that never closes loses instead of holding the
|
|
490
|
+
// await open forever.
|
|
491
|
+
const killed = Promise.withResolvers<void>();
|
|
492
|
+
let stopped = false;
|
|
493
|
+
let termination: Promise<void> | null = null;
|
|
494
|
+
const killChild = (): void => {
|
|
495
|
+
stopped = true;
|
|
496
|
+
termination ??= terminateChild(child).then(() => killed.resolve());
|
|
497
|
+
};
|
|
498
|
+
const timer = setTimeout(killChild, timeoutMs);
|
|
499
|
+
const onAbort = (): void => killChild();
|
|
500
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
501
|
+
if (options.signal?.aborted) killChild();
|
|
502
|
+
try {
|
|
503
|
+
child.stdin.write(JSON.stringify(input));
|
|
504
|
+
child.stdin.end();
|
|
505
|
+
const drain = (async () => {
|
|
506
|
+
const raw = await readBoundedProtocol(child.stdout);
|
|
507
|
+
return { raw, exitCode: await child.exited };
|
|
508
|
+
})();
|
|
509
|
+
await Promise.race([drain, killed.promise]);
|
|
510
|
+
// The drain can win the race on the SIGTERM EOF while the child is already
|
|
511
|
+
// stopped, so the flag — not the race winner — decides the status.
|
|
512
|
+
if (stopped) {
|
|
513
|
+
return {
|
|
514
|
+
status: "timed_out",
|
|
515
|
+
stop: { pid: child.pid, timeout_ms: timeoutMs, label: options.label },
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const { raw, exitCode } = await drain;
|
|
519
|
+
if (exitCode !== 0) return { status: "failed" };
|
|
520
|
+
const reply = parse(raw.trim());
|
|
521
|
+
return reply ? { status: "ok", reply } : { status: "protocol_error" };
|
|
522
|
+
} catch {
|
|
523
|
+
return stopped ? { status: "timed_out", stop: { pid: child.pid, timeout_ms: timeoutMs, label: options.label } }
|
|
524
|
+
: { status: "protocol_error" };
|
|
525
|
+
} finally {
|
|
526
|
+
clearTimeout(timer);
|
|
527
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
528
|
+
if (termination) {
|
|
529
|
+
// The stop sequence owns the reap; waiting for it keeps a dying child
|
|
530
|
+
// from outliving the call that opened it.
|
|
531
|
+
await termination;
|
|
532
|
+
} else if (child.exitCode === null) {
|
|
533
|
+
await terminateChild(child);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function spawnSyncWorker(
|
|
539
|
+
job: SyncWorkerJob,
|
|
540
|
+
timeoutMs: number,
|
|
541
|
+
command: string[],
|
|
542
|
+
signal?: AbortSignal,
|
|
543
|
+
): Promise<WorkerInvocation> {
|
|
544
|
+
// The parent holds the writer lease across the whole pass; the marker only
|
|
545
|
+
// tells the child it was spawned by one. Its authority is checked against the
|
|
546
|
+
// parent's lease (see requireParentLease), never trusted from the environment.
|
|
547
|
+
return spawnJsonChild(job, timeoutMs, command, parseWorkerReply, {
|
|
548
|
+
label: "sync_worker",
|
|
549
|
+
env: { ...process.env, LEDGER_SYNC_WORKER: "1" },
|
|
550
|
+
signal,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function validateMaintenanceJob(value: unknown): SyncMaintenanceJob {
|
|
555
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_maintenance_job");
|
|
556
|
+
const job = value as Record<string, unknown>;
|
|
557
|
+
if (job.protocol_version !== SYNC_PROTOCOL_VERSION || typeof job.db_path !== "string" || !job.db_path) {
|
|
558
|
+
throw new Error("invalid_maintenance_job");
|
|
559
|
+
}
|
|
560
|
+
nonnegativeInteger(job.fts_merge_pages as number, "fts_merge_pages");
|
|
561
|
+
positiveInteger(job.wal_autocheckpoint_pages as number, "wal_autocheckpoint_pages");
|
|
562
|
+
return value as SyncMaintenanceJob;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function executeSyncMaintenance(
|
|
566
|
+
input: unknown,
|
|
567
|
+
options: { workerChild?: boolean } = {},
|
|
568
|
+
): SyncMaintenanceReply {
|
|
569
|
+
const job = validateMaintenanceJob(input);
|
|
570
|
+
if (options.workerChild) requireParentLease(job.db_path);
|
|
571
|
+
const started = performance.now();
|
|
572
|
+
const db = new Database(job.db_path);
|
|
573
|
+
try {
|
|
574
|
+
db.run("PRAGMA busy_timeout = 15000");
|
|
575
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
576
|
+
db.run(`PRAGMA wal_autocheckpoint = ${job.wal_autocheckpoint_pages}`);
|
|
577
|
+
const ftsChanges = job.fts_merge_pages > 0
|
|
578
|
+
? db.run("INSERT INTO records_fts(records_fts, rank) VALUES ('merge', ?)", [job.fts_merge_pages]).changes
|
|
579
|
+
: 0;
|
|
580
|
+
const optimizeActions = db.query("PRAGMA optimize").all().length;
|
|
581
|
+
const checkpoint = db.query<{ busy: number; log: number; checkpointed: number }, []>(
|
|
582
|
+
"PRAGMA wal_checkpoint(PASSIVE)",
|
|
583
|
+
).get() ?? { busy: 0, log: 0, checkpointed: 0 };
|
|
584
|
+
return {
|
|
585
|
+
protocol_version: SYNC_PROTOCOL_VERSION,
|
|
586
|
+
type: "sync_maintenance_result",
|
|
587
|
+
fts_changes: ftsChanges,
|
|
588
|
+
optimize_actions: optimizeActions,
|
|
589
|
+
wal_busy: checkpoint.busy,
|
|
590
|
+
wal_log_frames: checkpoint.log,
|
|
591
|
+
wal_checkpointed_frames: checkpoint.checkpointed,
|
|
592
|
+
elapsed_ms: performance.now() - started,
|
|
593
|
+
};
|
|
594
|
+
} finally {
|
|
595
|
+
db.close();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function executeSyncMaintenanceStdio(options: { workerChild?: boolean } = {}): Promise<void> {
|
|
600
|
+
const raw = await readBoundedProtocol(Bun.stdin.stream());
|
|
601
|
+
if (raw.length > 64 * 1024) throw new Error("invalid_maintenance_job");
|
|
602
|
+
process.stdout.write(JSON.stringify(executeSyncMaintenance(JSON.parse(raw), options)) + "\n");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function parseMaintenanceReply(raw: string): SyncMaintenanceReply | null {
|
|
606
|
+
if (raw.length > 64 * 1024) return null;
|
|
607
|
+
try {
|
|
608
|
+
const reply = JSON.parse(raw) as SyncMaintenanceReply;
|
|
609
|
+
if (reply.protocol_version !== SYNC_PROTOCOL_VERSION || reply.type !== "sync_maintenance_result") return null;
|
|
610
|
+
for (const value of [reply.fts_changes, reply.optimize_actions, reply.wal_busy,
|
|
611
|
+
reply.wal_log_frames, reply.wal_checkpointed_frames, reply.elapsed_ms]) {
|
|
612
|
+
if (!Number.isFinite(value) || value < 0) return null;
|
|
613
|
+
}
|
|
614
|
+
return reply;
|
|
615
|
+
} catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export async function spawnSyncMaintenance(
|
|
621
|
+
job: SyncMaintenanceJob,
|
|
622
|
+
timeoutMs: number,
|
|
623
|
+
command: string[],
|
|
624
|
+
): Promise<MaintenanceInvocation> {
|
|
625
|
+
return spawnJsonChild(job, timeoutMs, command, parseMaintenanceReply, { label: "sync_maintenance" });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function retryAt(now: Date, attempts: number): string {
|
|
629
|
+
const delayMs = Math.min(6 * 60 * 60 * 1000, 15 * 60 * 1000 * 2 ** Math.min(attempts, 5));
|
|
630
|
+
return new Date(now.getTime() + delayMs).toISOString();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function workerEntry(): string[] {
|
|
634
|
+
return [process.execPath, fileURLToPath(new URL("../bin/ledger-core.ts", import.meta.url)), "__sync-worker"];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function maintenanceEntry(): string[] {
|
|
638
|
+
return [process.execPath, fileURLToPath(new URL("../bin/ledger-core.ts", import.meta.url)), "__sync-maintenance"];
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function workerReservation(
|
|
642
|
+
item: IngestQueueEntry,
|
|
643
|
+
limits: Pick<SyncBudgets, "attemptsPerSession" | "chunkBytes">,
|
|
644
|
+
remaining: number,
|
|
645
|
+
): number {
|
|
646
|
+
// Directory-backed native sessions span metadata plus companion files;
|
|
647
|
+
// discovery only sizes the metadata file. Reserve the assembled footprint.
|
|
648
|
+
const sourceBytes = item.native?.kind === "opencode-storage" || item.native?.kind === "fx-directory"
|
|
649
|
+
? sourceFingerprint(item)?.size ?? item.sizeBytes
|
|
650
|
+
: item.sizeBytes;
|
|
651
|
+
const desired = isIncrementalJsonl(item)
|
|
652
|
+
? limits.attemptsPerSession * (limits.chunkBytes + APPEND_TAIL_BYTES)
|
|
653
|
+
: Math.max(1, sourceBytes ?? limits.chunkBytes);
|
|
654
|
+
return Math.max(1, Math.min(desired, remaining));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function pressureOffSnapshot(): PressureSnapshot {
|
|
658
|
+
return { platform: process.platform, severity: "unavailable", cpu: null, memory: null, io: null,
|
|
659
|
+
cgroup_memory_ratio: null, cgroup_cpu_cores: null, reasons: ["pressure_mode_off"] };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function effectiveLimits(limits: SyncBudgets, state: AdaptivePressureState, snapshot: PressureSnapshot) {
|
|
663
|
+
const sessions = scaledLimit(limits.sessions, state.scale_permille);
|
|
664
|
+
const quotaWorkers = snapshot.cgroup_cpu_cores === null
|
|
665
|
+
? limits.workers
|
|
666
|
+
: Math.max(1, Math.floor(snapshot.cgroup_cpu_cores));
|
|
667
|
+
return {
|
|
668
|
+
sessions,
|
|
669
|
+
sourceBytes: scaledLimit(limits.sourceBytes, state.scale_permille),
|
|
670
|
+
readBytesPerSecond: scaledLimit(limits.readBytesPerSecond, state.scale_permille),
|
|
671
|
+
workers: Math.min(sessions, quotaWorkers, scaledLimit(limits.workers, state.scale_permille)),
|
|
672
|
+
freshSessions: limits.freshSessions === 0 ? 0 : Math.min(sessions, scaledLimit(limits.freshSessions, state.scale_permille)),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Result of the sync lease policy: this pass owns the store, or it does not run. */
|
|
677
|
+
type SyncLeaseOutcome =
|
|
678
|
+
| { status: "held"; lease: WriterLease }
|
|
679
|
+
| { status: "exempt" }
|
|
680
|
+
| { status: "skipped"; holder: WriterLeaseMetadata | null };
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Lease policy for the sync entry point. A scheduled pass yields the store to a
|
|
684
|
+
* foreground writer instead of queueing behind it, and a foreground verb waits
|
|
685
|
+
* briefly before refusing with a typed error. Nothing here consults the
|
|
686
|
+
* environment: the worker-child marker is a transport hint only, and a child
|
|
687
|
+
* proves its authority by naming its parent (see requireParentLease).
|
|
688
|
+
*/
|
|
689
|
+
function acquireSyncLease(dbPath: string): SyncLeaseOutcome {
|
|
690
|
+
if (isMemoryStorePath(dbPath)) return { status: "exempt" };
|
|
691
|
+
const scheduled = process.env.LEDGER_SCHEDULER === "1";
|
|
692
|
+
try {
|
|
693
|
+
return {
|
|
694
|
+
status: "held",
|
|
695
|
+
lease: acquireWriterLease(dbPath, {
|
|
696
|
+
waitMs: scheduled ? 0 : DEFAULT_WRITER_LEASE_WAIT_MS,
|
|
697
|
+
holder: scheduled ? "scheduler" : "sync",
|
|
698
|
+
}),
|
|
699
|
+
};
|
|
700
|
+
} catch (error) {
|
|
701
|
+
if (!scheduled || !(error instanceof WriterLeaseBusyError)) throw error;
|
|
702
|
+
console.error(`[ledger] sync skipped: another writer holds the store (${describeLeaseHolder(error.holder)})`);
|
|
703
|
+
return { status: "skipped", holder: error.holder };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* A disposable worker child writes on its parent's authority, and that authority
|
|
709
|
+
* is the parent's lease. The check is attribution and invocation binding — it
|
|
710
|
+
* decides whether this invocation may write and which process is to be named —
|
|
711
|
+
* NOT mutual exclusion: exclusion rests on the store's own SQLite write lock
|
|
712
|
+
* plus the parent's lease held across the whole pass. Trusting a transport marker
|
|
713
|
+
* instead would let an ambient environment variable, or a hand-invoked private
|
|
714
|
+
* verb, write to a store its caller does not own.
|
|
715
|
+
*/
|
|
716
|
+
function requireParentLease(dbPath: string): void {
|
|
717
|
+
const holder = readWriterLease(dbPath);
|
|
718
|
+
if (holder?.pid === process.ppid) return;
|
|
719
|
+
throw new WriterLeaseBusyError(
|
|
720
|
+
writerLockPath(dbPath),
|
|
721
|
+
holder,
|
|
722
|
+
`worker authority check failed: parent does not hold the lease (${describeLeaseHolder(holder)})`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Zero-work summary for a scheduled pass that yielded the store. It reports no
|
|
728
|
+
* work instead of a fabricated ingestion, and names the holder that owns the
|
|
729
|
+
* store until it finishes.
|
|
730
|
+
*/
|
|
731
|
+
function skippedSyncSummary(
|
|
732
|
+
limits: SyncBudgets,
|
|
733
|
+
started: number,
|
|
734
|
+
pressureMode: "auto" | "off",
|
|
735
|
+
readPressure: () => PressureSnapshot,
|
|
736
|
+
holder: WriterLeaseMetadata | null,
|
|
737
|
+
): SyncSummary {
|
|
738
|
+
const pressure = pressureMode === "auto" ? readPressure() : pressureOffSnapshot();
|
|
739
|
+
return {
|
|
740
|
+
type: "sync_summary",
|
|
741
|
+
schema_version: 2,
|
|
742
|
+
outcome: "complete",
|
|
743
|
+
elapsed_ms: performance.now() - started,
|
|
744
|
+
timing: { scan_ms: 0, queue_ms: 0, workers_ms: 0, maintenance_ms: 0, derive_ms: 0 },
|
|
745
|
+
discovered: 0,
|
|
746
|
+
queued_at_start: 0,
|
|
747
|
+
sessions_admitted: 0,
|
|
748
|
+
lanes: { fresh: 0, backlog: 0 },
|
|
749
|
+
worker_processes: 0,
|
|
750
|
+
worker_attempts: 0,
|
|
751
|
+
retries_scheduled: 0,
|
|
752
|
+
completed: 0,
|
|
753
|
+
results: {},
|
|
754
|
+
work: { ...emptyWork(), unavailable_worker_reports: 0, source_bytes_reserved: 0 },
|
|
755
|
+
pending: { total: 0, eligible: 0, retrying: 0 },
|
|
756
|
+
budgets: { ...limits, exhausted: [], effective: effectiveLimits(limits, DEFAULT_PRESSURE_STATE, pressure) },
|
|
757
|
+
pressure: { mode: pressureMode, initial: pressure, final: pressure, state: { ...DEFAULT_PRESSURE_STATE } },
|
|
758
|
+
maintenance: limits.maintenanceMs === 0 ? { status: "disabled" } : { status: "skipped" },
|
|
759
|
+
skipped: { reason: "writer_busy", holder },
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** One worker wave: each child's report, or `null` for a child the wall stopped. */
|
|
764
|
+
interface WaveWait {
|
|
765
|
+
expired: boolean;
|
|
766
|
+
invocations: Array<WorkerInvocation | null>;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Wait for one worker wave, but never past the pass's own wall deadline. The
|
|
771
|
+
* children are disposable and ours, so expiry aborts them through the same kill
|
|
772
|
+
* sequence their own deadlines use — a pid-scoped kill — and waits for that
|
|
773
|
+
* sequence to finish reaping them: the lease must not be released underneath a
|
|
774
|
+
* dying writer. A child that never closes its stdout cannot keep this await
|
|
775
|
+
* pending, because the abort settles it, and a sibling that already reported
|
|
776
|
+
* keeps its real result: only the children still pending at the clock are
|
|
777
|
+
* wall-stopped.
|
|
778
|
+
*/
|
|
779
|
+
async function awaitWorkerWave(
|
|
780
|
+
jobs: SyncWorkerJob[],
|
|
781
|
+
wallRemainingMs: number,
|
|
782
|
+
run: (job: SyncWorkerJob, signal: AbortSignal) => Promise<WorkerInvocation>,
|
|
783
|
+
): Promise<WaveWait> {
|
|
784
|
+
const abort = new AbortController();
|
|
785
|
+
const invocations: Array<WorkerInvocation | null> = jobs.map(() => null);
|
|
786
|
+
const wave = Promise.all(jobs.map((job, index) => run(job, abort.signal).then((invocation) => {
|
|
787
|
+
invocations[index] = invocation;
|
|
788
|
+
return invocation;
|
|
789
|
+
})));
|
|
790
|
+
let expired = false;
|
|
791
|
+
// The clock is cleared either way: a pass that finishes early must not keep a
|
|
792
|
+
// pending timer alive for the rest of its wall budget.
|
|
793
|
+
const stopped = Promise.withResolvers<void>();
|
|
794
|
+
const clock = setTimeout(() => { expired = true; stopped.resolve(); }, wallRemainingMs);
|
|
795
|
+
void wave.then(() => stopped.resolve(), () => stopped.resolve());
|
|
796
|
+
await stopped.promise;
|
|
797
|
+
clearTimeout(clock);
|
|
798
|
+
// A rejected runner still fails the pass, exactly as it did without the race.
|
|
799
|
+
if (!expired) { await wave; return { expired: false, invocations }; }
|
|
800
|
+
// Snapshot before the abort: what has reported by now is a report, and what
|
|
801
|
+
// the abort is about to stop must not be mistaken for one.
|
|
802
|
+
const reported = [...invocations];
|
|
803
|
+
abort.abort();
|
|
804
|
+
const reaped = Promise.withResolvers<void>();
|
|
805
|
+
const reap = setTimeout(reaped.resolve, readWaveReapMs());
|
|
806
|
+
void wave.then(() => reaped.resolve(), () => reaped.resolve());
|
|
807
|
+
await reaped.promise;
|
|
808
|
+
clearTimeout(reap);
|
|
809
|
+
return { expired: true, invocations: reported };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export class SyncPreparationError extends Error {
|
|
813
|
+
readonly code: string;
|
|
814
|
+
constructor(status: string) {
|
|
815
|
+
super(`Sync preparation ${status}; initialization/discovery did not complete. The committed store and discovery checkpoint remain authoritative; retry a bounded sync.`);
|
|
816
|
+
this.name = "SyncPreparationError";
|
|
817
|
+
this.code = status === "timed_out" ? "SYNC_WALL_TIMEOUT" : "SYNC_PREPARATION_FAILED";
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function validatePreparationJob(input: unknown): SyncPreparationJob {
|
|
822
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("invalid_preparation_job");
|
|
823
|
+
const job = input as SyncPreparationJob;
|
|
824
|
+
if (job.protocol_version !== SYNC_PROTOCOL_VERSION || typeof job.db_path !== "string" || !job.db_path ||
|
|
825
|
+
(job.sources !== undefined && (!Array.isArray(job.sources) || job.sources.some(source => typeof source !== "string"))) ||
|
|
826
|
+
(job.roots !== undefined && (!job.roots || typeof job.roots !== "object" || Array.isArray(job.roots) || Object.values(job.roots).some(root => typeof root !== "string")))) {
|
|
827
|
+
throw new Error("invalid_preparation_job");
|
|
828
|
+
}
|
|
829
|
+
positiveInteger(job.scan_limit, "scan limit");
|
|
830
|
+
positiveInteger(job.max_record_bytes, "max record bytes");
|
|
831
|
+
return job;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export async function executeSyncPreparationStdio(): Promise<void> {
|
|
835
|
+
const job = validatePreparationJob(JSON.parse(await readBoundedProtocol(Bun.stdin.stream())));
|
|
836
|
+
requireParentLease(job.db_path);
|
|
837
|
+
try {
|
|
838
|
+
const result = await prepareSyncStore(job);
|
|
839
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
840
|
+
} catch (error) {
|
|
841
|
+
// Preserve typed schema/operator failures without allowing arbitrary stdout
|
|
842
|
+
// to masquerade as a successful phase. No source bodies in protocol errors.
|
|
843
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
844
|
+
process.stdout.write(JSON.stringify({ protocol_version: SYNC_PROTOCOL_VERSION,
|
|
845
|
+
type: "sync_preparation_error", code: message.includes("schema version") ? "SCHEMA_MISMATCH" : "SYNC_PREPARATION_FAILED",
|
|
846
|
+
message: message.slice(0, 2048) }) + "\n");
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
interface PreparationFailure { protocol_version: 2; type: "sync_preparation_error"; code: string; message: string }
|
|
851
|
+
function parsePreparationReply(raw: string): SyncPreparationReply | PreparationFailure | null {
|
|
852
|
+
try {
|
|
853
|
+
const reply = JSON.parse(raw);
|
|
854
|
+
if (reply.protocol_version !== SYNC_PROTOCOL_VERSION) return null;
|
|
855
|
+
if (reply.type === "sync_preparation_error" && typeof reply.code === "string" && typeof reply.message === "string") return reply;
|
|
856
|
+
if (reply.type !== "sync_preparation_result" || !Number.isSafeInteger(reply.discovered) || reply.discovered < 0 ||
|
|
857
|
+
![reply.init_ms, reply.scan_ms, reply.queue_ms].every(value => Number.isFinite(value) && value >= 0)) return null;
|
|
858
|
+
return reply;
|
|
859
|
+
} catch { return null; }
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function prepareInChild(options: SyncOptions, scanLimit: number, maxRecordBytes: number, remaining: number): Promise<SyncPreparationReply> {
|
|
863
|
+
if (remaining <= 0) throw new SyncPreparationError("timed_out");
|
|
864
|
+
const result = await spawnJsonChild<SyncPreparationReply | PreparationFailure>({ protocol_version: SYNC_PROTOCOL_VERSION,
|
|
865
|
+
db_path: options.dbPath, sources: options.sources, roots: options.roots, scan_limit: scanLimit, max_record_bytes: maxRecordBytes },
|
|
866
|
+
remaining, options.preparationCommand ?? [process.execPath, fileURLToPath(new URL("../bin/ledger-core.ts", import.meta.url)), "__sync-prepare"],
|
|
867
|
+
parsePreparationReply, { label: "sync_preparation" });
|
|
868
|
+
if (result.status !== "ok" || !result.reply) throw new SyncPreparationError(result.status);
|
|
869
|
+
if (result.reply.type === "sync_preparation_error") {
|
|
870
|
+
const error = new SyncPreparationError("failed");
|
|
871
|
+
Object.assign(error, { code: result.reply.code, message: result.reply.message });
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
return result.reply;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export async function runSync(options: SyncOptions, dependencies: SyncDependencies = {}): Promise<SyncSummary> {
|
|
878
|
+
if (!options.dbPath) throw new Error("dbPath is required");
|
|
879
|
+
const limits = budgets(options.budgets);
|
|
880
|
+
if (limits.freshSessions > limits.sessions) throw new Error("fresh session budget cannot exceed session budget");
|
|
881
|
+
const maxRecordBytes = positiveInteger(options.maxRecordBytes ?? DEFAULT_INGEST_RECORD_BYTES, "max record bytes");
|
|
882
|
+
if (limits.chunkBytes > maxRecordBytes) throw new Error("sync chunk bytes cannot exceed max record bytes");
|
|
883
|
+
const scanLimit = positiveInteger(options.scanLimit ?? 1000, "scan limit");
|
|
884
|
+
const scan = dependencies.scan ?? scanSessions;
|
|
885
|
+
const runWorker = dependencies.runWorker ?? spawnSyncWorker;
|
|
886
|
+
const runMaintenance = dependencies.runMaintenance ?? spawnSyncMaintenance;
|
|
887
|
+
const readPressure = dependencies.pressure ?? samplePressure;
|
|
888
|
+
const now = dependencies.now ?? (() => new Date());
|
|
889
|
+
const pressureMode = options.pressureMode ?? "auto";
|
|
890
|
+
const started = performance.now();
|
|
891
|
+
const workDeadline = Math.max(1, limits.wallMs - limits.maintenanceMs);
|
|
892
|
+
// The lease attaches before the store handle: a refused or skipped pass must
|
|
893
|
+
// leave the database exactly as it found it.
|
|
894
|
+
const lease = acquireSyncLease(options.dbPath);
|
|
895
|
+
if (lease.status === "skipped") {
|
|
896
|
+
return skippedSyncSummary(limits, started, pressureMode, readPressure, lease.holder);
|
|
897
|
+
}
|
|
898
|
+
const held = lease.status === "held" ? lease.lease : null;
|
|
899
|
+
try {
|
|
900
|
+
const exhausted = new Set<string>();
|
|
901
|
+
// The pass's own deadline is the one exhaustion the pass owns, and it gets its
|
|
902
|
+
// own outcome instead of hiding behind the per-worker and per-budget keys that
|
|
903
|
+
// share `exhausted` (`wall_time` is also a worker budget boundary).
|
|
904
|
+
let wallBudgetExceeded = false;
|
|
905
|
+
const exhaustWallBudget = (): void => { exhausted.add("wall_time"); wallBudgetExceeded = true; };
|
|
906
|
+
const work = { ...emptyWork(), unavailable_worker_reports: 0, source_bytes_reserved: 0 };
|
|
907
|
+
const results: Record<string, number> = {};
|
|
908
|
+
let workerProcesses = 0;
|
|
909
|
+
let workerAttempts = 0;
|
|
910
|
+
let retriesScheduled = 0;
|
|
911
|
+
let completed = 0;
|
|
912
|
+
let maintenance: MaintenanceInvocation = limits.maintenanceMs === 0 ? { status: "disabled" } : { status: "skipped" };
|
|
913
|
+
let scanMs = 0;
|
|
914
|
+
let queueMs = 0;
|
|
915
|
+
let workersMs = 0;
|
|
916
|
+
let maintenanceMs = 0;
|
|
917
|
+
let deriveMs = 0;
|
|
918
|
+
const initialPressure = pressureMode === "auto" ? readPressure() : pressureOffSnapshot();
|
|
919
|
+
const preparationStarted = performance.now();
|
|
920
|
+
const prepared = !dependencies.scan && !isMemoryStorePath(options.dbPath)
|
|
921
|
+
? await prepareInChild(options, scanLimit, maxRecordBytes, Math.floor(workDeadline - (performance.now() - started)))
|
|
922
|
+
: null;
|
|
923
|
+
const preparationMs = performance.now() - preparationStarted;
|
|
924
|
+
const db = new TrajectoryDb(options.dbPath, { initialize: prepared === null });
|
|
925
|
+
let dbClosed = false;
|
|
926
|
+
try {
|
|
927
|
+
let finalPressure = initialPressure;
|
|
928
|
+
let pressureState = pressureMode === "auto" ? db.getAdaptivePressureState() : { ...DEFAULT_PRESSURE_STATE };
|
|
929
|
+
let pressureReducedThisRun = false;
|
|
930
|
+
if (pressureMode === "auto") {
|
|
931
|
+
const decision = adaptPressure(pressureState, initialPressure);
|
|
932
|
+
pressureState = decision.state;
|
|
933
|
+
pressureReducedThisRun = initialPressure.severity === "constrained" || initialPressure.severity === "critical";
|
|
934
|
+
db.setAdaptivePressureState(pressureState, now().toISOString());
|
|
935
|
+
if (decision.pause) exhausted.add("pressure");
|
|
936
|
+
}
|
|
937
|
+
let effective = effectiveLimits(limits, pressureState, initialPressure);
|
|
938
|
+
// The derive-rule counter's one-shot runs here, under the writer lease,
|
|
939
|
+
// before in-process admission and before workers on the prepared path.
|
|
940
|
+
// It is skipped under critical pressure exactly like the maintenance
|
|
941
|
+
// child — a rebuild is the kind of heavy one-shot this pass defers, and
|
|
942
|
+
// the flag is durable, so the next pass picks it up. A failure
|
|
943
|
+
// propagates: the store could not re-project its own canonical records,
|
|
944
|
+
// which is a broken store, not a pass that ran out of budget.
|
|
945
|
+
if (!exhausted.has("pressure")) {
|
|
946
|
+
const deriveStarted = performance.now();
|
|
947
|
+
if (db.reindexFts()) deriveMs = performance.now() - deriveStarted;
|
|
948
|
+
}
|
|
949
|
+
let discoveredCount = prepared?.discovered ?? 0;
|
|
950
|
+
scanMs = prepared?.scan_ms ?? 0;
|
|
951
|
+
queueMs = prepared?.queue_ms ?? 0;
|
|
952
|
+
if (!prepared) {
|
|
953
|
+
// Injectable scans are a deterministic test seam; file-backed production
|
|
954
|
+
// never enters this in-process branch. The default memory store is local.
|
|
955
|
+
const scanStarted = performance.now();
|
|
956
|
+
let clock: ReturnType<typeof setTimeout> | undefined;
|
|
957
|
+
const remaining = Math.max(1, workDeadline - (performance.now() - started));
|
|
958
|
+
try {
|
|
959
|
+
const result = await Promise.race([
|
|
960
|
+
scan({ sources: options.sources, roots: options.roots, limit: scanLimit, discovery: db.getDiscoveryCheckpoints() }),
|
|
961
|
+
new Promise<never>((_, reject) => { clock = setTimeout(() => reject(new SyncPreparationError("timed_out")), remaining); }),
|
|
962
|
+
]);
|
|
963
|
+
discoveredCount = result.sessions.length;
|
|
964
|
+
scanMs = performance.now() - scanStarted;
|
|
965
|
+
const admissionStarted = performance.now();
|
|
966
|
+
db.admitDiscovery(result, item => sessionNeedsIngest(db, item, { maxRecordBytes }), now());
|
|
967
|
+
queueMs = performance.now() - admissionStarted;
|
|
968
|
+
} finally { clearTimeout(clock); }
|
|
969
|
+
}
|
|
970
|
+
const queueStarted = performance.now();
|
|
971
|
+
if (performance.now() - started >= workDeadline) exhaustWallBudget();
|
|
972
|
+
|
|
973
|
+
const queuedAtStart = db.getIngestQueueSummary(now().toISOString()).total;
|
|
974
|
+
const cutoff = new Date(now().getTime() - limits.freshWindowMs).toISOString();
|
|
975
|
+
const selected = exhausted.size ? { items: [], fresh: 0, backlog: 0 }
|
|
976
|
+
: db.getQueuedIngestLanes(effective.sessions, effective.freshSessions, cutoff, now().toISOString());
|
|
977
|
+
queueMs += performance.now() - queueStarted;
|
|
978
|
+
const readyAtStart = db.getIngestQueueSummary(now().toISOString()).eligible;
|
|
979
|
+
// An empty selection because an earlier boundary (wall, pressure) stopped the
|
|
980
|
+
// pass before admission is not the session budget's doing: with no lane
|
|
981
|
+
// admitted there is no admission to call limited.
|
|
982
|
+
const sessionBudgetLimited = selected.items.length > 0 && readyAtStart > selected.items.length;
|
|
983
|
+
const queue = [...selected.items];
|
|
984
|
+
let sessionAttemptsLimited = false;
|
|
985
|
+
let budgetCharge = 0;
|
|
986
|
+
let failedAttempts = 0;
|
|
987
|
+
|
|
988
|
+
while (queue.length > 0 && exhausted.size === 0) {
|
|
989
|
+
const elapsed = performance.now() - started;
|
|
990
|
+
if (elapsed >= workDeadline) { exhaustWallBudget(); break; }
|
|
991
|
+
if (workerProcesses >= effective.sessions) { exhausted.add("sessions"); break; }
|
|
992
|
+
if (budgetCharge >= effective.sourceBytes) { exhausted.add("source_bytes"); break; }
|
|
993
|
+
if (failedAttempts >= Math.max(1, limits.retries)) { exhausted.add("retries"); break; }
|
|
994
|
+
|
|
995
|
+
if (pressureMode === "auto") {
|
|
996
|
+
const sampled = readPressure();
|
|
997
|
+
finalPressure = sampled;
|
|
998
|
+
if (sampled.severity === "critical") {
|
|
999
|
+
if (pressureState.last_severity !== "critical") pressureState = adaptPressure(pressureState, sampled).state;
|
|
1000
|
+
pressureReducedThisRun = true;
|
|
1001
|
+
db.setAdaptivePressureState(pressureState, now().toISOString());
|
|
1002
|
+
exhausted.add("pressure");
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
if (sampled.severity === "constrained" && !pressureReducedThisRun) {
|
|
1006
|
+
pressureState = adaptPressure(pressureState, sampled).state;
|
|
1007
|
+
pressureReducedThisRun = true;
|
|
1008
|
+
db.setAdaptivePressureState(pressureState, now().toISOString());
|
|
1009
|
+
effective = effectiveLimits(limits, pressureState, sampled);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const remainingFailureSlots = Math.max(1, limits.retries) - failedAttempts;
|
|
1014
|
+
const maxWave = Math.min(effective.workers, remainingFailureSlots, queue.length, effective.sessions - workerProcesses);
|
|
1015
|
+
const wave: Array<{ item: IngestQueueEntry; reservation: number; job: SyncWorkerJob }> = [];
|
|
1016
|
+
let available = effective.sourceBytes - budgetCharge;
|
|
1017
|
+
while (wave.length < maxWave && queue.length > 0 && available > 0) {
|
|
1018
|
+
const item = queue[0];
|
|
1019
|
+
const remainingSlots = maxWave - wave.length;
|
|
1020
|
+
const fairShare = Math.max(1, Math.floor(available / remainingSlots));
|
|
1021
|
+
const desired = workerReservation(item, limits, fairShare);
|
|
1022
|
+
queue.shift();
|
|
1023
|
+
const reservation = desired;
|
|
1024
|
+
available -= reservation;
|
|
1025
|
+
const remainingWall = Math.max(1, Math.floor(workDeadline - elapsed));
|
|
1026
|
+
wave.push({
|
|
1027
|
+
item,
|
|
1028
|
+
reservation,
|
|
1029
|
+
job: {
|
|
1030
|
+
protocol_version: SYNC_PROTOCOL_VERSION,
|
|
1031
|
+
db_path: options.dbPath,
|
|
1032
|
+
item,
|
|
1033
|
+
max_chunk_bytes: Math.min(limits.chunkBytes, maxRecordBytes),
|
|
1034
|
+
max_record_bytes: maxRecordBytes,
|
|
1035
|
+
max_source_bytes: reservation,
|
|
1036
|
+
read_bytes_per_second: Math.max(1, Math.floor(effective.readBytesPerSecond / maxWave)),
|
|
1037
|
+
max_wall_ms: Math.min(limits.workerMs, remainingWall),
|
|
1038
|
+
max_attempts: limits.attemptsPerSession,
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
if (wave.length === 0) { exhausted.add("source_bytes"); break; }
|
|
1043
|
+
workerProcesses += wave.length;
|
|
1044
|
+
work.source_bytes_reserved += wave.reduce((sum, entry) => sum + entry.reservation, 0);
|
|
1045
|
+
const workersStarted = performance.now();
|
|
1046
|
+
const workerCommand = options.workerCommand ?? workerEntry();
|
|
1047
|
+
// The pass owns the deadline: the wave is waited for at most the wall that
|
|
1048
|
+
// is actually left, whatever the runners do with their own budgets.
|
|
1049
|
+
const wallRemaining = Math.max(0, Math.ceil(workDeadline - (performance.now() - started)));
|
|
1050
|
+
const waited = await awaitWorkerWave(wave.map(entry => entry.job), wallRemaining,
|
|
1051
|
+
(job, signal) => runWorker(job, job.max_wall_ms, workerCommand, signal));
|
|
1052
|
+
workersMs += performance.now() - workersStarted;
|
|
1053
|
+
// The deadline is the pass's boundary, not the session's: nothing is
|
|
1054
|
+
// failed or backed off for it, and a sibling that already reported keeps
|
|
1055
|
+
// its real result below.
|
|
1056
|
+
if (waited.expired) exhaustWallBudget();
|
|
1057
|
+
|
|
1058
|
+
for (let index = 0; index < wave.length; index++) {
|
|
1059
|
+
const { item, reservation, job } = wave[index];
|
|
1060
|
+
const reported = waited.invocations[index];
|
|
1061
|
+
if (reported === null) {
|
|
1062
|
+
// Stopped at the wall with its reads unknown: keep the reservation
|
|
1063
|
+
// charged and the session durably queued for the next pass.
|
|
1064
|
+
budgetCharge += reservation;
|
|
1065
|
+
workerAttempts++;
|
|
1066
|
+
work.unavailable_worker_reports++;
|
|
1067
|
+
results.timed_out = (results.timed_out ?? 0) + 1;
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
let invocation = reported;
|
|
1071
|
+
const reply = invocation.reply;
|
|
1072
|
+
if (reply && (reply.work.source_bytes_read > reservation || reply.attempts > job.max_attempts)) {
|
|
1073
|
+
invocation = { status: "protocol_error" };
|
|
1074
|
+
}
|
|
1075
|
+
const acceptedReply = invocation.status === "ok" ? invocation.reply : undefined;
|
|
1076
|
+
if (acceptedReply) {
|
|
1077
|
+
budgetCharge += acceptedReply.work.source_bytes_read;
|
|
1078
|
+
addWork(work, acceptedReply.work);
|
|
1079
|
+
workerAttempts += acceptedReply.attempts;
|
|
1080
|
+
addCounts(results, acceptedReply.results);
|
|
1081
|
+
} else {
|
|
1082
|
+
budgetCharge += reservation;
|
|
1083
|
+
workerAttempts++;
|
|
1084
|
+
work.unavailable_worker_reports++;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (invocation.status !== "ok" || !acceptedReply || acceptedReply.outcome === "error") {
|
|
1088
|
+
failedAttempts++;
|
|
1089
|
+
retriesScheduled++;
|
|
1090
|
+
const stamp = now();
|
|
1091
|
+
const status = invocation.status === "ok" ? acceptedReply?.error_code ?? "worker_error" : invocation.status;
|
|
1092
|
+
db.markQueuedIngestRetry(item, status, retryAt(stamp, item.attempt_count), stamp.toISOString());
|
|
1093
|
+
results[status] = (results[status] ?? 0) + 1;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (acceptedReply.outcome === "budget_exhausted") {
|
|
1097
|
+
const workerBudget = acceptedReply.budget ?? "worker_budget";
|
|
1098
|
+
if (workerBudget === "source_bytes") {
|
|
1099
|
+
budgetCharge += reservation - acceptedReply.work.source_bytes_read;
|
|
1100
|
+
}
|
|
1101
|
+
// A worker whose wall budget was the pass's remaining wall reports the
|
|
1102
|
+
// pass's own boundary: it spent what was left, or could not finish
|
|
1103
|
+
// inside it. A worker budget under the wall stays the worker's own.
|
|
1104
|
+
if (workerBudget === "wall_time" && job.max_wall_ms < limits.workerMs) exhaustWallBudget();
|
|
1105
|
+
else if (workerBudget !== "source_bytes" || budgetCharge >= effective.sourceBytes) exhausted.add(workerBudget);
|
|
1106
|
+
db.markQueuedIngestProgress(item, `pending_${acceptedReply.budget ?? "worker_budget"}`, now().toISOString());
|
|
1107
|
+
results.budget_exhausted = (results.budget_exhausted ?? 0) + 1;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (!acceptedReply.needs_ingest_after || acceptedReply.results.tombstoned) {
|
|
1111
|
+
// The planner re-checks `sessionNeedsIngest`, but a listing the store
|
|
1112
|
+
// itself refused — the committed id of a grouped source's member file —
|
|
1113
|
+
// still looks ingestable to it, because the group mapping is only
|
|
1114
|
+
// visible after the read. Its answer is final: the session was deleted
|
|
1115
|
+
// by the user, so the item leaves the queue instead of returning as a
|
|
1116
|
+
// retry every pass. A later scan enqueues it again if the deletion is
|
|
1117
|
+
// ever undone, since discovery is what fills the queue.
|
|
1118
|
+
db.removeQueuedIngest(item);
|
|
1119
|
+
completed++;
|
|
1120
|
+
} else if (acceptedReply.stalled || !acceptedReply.cursor_changed) {
|
|
1121
|
+
failedAttempts++;
|
|
1122
|
+
retriesScheduled++;
|
|
1123
|
+
const stamp = now();
|
|
1124
|
+
db.markQueuedIngestRetry(item, "no_progress", retryAt(stamp, item.attempt_count), stamp.toISOString());
|
|
1125
|
+
results.no_progress = (results.no_progress ?? 0) + 1;
|
|
1126
|
+
} else {
|
|
1127
|
+
sessionAttemptsLimited ||= acceptedReply.attempts >= limits.attemptsPerSession;
|
|
1128
|
+
db.markQueuedIngestProgress(item, "progress", now().toISOString());
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// The wall is spent: the admissions stop here and the queue keeps what
|
|
1132
|
+
// this pass did not get to.
|
|
1133
|
+
if (waited.expired) break;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const pending = db.getIngestQueueSummary(now().toISOString());
|
|
1137
|
+
const discovery = db.getDiscoverySummary();
|
|
1138
|
+
if (discovery?.pending_sources) exhausted.add("discovery");
|
|
1139
|
+
if (pending.total > 0 && sessionBudgetLimited) exhausted.add("sessions");
|
|
1140
|
+
if (pending.total > 0 && sessionAttemptsLimited) exhausted.add("session_attempts");
|
|
1141
|
+
if (pressureMode === "auto") {
|
|
1142
|
+
finalPressure = readPressure();
|
|
1143
|
+
if (finalPressure.severity === "critical") exhausted.add("pressure");
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const maintenanceRemaining = Math.floor(limits.wallMs - (performance.now() - started));
|
|
1147
|
+
if (limits.maintenanceMs > 0 && work.source_bytes_read === 0 &&
|
|
1148
|
+
!exhausted.has("pressure") && maintenanceRemaining > 0) {
|
|
1149
|
+
const timeout = Math.min(limits.maintenanceMs, maintenanceRemaining);
|
|
1150
|
+
const maintenanceStarted = performance.now();
|
|
1151
|
+
db.close();
|
|
1152
|
+
dbClosed = true;
|
|
1153
|
+
maintenance = await runMaintenance({
|
|
1154
|
+
protocol_version: SYNC_PROTOCOL_VERSION,
|
|
1155
|
+
db_path: options.dbPath,
|
|
1156
|
+
fts_merge_pages: limits.ftsMergePages,
|
|
1157
|
+
wal_autocheckpoint_pages: DEFAULT_WAL_AUTOCHECKPOINT_PAGES,
|
|
1158
|
+
}, timeout, options.maintenanceCommand ?? maintenanceEntry());
|
|
1159
|
+
maintenanceMs = performance.now() - maintenanceStarted;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const outcome: SyncSummary["outcome"] = pending.total === 0 && !discovery?.pending_sources ? "complete"
|
|
1163
|
+
: exhausted.has("pressure") ? "pending_pressure"
|
|
1164
|
+
: wallBudgetExceeded ? "wall_budget_exceeded"
|
|
1165
|
+
: exhausted.size > 0 && !(exhausted.size === 1 && exhausted.has("retries")) ? "pending_budget"
|
|
1166
|
+
: pending.retrying > 0 || exhausted.has("retries") ? "pending_retry" : "pending";
|
|
1167
|
+
// Post-pass durable coverage signal (issue #37): how many sources stand
|
|
1168
|
+
// degraded after this pass recorded its verdicts. Maintenance closes the
|
|
1169
|
+
// writer handle, so re-read through a short-lived reader when needed; the
|
|
1170
|
+
// read-only reader never migrates, so this cannot reopen the write path.
|
|
1171
|
+
let coverageSignal: ReturnType<TrajectoryDb["getSourceCoverageSummary"]> = null;
|
|
1172
|
+
if (!dbClosed) {
|
|
1173
|
+
coverageSignal = db.getSourceCoverageSummary();
|
|
1174
|
+
} else {
|
|
1175
|
+
try {
|
|
1176
|
+
const reader = new TrajectoryReader(options.dbPath);
|
|
1177
|
+
try {
|
|
1178
|
+
coverageSignal = reader.getSourceCoverageSummary();
|
|
1179
|
+
} finally {
|
|
1180
|
+
reader.close();
|
|
1181
|
+
}
|
|
1182
|
+
} catch {
|
|
1183
|
+
// In-memory stores and never-materialized paths have no reader
|
|
1184
|
+
// surface; the signal is simply unknown for this pass.
|
|
1185
|
+
coverageSignal = null;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return {
|
|
1189
|
+
type: "sync_summary",
|
|
1190
|
+
schema_version: 2,
|
|
1191
|
+
completion_scope: "known_work",
|
|
1192
|
+
...(discovery ? { discovery } : {}),
|
|
1193
|
+
preparation_ms: preparationMs,
|
|
1194
|
+
outcome,
|
|
1195
|
+
elapsed_ms: performance.now() - started,
|
|
1196
|
+
timing: { scan_ms: scanMs, queue_ms: queueMs, workers_ms: workersMs, maintenance_ms: maintenanceMs, derive_ms: deriveMs },
|
|
1197
|
+
discovered: discoveredCount,
|
|
1198
|
+
queued_at_start: queuedAtStart,
|
|
1199
|
+
sessions_admitted: selected.items.length,
|
|
1200
|
+
lanes: { fresh: selected.fresh, backlog: selected.backlog },
|
|
1201
|
+
worker_processes: workerProcesses,
|
|
1202
|
+
worker_attempts: workerAttempts,
|
|
1203
|
+
retries_scheduled: retriesScheduled,
|
|
1204
|
+
completed,
|
|
1205
|
+
results,
|
|
1206
|
+
work,
|
|
1207
|
+
pending,
|
|
1208
|
+
budgets: { ...limits, exhausted: [...exhausted].sort(), effective },
|
|
1209
|
+
pressure: { mode: pressureMode, initial: initialPressure, final: finalPressure, state: pressureState },
|
|
1210
|
+
maintenance,
|
|
1211
|
+
...(coverageSignal ? { coverage: coverageSignal } : {}),
|
|
1212
|
+
};
|
|
1213
|
+
} finally {
|
|
1214
|
+
if (!dbClosed) db.close();
|
|
1215
|
+
}
|
|
1216
|
+
} finally {
|
|
1217
|
+
held?.close();
|
|
1218
|
+
}
|
|
1219
|
+
}
|