clawmem 0.7.1 → 0.8.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.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ClawMem Worker Lease (v0.8.0 Ext 5)
3
+ *
4
+ * DB-backed exclusive lease for heavy-lane workers. Uses the `worker_leases`
5
+ * table (schema in store.ts) instead of module globals so multiple processes
6
+ * sharing a vault cannot run heavy maintenance concurrently.
7
+ *
8
+ * Lease lifecycle:
9
+ * 1. acquireWorkerLease inserts or reclaims an expired row via transaction
10
+ * and returns a random fencing token on success.
11
+ * 2. The holder runs its work.
12
+ * 3. releaseWorkerLease deletes the row only if the caller's token matches,
13
+ * so a lease reclaimed by another worker after TTL expiry cannot be
14
+ * torn down by the original holder.
15
+ *
16
+ * withWorkerLease wraps acquire/release around a callback; failure to acquire
17
+ * is a silent no-op (returns `{acquired: false}`) — callers should log a
18
+ * `skipped` journal row with reason `lease_unavailable`.
19
+ */
20
+
21
+ import { randomBytes } from "node:crypto";
22
+ import type { Store } from "./store.ts";
23
+
24
+ export interface LeaseAcquireResult {
25
+ acquired: boolean;
26
+ token?: string;
27
+ expiresAt?: string;
28
+ }
29
+
30
+ function nowIso(now: Date = new Date()): string {
31
+ return now.toISOString();
32
+ }
33
+
34
+ function futureIso(now: Date, ttlMs: number): string {
35
+ return new Date(now.getTime() + ttlMs).toISOString();
36
+ }
37
+
38
+ /**
39
+ * Attempt to acquire an exclusive lease on `workerName` for `ttlMs`.
40
+ *
41
+ * Returns `{acquired: true, token, expiresAt}` on success, or
42
+ * `{acquired: false}` if another worker holds a live (non-expired) lease.
43
+ *
44
+ * Race-safe under multi-process contention: uses a single
45
+ * `INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at <= ?` statement
46
+ * so the "no row → insert" and "expired row → update" paths cannot
47
+ * both fire for two concurrent callers. SQLite's changes() reports 1
48
+ * iff THIS call either inserted a fresh row or reclaimed an expired row;
49
+ * 0 means a live lease was held by someone else.
50
+ *
51
+ * Any SQLITE_BUSY / constraint failure is translated to
52
+ * `{ acquired: false }` so the advertised non-throw contract holds for
53
+ * callers that are layering `shouldRunHeavyMaintenance` above this.
54
+ */
55
+ export function acquireWorkerLease(
56
+ store: Store,
57
+ workerName: string,
58
+ ttlMs: number,
59
+ now: Date = new Date(),
60
+ ): LeaseAcquireResult {
61
+ if (ttlMs <= 0) {
62
+ throw new Error(`acquireWorkerLease: ttlMs must be positive, got ${ttlMs}`);
63
+ }
64
+ const token = randomBytes(16).toString("hex");
65
+ const acquiredAt = nowIso(now);
66
+ const expiresAt = futureIso(now, ttlMs);
67
+
68
+ try {
69
+ // Single-statement atomic acquire. The WHERE on the UPDATE clause
70
+ // only reclaims when the existing lease has expired (its expires_at
71
+ // <= our acquired_at); otherwise the ON CONFLICT DO UPDATE becomes
72
+ // a no-op and SQLite reports changes=0.
73
+ const result = store.db.prepare(
74
+ `INSERT INTO worker_leases
75
+ (worker_name, lease_token, acquired_at, expires_at)
76
+ VALUES (?, ?, ?, ?)
77
+ ON CONFLICT(worker_name) DO UPDATE SET
78
+ lease_token = excluded.lease_token,
79
+ acquired_at = excluded.acquired_at,
80
+ expires_at = excluded.expires_at
81
+ WHERE worker_leases.expires_at <= excluded.acquired_at`,
82
+ ).run(workerName, token, acquiredAt, expiresAt);
83
+
84
+ if (result.changes === 0) {
85
+ return { acquired: false };
86
+ }
87
+ return { acquired: true, token, expiresAt };
88
+ } catch (err) {
89
+ // Defensive fallback: any unexpected DB error (SQLITE_BUSY under
90
+ // extreme contention, constraint error from schema drift, etc.) is
91
+ // translated to a lease-unavailable result instead of bubbling up,
92
+ // so heavy-maintenance callers always get a deterministic
93
+ // "skipped/lease_unavailable" journal row.
94
+ console.error(
95
+ `[worker-lease] acquire error for ${workerName}: ${(err as Error).message}`,
96
+ );
97
+ return { acquired: false };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Release a lease if the caller's token still matches. Returns `true` if
103
+ * the lease was owned and deleted, `false` if a different token held it
104
+ * (e.g., TTL expired and another worker reclaimed).
105
+ */
106
+ export function releaseWorkerLease(
107
+ store: Store,
108
+ workerName: string,
109
+ token: string,
110
+ ): boolean {
111
+ const result = store.db.prepare(
112
+ `DELETE FROM worker_leases WHERE worker_name = ? AND lease_token = ?`,
113
+ ).run(workerName, token);
114
+ return result.changes > 0;
115
+ }
116
+
117
+ /**
118
+ * Run `fn` under an exclusive lease on `workerName`. If the lease cannot
119
+ * be acquired, returns `{acquired: false}` without invoking `fn`. The
120
+ * lease is always released in a `finally` block, even if `fn` throws.
121
+ *
122
+ * Rethrows any error from `fn` — callers are responsible for translating
123
+ * exceptions into journal rows.
124
+ */
125
+ export async function withWorkerLease<T>(
126
+ store: Store,
127
+ workerName: string,
128
+ ttlMs: number,
129
+ fn: () => Promise<T>,
130
+ ): Promise<{ acquired: boolean; result?: T }> {
131
+ const lease = acquireWorkerLease(store, workerName, ttlMs);
132
+ if (!lease.acquired || !lease.token) {
133
+ return { acquired: false };
134
+ }
135
+ try {
136
+ const result = await fn();
137
+ return { acquired: true, result };
138
+ } finally {
139
+ releaseWorkerLease(store, workerName, lease.token);
140
+ }
141
+ }