orez 0.5.8 → 0.5.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/pg-proxy-browser.d.ts +1 -1
- package/dist/pg-proxy-browser.d.ts.map +1 -1
- package/dist/pg-proxy-browser.js +184 -108
- package/dist/pg-proxy-browser.js.map +1 -1
- package/dist/worker/shims/zero-process-env.d.ts +1 -1
- package/dist/worker/shims/zero-process-env.d.ts.map +1 -1
- package/dist/worker/shims/zero-process-env.js +58 -8
- package/dist/worker/shims/zero-process-env.js.map +1 -1
- package/dist/worker/zero-cache-embed-cf.d.ts +0 -1
- package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
- package/dist/worker/zero-cache-embed-cf.js +373 -258
- package/dist/worker/zero-cache-embed-cf.js.map +1 -1
- package/dist/worker/zero-cache-run-worker.d.ts +2 -0
- package/dist/worker/zero-cache-run-worker.d.ts.map +1 -0
- package/dist/worker/zero-cache-run-worker.js +3 -0
- package/dist/worker/zero-cache-run-worker.js.map +1 -0
- package/package.json +2 -2
|
@@ -35,12 +35,7 @@
|
|
|
35
35
|
* again from durable DO SQLite. see plans/cf-do-idle-hibernation.md and
|
|
36
36
|
* `shouldHibernateIdleZeroCache` in ./zero-cache-do-idle.ts.
|
|
37
37
|
*/
|
|
38
|
-
import './shims/zero-process-env.js';
|
|
39
38
|
import { EventEmitter } from 'node:events';
|
|
40
|
-
// static import so wrangler can follow the dependency tree and bundle
|
|
41
|
-
// zero-cache with all its transitive deps + our shim aliases.
|
|
42
|
-
// @ts-expect-error — internal zero-cache module, no type declarations
|
|
43
|
-
import { runWorker as _runWorker } from '@rocicorp/zero/out/zero-cache/src/server/runner/run-worker.js';
|
|
44
39
|
import { setLogLevel } from '../log.js';
|
|
45
40
|
import { createBrowserProxy } from '../pg-proxy-browser.js';
|
|
46
41
|
import { DoBackend } from '../pg-proxy-do-backend.js';
|
|
@@ -49,7 +44,73 @@ import { DurableObjectWebSocketHandoff, } from './durable-object-websocket-hando
|
|
|
49
44
|
import { sweepLeakedSqliteHandles } from './embed-generation.js';
|
|
50
45
|
import { createLocalSqlBackend } from './local-sql-backend.js';
|
|
51
46
|
import { resetFastifyRegistry } from './shims/fastify.js';
|
|
47
|
+
import { acquireZeroProcessEnv } from './shims/zero-process-env.js';
|
|
48
|
+
// static import so wrangler follows zero-cache's dependency tree and shim aliases.
|
|
49
|
+
import { runWorker as _runWorker } from './zero-cache-run-worker.js';
|
|
52
50
|
const runWorkerFn = _runWorker;
|
|
51
|
+
const WORKER_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
52
|
+
// zero-cache's in-process worker graph and the CF shims still contain
|
|
53
|
+
// process-wide module state. keep one embed per isolate until those upstream
|
|
54
|
+
// globals are instance-routed; rejecting a second generation is safer than
|
|
55
|
+
// cross-routing one durable object's sql or process events into another.
|
|
56
|
+
let activeGeneration = null;
|
|
57
|
+
const propertyOwners = new WeakMap();
|
|
58
|
+
function releaseGenerationWhenComplete(generation) {
|
|
59
|
+
if (activeGeneration === generation &&
|
|
60
|
+
generation.workerDone &&
|
|
61
|
+
generation.cleanupDone &&
|
|
62
|
+
!generation.cleanupFailed) {
|
|
63
|
+
activeGeneration = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function setOwnedProperty(mutations, generation, target, key, value) {
|
|
67
|
+
const mutation = mutations.find((candidate) => candidate.target === target && candidate.key === key);
|
|
68
|
+
if (mutation) {
|
|
69
|
+
mutation.installedValue = value;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
mutations.push({
|
|
73
|
+
hadValue: Object.prototype.hasOwnProperty.call(target, key),
|
|
74
|
+
installedValue: value,
|
|
75
|
+
key,
|
|
76
|
+
previousValue: target[key],
|
|
77
|
+
target,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
let owners = propertyOwners.get(target);
|
|
81
|
+
if (!owners) {
|
|
82
|
+
owners = new Map();
|
|
83
|
+
propertyOwners.set(target, owners);
|
|
84
|
+
}
|
|
85
|
+
owners.set(key, generation.token);
|
|
86
|
+
target[key] = value;
|
|
87
|
+
}
|
|
88
|
+
function updateOwnedProperty(mutations, generation, target, key) {
|
|
89
|
+
const mutation = mutations.find((candidate) => candidate.target === target && candidate.key === key);
|
|
90
|
+
if (!mutation)
|
|
91
|
+
return;
|
|
92
|
+
const owners = propertyOwners.get(target);
|
|
93
|
+
if (owners?.get(key) !== generation.token)
|
|
94
|
+
return;
|
|
95
|
+
mutation.installedValue = target[key];
|
|
96
|
+
}
|
|
97
|
+
function restoreOwnedProperties(mutations, generation) {
|
|
98
|
+
for (let index = mutations.length - 1; index >= 0; index--) {
|
|
99
|
+
const mutation = mutations[index];
|
|
100
|
+
const owners = propertyOwners.get(mutation.target);
|
|
101
|
+
if (owners?.get(mutation.key) !== generation.token)
|
|
102
|
+
continue;
|
|
103
|
+
owners.delete(mutation.key);
|
|
104
|
+
if (mutation.target[mutation.key] !== mutation.installedValue)
|
|
105
|
+
continue;
|
|
106
|
+
if (mutation.hadValue) {
|
|
107
|
+
mutation.target[mutation.key] = mutation.previousValue;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
delete mutation.target[mutation.key];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
53
114
|
// tx-journal owner id for every pg session this embed opens. recovery at
|
|
54
115
|
// embed boot targets exactly this owner, so it can never roll back another
|
|
55
116
|
// client's live transaction on the shared upstream db (e.g. the app worker's
|
|
@@ -79,238 +140,326 @@ function addProtocolSessionFactory(backend, createProtocolSession) {
|
|
|
79
140
|
* a DoBackend target for upstream/CVR/change Postgres connections.
|
|
80
141
|
*/
|
|
81
142
|
export async function startZeroCacheEmbedCF(opts) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// ignored and the replication poll loop is undebuggable in tails.
|
|
85
|
-
setLogLevel(opts.env?.OREZ_LOG_LEVEL || 'warn');
|
|
86
|
-
// generation hermeticity: the isolate (module state) outlives a stop() —
|
|
87
|
-
// CF reuses it across DO instance recreation after idle-hibernation
|
|
88
|
-
// teardown. a fresh embed must start from the replication-handler state a
|
|
89
|
-
// fresh isolate would have (lsn floor, stream watermark, schema caches);
|
|
90
|
-
// the reconnect reconciliation in handleStartReplication is designed to
|
|
91
|
-
// resume from durable state, not from a prior generation's module vars.
|
|
92
|
-
resetReplicationState();
|
|
93
|
-
// embed restart contract (see ./embed-generation.ts): reclaim what process
|
|
94
|
-
// death would have — sqlite handles the dead generation never closed
|
|
95
|
-
// (zero-cache relies on process-per-worker exit for these), and the
|
|
96
|
-
// fastify/ws shim instance registry (a dead change-streamer otherwise
|
|
97
|
-
// captures the new generation's replicator subscription and boot hangs).
|
|
98
|
-
const leakedHandles = sweepLeakedSqliteHandles();
|
|
99
|
-
if (leakedHandles > 0) {
|
|
100
|
-
console.warn(`[orez-zero-cache-cf] closed ${leakedHandles} sqlite handles leaked by the previous embed generation`);
|
|
101
|
-
}
|
|
102
|
-
resetFastifyRegistry();
|
|
103
|
-
const appId = opts.appId || 'zero';
|
|
104
|
-
const publications = opts.publications?.join(',') || `orez_${appId}_public`;
|
|
105
|
-
const readyTimeout = opts.readyTimeout ?? 30000;
|
|
106
|
-
const pgUser = opts.pgUser || 'user';
|
|
107
|
-
const pgPassword = opts.pgPassword || '';
|
|
108
|
-
const backendUrl = opts.backendUrl || 'https://orez-do-backend.local';
|
|
109
|
-
const backendNamespace = opts.backendNamespace || appId;
|
|
110
|
-
// zero-cache's CVR and change DBs are embed-private state; they live in
|
|
111
|
-
// THIS Durable Object's SQLite so their pg sessions never pay a cross-DO
|
|
112
|
-
// round-trip. only the shared upstream `postgres` db routes to ZeroSqlDO.
|
|
113
|
-
const localSql = createLocalSqlBackend(opts.doSqlite);
|
|
114
|
-
const createBackend = (dbName) => new DoBackend(backendUrl, dbName, backendNamespace, {
|
|
115
|
-
fetch: dbName === 'postgres' ? opts.backendFetch : localSql.fetch,
|
|
116
|
-
txOwner: EMBED_TX_OWNER,
|
|
117
|
-
});
|
|
118
|
-
// crash recovery: this embed boot proves the previous embed generation is
|
|
119
|
-
// dead, so any journaled transaction it left mid-flight (DO eviction,
|
|
120
|
-
// deploy upgrade-kill) is rolled back BEFORE any pg session opens. without
|
|
121
|
-
// this, a partially-persisted tx (e.g. the change-streamer's cdc changeLog
|
|
122
|
-
// write) wedges replication on every subsequent boot.
|
|
123
|
-
localSql.recoverOrphanedTransactions();
|
|
124
|
-
await recoverRemoteTransactions(`${backendUrl.replace(/\/+$/, '')}/recover-txs?db=postgres&ns=${encodeURIComponent(backendNamespace)}`, opts.backendFetch);
|
|
125
|
-
const backends = {
|
|
126
|
-
postgres: createBackend('postgres'),
|
|
127
|
-
cvr: createBackend('zero_cvr'),
|
|
128
|
-
cdb: createBackend('zero_cdb'),
|
|
129
|
-
};
|
|
130
|
-
const proxyBackends = {
|
|
131
|
-
postgres: addProtocolSessionFactory(backends.postgres, () => createBackend('postgres')),
|
|
132
|
-
cvr: addProtocolSessionFactory(backends.cvr, () => createBackend('zero_cvr')),
|
|
133
|
-
cdb: addProtocolSessionFactory(backends.cdb, () => createBackend('zero_cdb')),
|
|
134
|
-
};
|
|
135
|
-
await Promise.all([
|
|
136
|
-
backends.postgres.waitReady,
|
|
137
|
-
backends.cvr.waitReady,
|
|
138
|
-
backends.cdb.waitReady,
|
|
139
|
-
]);
|
|
140
|
-
const proxy = await createBrowserProxy({
|
|
141
|
-
postgres: proxyBackends.postgres,
|
|
142
|
-
cvr: proxyBackends.cvr,
|
|
143
|
-
cdb: proxyBackends.cdb,
|
|
144
|
-
postgresReplicas: [],
|
|
145
|
-
}, {
|
|
146
|
-
pgUser,
|
|
147
|
-
pgPassword,
|
|
148
|
-
singleDb: false,
|
|
149
|
-
logLevel: opts.env?.ZERO_LOG_LEVEL || 'info',
|
|
150
|
-
});
|
|
151
|
-
globalThis.__orez_do_sqlite = opts.doSqlite;
|
|
152
|
-
globalThis.__orez_proxy_connect = (port) => {
|
|
153
|
-
proxy.handleConnection(port);
|
|
154
|
-
};
|
|
155
|
-
globalThis.__orez_proxy_user = pgUser;
|
|
156
|
-
globalThis.__orez_proxy_password = pgPassword;
|
|
157
|
-
globalThis.process ??= {};
|
|
158
|
-
globalThis.process.env ??= {};
|
|
159
|
-
globalThis.process.pid ??= 1;
|
|
160
|
-
globalThis.process.argv ??= [];
|
|
161
|
-
globalThis.process.env.SINGLE_PROCESS = '1';
|
|
162
|
-
globalThis.process.env.NODE_ENV = 'development';
|
|
163
|
-
globalThis.process.kill ??= () => { };
|
|
164
|
-
// create fake parent EventEmitter for zero-cache's runWorker()
|
|
165
|
-
// must be declared before process.exit shim (which references it)
|
|
166
|
-
const parent = new EventEmitter();
|
|
167
|
-
const parentEmitter = new EventEmitter();
|
|
168
|
-
parent.send = (message, sendHandle) => {
|
|
169
|
-
parentEmitter.emit('message', message, sendHandle);
|
|
170
|
-
return true;
|
|
171
|
-
};
|
|
172
|
-
parent.kill = (signal = 'SIGTERM') => {
|
|
173
|
-
parent.emit(signal, signal);
|
|
174
|
-
};
|
|
175
|
-
parent.pid = globalThis.process.pid ?? 1;
|
|
176
|
-
// shim process.exit to emit on parent instead of actually exiting
|
|
177
|
-
const origExit = globalThis.process.exit;
|
|
178
|
-
const origNodeEnv = globalThis.process.env.NODE_ENV;
|
|
179
|
-
const origKill = globalThis.process.kill;
|
|
180
|
-
const origFetch = globalThis.fetch;
|
|
181
|
-
globalThis.process.exit = (code) => {
|
|
182
|
-
parent.emit('exit', code ?? 0);
|
|
183
|
-
};
|
|
184
|
-
if (opts.apiFetch) {
|
|
185
|
-
;
|
|
186
|
-
globalThis.fetch = (input, init) => {
|
|
187
|
-
const request = new Request(input, init);
|
|
188
|
-
const url = new URL(request.url);
|
|
189
|
-
if (url.hostname === 'orez-zero-api.local')
|
|
190
|
-
return opts.apiFetch(request);
|
|
191
|
-
return origFetch(input, init);
|
|
192
|
-
};
|
|
143
|
+
if (activeGeneration) {
|
|
144
|
+
throw new Error('zero-cache CF embed: another generation is active or still tearing down');
|
|
193
145
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// postgres-browser intercepts these URLs and routes PG wire over
|
|
200
|
-
// MessagePort to the DoBackend-backed proxy above.
|
|
201
|
-
ZERO_UPSTREAM_DB: `postgres://${pgUser}:ignored@127.0.0.1/postgres`,
|
|
202
|
-
ZERO_CVR_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cvr`,
|
|
203
|
-
ZERO_CHANGE_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cdb`,
|
|
204
|
-
// this path is intercepted by the sqlite shim
|
|
205
|
-
ZERO_REPLICA_FILE: ':do-sqlite:',
|
|
206
|
-
// don't bind a port — we route via inject/handoff
|
|
207
|
-
ZERO_PORT: '0',
|
|
208
|
-
ZERO_APP_ID: appId,
|
|
209
|
-
ZERO_APP_PUBLICATIONS: publications,
|
|
210
|
-
ZERO_ADMIN_PASSWORD: opts.env?.ZERO_ADMIN_PASSWORD || crypto.randomUUID(),
|
|
211
|
-
ZERO_NUM_SYNC_WORKERS: opts.env?.ZERO_NUM_SYNC_WORKERS || '1',
|
|
212
|
-
ZERO_ENABLE_QUERY_PLANNER: 'false',
|
|
213
|
-
// one isolate, one sync worker — zero-cache's default pg pools (upstream 20,
|
|
214
|
-
// cvr 30, change 5) would let ~50 DoBackend protocol sessions accumulate
|
|
215
|
-
// inside the single 128MB DO isolate, each carrying its own rewrite cache +
|
|
216
|
-
// protocol/schema state. cap all three hard: with SINGLE_PROCESS + one sync
|
|
217
|
-
// worker a couple connections per db is plenty, and the freed heap is what
|
|
218
|
-
// keeps cold-boot view-syncer hydration from tipping the isolate over its
|
|
219
|
-
// memory limit. (these are the max=N pools; zero also opens fixed max=1/max=2
|
|
220
|
-
// pools per db for the replication stream + initial-sync that we can't cap.)
|
|
221
|
-
ZERO_UPSTREAM_MAX_CONNS: opts.env?.ZERO_UPSTREAM_MAX_CONNS || '2',
|
|
222
|
-
ZERO_CVR_MAX_CONNS: opts.env?.ZERO_CVR_MAX_CONNS || '2',
|
|
223
|
-
ZERO_CHANGE_MAX_CONNS: opts.env?.ZERO_CHANGE_MAX_CONNS || '2',
|
|
224
|
-
// 'info' dumps the full table schema as JSON on every replication-status
|
|
225
|
-
// event; during cold-boot hydration + the reconnect loop that string churn
|
|
226
|
-
// is pure heap pressure in the 128MB isolate. 'warn' keeps errors visible.
|
|
227
|
-
ZERO_LOG_LEVEL: opts.env?.ZERO_LOG_LEVEL || 'warn',
|
|
228
|
-
...opts.env,
|
|
229
|
-
// shadow sync is an optional upstream canary that imports the initial-sync
|
|
230
|
-
// copy path. keep it disabled in the CF embed to avoid bundling unused
|
|
231
|
-
// worker code and storage paths into Durable Objects.
|
|
232
|
-
ZERO_SHADOW_SYNC_ENABLED: 'false',
|
|
146
|
+
const generation = {
|
|
147
|
+
cleanupDone: false,
|
|
148
|
+
cleanupFailed: false,
|
|
149
|
+
token: Symbol('zero-cache-cf-generation'),
|
|
150
|
+
workerDone: true,
|
|
233
151
|
};
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
return receiver;
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
if (prop === 'onceMessageType') {
|
|
251
|
-
return (type, handler) => {
|
|
252
|
-
const listener = (data, sendHandle) => {
|
|
253
|
-
if (Array.isArray(data) && data.length === 2 && data[0] === type) {
|
|
254
|
-
target.off('message', listener);
|
|
255
|
-
handler(data[1], sendHandle);
|
|
256
|
-
}
|
|
257
|
-
};
|
|
258
|
-
target.on('message', listener);
|
|
259
|
-
return receiver;
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
return Reflect.get(target, prop, receiver);
|
|
263
|
-
},
|
|
264
|
-
});
|
|
265
|
-
// track state
|
|
152
|
+
activeGeneration = generation;
|
|
153
|
+
const globalRecord = globalThis;
|
|
154
|
+
let processRecord;
|
|
155
|
+
let processEnv;
|
|
156
|
+
let releaseProcessEnv = null;
|
|
157
|
+
const mutations = [];
|
|
158
|
+
const backendRoots = [];
|
|
159
|
+
let proxy = null;
|
|
160
|
+
let parent = null;
|
|
161
|
+
let parentEmitter = null;
|
|
162
|
+
let wrappedParent = null;
|
|
266
163
|
let isReady = false;
|
|
164
|
+
let stopping = false;
|
|
267
165
|
let runWorkerPromise = null;
|
|
268
|
-
|
|
269
|
-
|
|
166
|
+
let workerSettledPromise = null;
|
|
167
|
+
let workerError;
|
|
168
|
+
let workerFailed = false;
|
|
169
|
+
let startupFailure;
|
|
170
|
+
let shutdownPromise = null;
|
|
270
171
|
let fastifyInstance = null;
|
|
271
172
|
let readyTimer;
|
|
173
|
+
let debugEmbed = false;
|
|
272
174
|
const webSocketHandoff = new DurableObjectWebSocketHandoff(() => fastifyInstance);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
175
|
+
const updateOwnedFastifyInstance = () => {
|
|
176
|
+
const instancesMutation = mutations.find((mutation) => mutation.target === globalRecord && mutation.key === '__orez_fastify_instances');
|
|
177
|
+
const currentInstance = globalRecord.__orez_fastify_instance;
|
|
178
|
+
if (Array.isArray(instancesMutation?.installedValue) &&
|
|
179
|
+
instancesMutation.installedValue.includes(currentInstance)) {
|
|
180
|
+
updateOwnedProperty(mutations, generation, globalRecord, '__orez_fastify_instance');
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const shutdown = () => {
|
|
184
|
+
if (shutdownPromise)
|
|
185
|
+
return shutdownPromise;
|
|
186
|
+
stopping = true;
|
|
187
|
+
isReady = false;
|
|
188
|
+
if (readyTimer)
|
|
189
|
+
clearTimeout(readyTimer);
|
|
190
|
+
shutdownPromise = (async () => {
|
|
191
|
+
const cleanupErrors = [];
|
|
192
|
+
let resourceCleanupFailed = false;
|
|
193
|
+
if (wrappedParent && runWorkerPromise && !generation.workerDone) {
|
|
194
|
+
try {
|
|
195
|
+
;
|
|
196
|
+
wrappedParent.kill('SIGTERM');
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
cleanupErrors.push(err);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (workerSettledPromise && !generation.workerDone) {
|
|
203
|
+
let timeout;
|
|
204
|
+
const workerStopped = await Promise.race([
|
|
205
|
+
workerSettledPromise.then(() => true),
|
|
206
|
+
new Promise((resolve) => {
|
|
207
|
+
timeout = setTimeout(() => resolve(false), WORKER_SHUTDOWN_TIMEOUT_MS);
|
|
208
|
+
}),
|
|
209
|
+
]);
|
|
210
|
+
if (timeout)
|
|
211
|
+
clearTimeout(timeout);
|
|
212
|
+
if (!workerStopped) {
|
|
213
|
+
cleanupErrors.push(new Error(`zero-cache CF embed: worker did not terminate within ${WORKER_SHUTDOWN_TIMEOUT_MS}ms`));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (workerFailed && workerError !== startupFailure) {
|
|
217
|
+
cleanupErrors.push(workerError);
|
|
218
|
+
}
|
|
219
|
+
if (proxy) {
|
|
220
|
+
try {
|
|
221
|
+
await proxy.close();
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
resourceCleanupFailed = true;
|
|
225
|
+
cleanupErrors.push(err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const backendResults = await Promise.allSettled(backendRoots.map((backend) => Promise.resolve().then(() => backend.close())));
|
|
229
|
+
for (const result of backendResults) {
|
|
230
|
+
if (result.status === 'rejected') {
|
|
231
|
+
resourceCleanupFailed = true;
|
|
232
|
+
cleanupErrors.push(result.reason);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
updateOwnedFastifyInstance();
|
|
236
|
+
restoreOwnedProperties(mutations, generation);
|
|
237
|
+
releaseProcessEnv?.();
|
|
238
|
+
parentEmitter?.removeAllListeners();
|
|
239
|
+
if (generation.workerDone)
|
|
240
|
+
parent?.removeAllListeners();
|
|
241
|
+
generation.cleanupDone = true;
|
|
242
|
+
generation.cleanupFailed = resourceCleanupFailed;
|
|
243
|
+
releaseGenerationWhenComplete(generation);
|
|
244
|
+
if (cleanupErrors.length > 0) {
|
|
245
|
+
throw new AggregateError(cleanupErrors, 'zero-cache CF embed: teardown failed');
|
|
286
246
|
}
|
|
247
|
+
})();
|
|
248
|
+
return shutdownPromise;
|
|
249
|
+
};
|
|
250
|
+
const handleUnexpectedWorkerExit = () => {
|
|
251
|
+
if (!isReady || stopping)
|
|
252
|
+
return;
|
|
253
|
+
isReady = false;
|
|
254
|
+
if (!workerFailed) {
|
|
255
|
+
workerFailed = true;
|
|
256
|
+
workerError = new Error('zero-cache CF embed: runWorker exited after becoming ready');
|
|
257
|
+
}
|
|
258
|
+
void shutdown().catch((error) => {
|
|
259
|
+
console.error('[orez-zero-cache-cf] unexpected worker exit cleanup failed', error);
|
|
287
260
|
});
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
releaseProcessEnv = acquireZeroProcessEnv();
|
|
264
|
+
processRecord = globalRecord.process;
|
|
265
|
+
processEnv = processRecord.env;
|
|
266
|
+
// wire orez's own logger from env, mirroring the node path's setLogLevel.
|
|
267
|
+
setLogLevel(opts.env?.OREZ_LOG_LEVEL || 'warn');
|
|
268
|
+
resetReplicationState();
|
|
269
|
+
const leakedHandles = sweepLeakedSqliteHandles();
|
|
270
|
+
if (leakedHandles > 0) {
|
|
271
|
+
console.warn(`[orez-zero-cache-cf] closed ${leakedHandles} sqlite handles leaked by the previous embed generation`);
|
|
295
272
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
273
|
+
setOwnedProperty(mutations, generation, globalRecord, '__orez_fastify_instance', undefined);
|
|
274
|
+
delete globalRecord.__orez_fastify_instance;
|
|
275
|
+
setOwnedProperty(mutations, generation, globalRecord, '__orez_fastify_instances', []);
|
|
276
|
+
resetFastifyRegistry();
|
|
277
|
+
updateOwnedProperty(mutations, generation, globalRecord, '__orez_fastify_instances');
|
|
278
|
+
const appId = opts.appId || 'zero';
|
|
279
|
+
const publications = opts.publications?.join(',') || `orez_${appId}_public`;
|
|
280
|
+
const readyTimeout = opts.readyTimeout ?? 30000;
|
|
281
|
+
const pgUser = opts.pgUser || 'user';
|
|
282
|
+
const pgPassword = opts.pgPassword || '';
|
|
283
|
+
const backendUrl = opts.backendUrl || 'https://orez-do-backend.local';
|
|
284
|
+
const backendNamespace = opts.backendNamespace || appId;
|
|
285
|
+
const localSql = createLocalSqlBackend(opts.doSqlite);
|
|
286
|
+
const instantiateBackend = (dbName) => new DoBackend(backendUrl, dbName, backendNamespace, {
|
|
287
|
+
fetch: dbName === 'postgres' ? opts.backendFetch : localSql.fetch,
|
|
288
|
+
txOwner: EMBED_TX_OWNER,
|
|
289
|
+
});
|
|
290
|
+
const createRootBackend = (dbName) => {
|
|
291
|
+
const backend = instantiateBackend(dbName);
|
|
292
|
+
backendRoots.push(backend);
|
|
293
|
+
return backend;
|
|
294
|
+
};
|
|
295
|
+
localSql.recoverOrphanedTransactions();
|
|
296
|
+
await recoverRemoteTransactions(`${backendUrl.replace(/\/+$/, '')}/recover-txs?db=postgres&ns=${encodeURIComponent(backendNamespace)}`, opts.backendFetch);
|
|
297
|
+
const backends = {
|
|
298
|
+
postgres: createRootBackend('postgres'),
|
|
299
|
+
cvr: createRootBackend('zero_cvr'),
|
|
300
|
+
cdb: createRootBackend('zero_cdb'),
|
|
301
|
+
};
|
|
302
|
+
const proxyBackends = {
|
|
303
|
+
postgres: addProtocolSessionFactory(backends.postgres, () => instantiateBackend('postgres')),
|
|
304
|
+
cvr: addProtocolSessionFactory(backends.cvr, () => instantiateBackend('zero_cvr')),
|
|
305
|
+
cdb: addProtocolSessionFactory(backends.cdb, () => instantiateBackend('zero_cdb')),
|
|
306
|
+
};
|
|
307
|
+
const backendReadyResults = await Promise.allSettled([
|
|
308
|
+
backends.postgres.waitReady,
|
|
309
|
+
backends.cvr.waitReady,
|
|
310
|
+
backends.cdb.waitReady,
|
|
311
|
+
]);
|
|
312
|
+
const backendReadyErrors = backendReadyResults.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
|
|
313
|
+
if (backendReadyErrors.length === 1)
|
|
314
|
+
throw backendReadyErrors[0];
|
|
315
|
+
if (backendReadyErrors.length > 1) {
|
|
316
|
+
throw new AggregateError(backendReadyErrors, 'zero-cache CF embed: backend setup failed');
|
|
301
317
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
318
|
+
proxy = await createBrowserProxy({
|
|
319
|
+
postgres: proxyBackends.postgres,
|
|
320
|
+
cvr: proxyBackends.cvr,
|
|
321
|
+
cdb: proxyBackends.cdb,
|
|
322
|
+
postgresReplicas: [],
|
|
323
|
+
}, {
|
|
324
|
+
pgUser,
|
|
325
|
+
pgPassword,
|
|
326
|
+
singleDb: false,
|
|
327
|
+
logLevel: opts.env?.ZERO_LOG_LEVEL || 'info',
|
|
328
|
+
});
|
|
329
|
+
setOwnedProperty(mutations, generation, globalRecord, '__orez_do_sqlite', opts.doSqlite);
|
|
330
|
+
setOwnedProperty(mutations, generation, globalRecord, '__orez_proxy_connect', (port) => proxy?.handleConnection(port));
|
|
331
|
+
setOwnedProperty(mutations, generation, globalRecord, '__orez_proxy_user', pgUser);
|
|
332
|
+
setOwnedProperty(mutations, generation, globalRecord, '__orez_proxy_password', pgPassword);
|
|
333
|
+
const createdParent = new EventEmitter();
|
|
334
|
+
parent = createdParent;
|
|
335
|
+
parentEmitter = new EventEmitter();
|
|
336
|
+
createdParent.send = (message, sendHandle) => {
|
|
337
|
+
parentEmitter?.emit('message', message, sendHandle);
|
|
338
|
+
return true;
|
|
339
|
+
};
|
|
340
|
+
createdParent.kill = (signal = 'SIGTERM') => {
|
|
341
|
+
createdParent.emit(signal, signal);
|
|
342
|
+
};
|
|
343
|
+
createdParent.pid = processRecord.pid ?? 1;
|
|
344
|
+
const originalFetch = globalRecord.fetch;
|
|
345
|
+
setOwnedProperty(mutations, generation, processRecord, 'exit', (code) => parent?.emit('exit', code ?? 0));
|
|
346
|
+
if (opts.apiFetch) {
|
|
347
|
+
setOwnedProperty(mutations, generation, globalRecord, 'fetch', (input, init) => {
|
|
348
|
+
const request = new Request(input, init);
|
|
349
|
+
const url = new URL(request.url);
|
|
350
|
+
if (url.hostname === 'orez-zero-api.local')
|
|
351
|
+
return opts.apiFetch(request);
|
|
352
|
+
return originalFetch(input, init);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const env = {
|
|
356
|
+
...processEnv,
|
|
357
|
+
SINGLE_PROCESS: '1',
|
|
358
|
+
NODE_ENV: 'development',
|
|
359
|
+
ZERO_UPSTREAM_DB: `postgres://${pgUser}:ignored@127.0.0.1/postgres`,
|
|
360
|
+
ZERO_CVR_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cvr`,
|
|
361
|
+
ZERO_CHANGE_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cdb`,
|
|
362
|
+
ZERO_REPLICA_FILE: ':do-sqlite:',
|
|
363
|
+
ZERO_PORT: '0',
|
|
364
|
+
ZERO_APP_ID: appId,
|
|
365
|
+
ZERO_APP_PUBLICATIONS: publications,
|
|
366
|
+
ZERO_ADMIN_PASSWORD: opts.env?.ZERO_ADMIN_PASSWORD || crypto.randomUUID(),
|
|
367
|
+
ZERO_NUM_SYNC_WORKERS: opts.env?.ZERO_NUM_SYNC_WORKERS || '1',
|
|
368
|
+
ZERO_ENABLE_QUERY_PLANNER: 'false',
|
|
369
|
+
ZERO_UPSTREAM_MAX_CONNS: opts.env?.ZERO_UPSTREAM_MAX_CONNS || '2',
|
|
370
|
+
ZERO_CVR_MAX_CONNS: opts.env?.ZERO_CVR_MAX_CONNS || '2',
|
|
371
|
+
ZERO_CHANGE_MAX_CONNS: opts.env?.ZERO_CHANGE_MAX_CONNS || '2',
|
|
372
|
+
ZERO_LOG_LEVEL: opts.env?.ZERO_LOG_LEVEL || 'warn',
|
|
373
|
+
...opts.env,
|
|
374
|
+
ZERO_SHADOW_SYNC_ENABLED: 'false',
|
|
375
|
+
};
|
|
376
|
+
for (const [key, value] of Object.entries(env)) {
|
|
377
|
+
setOwnedProperty(mutations, generation, processEnv, key, value);
|
|
378
|
+
}
|
|
379
|
+
debugEmbed =
|
|
380
|
+
env.OREZ_DEBUG_EMBED === '1' || globalRecord.__OREZ_DEBUG_EMBED__ === true;
|
|
381
|
+
wrappedParent = new Proxy(createdParent, {
|
|
382
|
+
get(target, prop, receiver) {
|
|
383
|
+
if (prop === 'onMessageType') {
|
|
384
|
+
return (type, handler) => {
|
|
385
|
+
target.on('message', (data, sendHandle) => {
|
|
386
|
+
if (Array.isArray(data) && data.length === 2 && data[0] === type) {
|
|
387
|
+
handler(data[1], sendHandle);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
return receiver;
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (prop === 'onceMessageType') {
|
|
394
|
+
return (type, handler) => {
|
|
395
|
+
const listener = (data, sendHandle) => {
|
|
396
|
+
if (Array.isArray(data) && data.length === 2 && data[0] === type) {
|
|
397
|
+
target.off('message', listener);
|
|
398
|
+
handler(data[1], sendHandle);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
target.on('message', listener);
|
|
402
|
+
return receiver;
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return Reflect.get(target, prop, receiver);
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
const readyPromise = new Promise((resolve, reject) => {
|
|
409
|
+
readyTimer = setTimeout(() => {
|
|
410
|
+
reject(new Error(`zero-cache CF embed: timed out waiting for ready after ${readyTimeout}ms`));
|
|
411
|
+
}, readyTimeout);
|
|
412
|
+
parentEmitter?.on('message', (msg) => {
|
|
413
|
+
if (debugEmbed)
|
|
414
|
+
console.debug('[orez-zero-cache-cf] parent message', msg);
|
|
415
|
+
if (!stopping && Array.isArray(msg) && msg[0] === 'ready') {
|
|
416
|
+
if (readyTimer)
|
|
417
|
+
clearTimeout(readyTimer);
|
|
418
|
+
isReady = true;
|
|
419
|
+
resolve();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
generation.workerDone = false;
|
|
424
|
+
runWorkerPromise = Promise.resolve().then(() => runWorkerFn(wrappedParent, env));
|
|
425
|
+
void runWorkerPromise.catch((err) => {
|
|
426
|
+
if (debugEmbed)
|
|
427
|
+
console.error('[orez-zero-cache-cf] runWorker error', err);
|
|
428
|
+
});
|
|
429
|
+
workerSettledPromise = runWorkerPromise.then(() => {
|
|
430
|
+
generation.workerDone = true;
|
|
431
|
+
handleUnexpectedWorkerExit();
|
|
432
|
+
if (generation.cleanupDone)
|
|
433
|
+
parent?.removeAllListeners();
|
|
434
|
+
releaseGenerationWhenComplete(generation);
|
|
435
|
+
}, (error) => {
|
|
436
|
+
workerError = error;
|
|
437
|
+
workerFailed = true;
|
|
438
|
+
generation.workerDone = true;
|
|
439
|
+
handleUnexpectedWorkerExit();
|
|
440
|
+
if (generation.cleanupDone)
|
|
441
|
+
parent?.removeAllListeners();
|
|
442
|
+
releaseGenerationWhenComplete(generation);
|
|
443
|
+
});
|
|
444
|
+
const workerStartupPromise = runWorkerPromise.then(() => {
|
|
445
|
+
if (!isReady) {
|
|
446
|
+
throw new Error('zero-cache CF embed: runWorker exited before ready');
|
|
447
|
+
}
|
|
448
|
+
});
|
|
305
449
|
await Promise.race([readyPromise, workerStartupPromise]);
|
|
450
|
+
fastifyInstance = globalRecord.__orez_fastify_instance;
|
|
451
|
+
updateOwnedFastifyInstance();
|
|
306
452
|
}
|
|
307
|
-
catch (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
453
|
+
catch (startupError) {
|
|
454
|
+
startupFailure = startupError;
|
|
455
|
+
try {
|
|
456
|
+
await shutdown();
|
|
457
|
+
}
|
|
458
|
+
catch (cleanupError) {
|
|
459
|
+
throw new AggregateError([startupError, cleanupError], 'zero-cache CF embed: startup failed and teardown also failed', { cause: startupError });
|
|
460
|
+
}
|
|
461
|
+
throw startupError;
|
|
311
462
|
}
|
|
312
|
-
// get the fastify instance (set by our shim during init)
|
|
313
|
-
fastifyInstance = globalThis.__orez_fastify_instance;
|
|
314
463
|
return {
|
|
315
464
|
get ready() {
|
|
316
465
|
return isReady;
|
|
@@ -330,41 +479,7 @@ export async function startZeroCacheEmbedCF(opts) {
|
|
|
330
479
|
}
|
|
331
480
|
return handleHttpRequest(request, url, fastifyInstance);
|
|
332
481
|
},
|
|
333
|
-
|
|
334
|
-
isReady = false;
|
|
335
|
-
wrappedParent.kill('SIGTERM');
|
|
336
|
-
if (runWorkerPromise) {
|
|
337
|
-
await Promise.race([runWorkerPromise, new Promise((r) => setTimeout(r, 5000))]);
|
|
338
|
-
}
|
|
339
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
340
|
-
proxy.close();
|
|
341
|
-
await Promise.all([
|
|
342
|
-
backends.postgres.close(),
|
|
343
|
-
backends.cvr.close(),
|
|
344
|
-
backends.cdb.close(),
|
|
345
|
-
]);
|
|
346
|
-
// restore all modified globals
|
|
347
|
-
if (origExit) {
|
|
348
|
-
;
|
|
349
|
-
globalThis.process.exit = origExit;
|
|
350
|
-
}
|
|
351
|
-
if (origNodeEnv !== undefined) {
|
|
352
|
-
;
|
|
353
|
-
globalThis.process.env.NODE_ENV = origNodeEnv;
|
|
354
|
-
}
|
|
355
|
-
if (origKill) {
|
|
356
|
-
;
|
|
357
|
-
globalThis.process.kill = origKill;
|
|
358
|
-
}
|
|
359
|
-
if (opts.apiFetch) {
|
|
360
|
-
;
|
|
361
|
-
globalThis.fetch = origFetch;
|
|
362
|
-
}
|
|
363
|
-
delete globalThis.process.env.SINGLE_PROCESS;
|
|
364
|
-
delete globalThis.__orez_proxy_connect;
|
|
365
|
-
delete globalThis.__orez_proxy_user;
|
|
366
|
-
delete globalThis.__orez_proxy_password;
|
|
367
|
-
},
|
|
482
|
+
stop: shutdown,
|
|
368
483
|
};
|
|
369
484
|
}
|
|
370
485
|
// -- HTTP request handling --
|