@moldesk/core 0.0.5 → 0.0.6
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/cloud/accounts.d.ts +69 -0
- package/dist/cloud/accounts.js +113 -0
- package/dist/cloud/accounts.test.d.ts +1 -0
- package/dist/cloud/accounts.test.js +188 -0
- package/dist/cloud/backend.test.d.ts +1 -0
- package/dist/cloud/backend.test.js +339 -0
- package/dist/cloud/deps.d.ts +15 -0
- package/dist/cloud/deps.js +30 -0
- package/dist/cloud/manage.d.ts +57 -0
- package/dist/cloud/manage.js +210 -0
- package/dist/cloud/manage.test.d.ts +1 -0
- package/dist/cloud/manage.test.js +105 -0
- package/dist/cloud/quote.d.ts +131 -0
- package/dist/cloud/quote.js +225 -0
- package/dist/cloud/quote.test.d.ts +1 -0
- package/dist/cloud/quote.test.js +264 -0
- package/dist/cloud/run.d.ts +114 -0
- package/dist/cloud/run.js +498 -0
- package/dist/cloud/run.test.d.ts +1 -0
- package/dist/cloud/run.test.js +597 -0
- package/dist/cloud/state.d.ts +159 -0
- package/dist/cloud/state.js +395 -0
- package/dist/cloud/state.test.d.ts +1 -0
- package/dist/cloud/state.test.js +199 -0
- package/dist/cloud/worker/cache.d.ts +75 -0
- package/dist/cloud/worker/cache.js +152 -0
- package/dist/cloud/worker/cache.test.d.ts +1 -0
- package/dist/cloud/worker/cache.test.js +123 -0
- package/dist/cloud/worker/main.d.ts +2 -0
- package/dist/cloud/worker/main.js +34 -0
- package/dist/cloud/worker/pipeline.d.ts +26 -0
- package/dist/cloud/worker/pipeline.js +160 -0
- package/dist/cloud/worker/pipeline.test.d.ts +1 -0
- package/dist/cloud/worker/pipeline.test.js +298 -0
- package/dist/cloud/worker/server.d.ts +38 -0
- package/dist/cloud/worker/server.js +287 -0
- package/dist/cloud/worker/server.test.d.ts +1 -0
- package/dist/cloud/worker/server.test.js +391 -0
- package/dist/cloud/worker/watchdog.d.ts +27 -0
- package/dist/cloud/worker/watchdog.js +46 -0
- package/dist/cloud/worker-release.d.ts +12 -0
- package/dist/cloud/worker-release.js +25 -0
- package/dist/cloud/worker-release.test.d.ts +1 -0
- package/dist/cloud/worker-release.test.js +21 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +9 -2
- package/dist/run-lifecycle.d.ts +74 -0
- package/dist/run-lifecycle.js +178 -0
- package/dist/run.d.ts +61 -1
- package/dist/run.js +54 -125
- package/dist/run.test.js +47 -1
- package/package.json +10 -6
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { MIN_WORKER_READY_TIMEOUT_MS, RunpodExecutionBackend, WorkerClient, } from "@moldesk/runtime";
|
|
6
|
+
import { cacheLedgerFor } from "./run.js";
|
|
7
|
+
import { CloudJournal } from "./state.js";
|
|
8
|
+
import { createWorkerServer } from "./worker/server.js";
|
|
9
|
+
import { parseManifest } from "@moldesk/registry";
|
|
10
|
+
const DIGEST = "a".repeat(64);
|
|
11
|
+
const dirs = [];
|
|
12
|
+
const fleets = [];
|
|
13
|
+
const manifest = parseManifest({
|
|
14
|
+
schemaVersion: 1,
|
|
15
|
+
name: "m",
|
|
16
|
+
displayName: "M",
|
|
17
|
+
modelVersion: "1.0.0",
|
|
18
|
+
adapterVersion: "0.1.0",
|
|
19
|
+
description: "t",
|
|
20
|
+
category: "other",
|
|
21
|
+
status: "planned",
|
|
22
|
+
source: { repository: "https://example.com/r", revision: "b".repeat(40) },
|
|
23
|
+
runtimes: [{ kind: "python", python: "3.11", installer: "uv", platforms: ["linux-x64"], requirements: [{ name: "torch" }] }],
|
|
24
|
+
hardware: { platforms: ["linux-x64"] },
|
|
25
|
+
input: { formats: [".txt"], required: true },
|
|
26
|
+
outputs: [{ id: "o", glob: "*.out", required: true }],
|
|
27
|
+
cloud: { schemaVersion: 1, platform: "linux-x64", images: { gpu: `r.example/g@sha256:${DIGEST}`, cpu: `r.example/c@sha256:${DIGEST}` }, minVramGb: 24, minRamGb: 32, ephemeralDiskGb: 40, volumeGb: 40, assetLayoutRevision: "1", cpuAssetPreparation: true },
|
|
28
|
+
}, "x/manifest.yaml");
|
|
29
|
+
class Fleet {
|
|
30
|
+
name = "runpod";
|
|
31
|
+
log = [];
|
|
32
|
+
pods = new Map();
|
|
33
|
+
volumes = new Map();
|
|
34
|
+
nextId = 1;
|
|
35
|
+
gpuAvailable = true;
|
|
36
|
+
/** Make the next volume claim `cacheReady` (a warm cache) or not. */
|
|
37
|
+
podStatus = "RUNNING";
|
|
38
|
+
terminateFails = false;
|
|
39
|
+
/** Set to make GPU workers claim the cache is missing once. */
|
|
40
|
+
forgetCache = false;
|
|
41
|
+
async validateCredentials() {
|
|
42
|
+
return { ok: true, checkedAt: "t" };
|
|
43
|
+
}
|
|
44
|
+
async getCatalog() {
|
|
45
|
+
return {
|
|
46
|
+
retrievedAt: "t",
|
|
47
|
+
gpus: [{ id: "a40", name: "A40", memoryGb: 48, priceUsdPerHour: 0.6, availability: this.gpuAvailable ? "LOW" : "NONE", dataCenters: [{ id: "DC-1", availability: this.gpuAvailable ? "LOW" : "NONE" }], cudaVersions: [] }],
|
|
48
|
+
cpus: [],
|
|
49
|
+
dataCenters: [{ id: "DC-1", region: "EU", networkVolumeTypes: ["STANDARD"] }],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async createVolume(request) {
|
|
53
|
+
const volume = { id: `vol${this.nextId++}`, name: request.name, sizeGb: request.sizeGb, dataCenterId: request.dataCenterId, type: "STANDARD", cacheReady: false };
|
|
54
|
+
this.volumes.set(volume.id, volume);
|
|
55
|
+
this.log.push(`createVolume ${volume.id}`);
|
|
56
|
+
return volume;
|
|
57
|
+
}
|
|
58
|
+
async createPod(request) {
|
|
59
|
+
if (request.gpu && !this.gpuAvailable)
|
|
60
|
+
throw Object.assign(new Error("no capacity"), { code: "CLOUD_CAPACITY_UNAVAILABLE" });
|
|
61
|
+
const id = `pod${this.nextId++}`;
|
|
62
|
+
const role = request.gpu ? "gpu" : "cpu";
|
|
63
|
+
this.log.push(`createPod ${role} ${id}`);
|
|
64
|
+
const volumeId = request.volume?.id;
|
|
65
|
+
const pipeline = async ({ job, outputDir, log, setStage }) => {
|
|
66
|
+
const volume = volumeId ? this.volumes.get(volumeId) : undefined;
|
|
67
|
+
if (job.mode === "prepare-assets") {
|
|
68
|
+
setStage("preparing_assets");
|
|
69
|
+
volume.cacheReady = true;
|
|
70
|
+
return { exitCode: 0, runtime: { dependencyFreeze: [], cache: { checksumManifestSha256: "c".repeat(64), sizeBytes: 123, fileCount: 2, reused: false } } };
|
|
71
|
+
}
|
|
72
|
+
setStage("preparing_assets");
|
|
73
|
+
if (volume && (!volume.cacheReady || this.forgetCache)) {
|
|
74
|
+
this.forgetCache = false;
|
|
75
|
+
volume.cacheReady = false;
|
|
76
|
+
throw Object.assign(new Error("no cache"), { code: "CLOUD_CACHE_MISSING" });
|
|
77
|
+
}
|
|
78
|
+
setStage("running");
|
|
79
|
+
log("stdout", "predicting\n");
|
|
80
|
+
fs.writeFileSync(path.join(outputDir, "a.out"), "result");
|
|
81
|
+
return { exitCode: 0, runtime: { dependencyFreeze: ["torch==2"] } };
|
|
82
|
+
};
|
|
83
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk fleet "));
|
|
84
|
+
dirs.push(rootDir);
|
|
85
|
+
const worker = createWorkerServer({ token: request.env.MOLDESK_WORKER_TOKEN, rootDir, pipeline });
|
|
86
|
+
const port = await worker.listen(0, "127.0.0.1");
|
|
87
|
+
const pod = { id, name: request.name, status: this.podStatus, dataCenterId: request.dataCenterId, costUsdPerHour: 0.6, createdAt: null };
|
|
88
|
+
this.pods.set(id, { pod, worker, endpoint: `http://127.0.0.1:${port}`, request });
|
|
89
|
+
return pod;
|
|
90
|
+
}
|
|
91
|
+
async getPod(id) {
|
|
92
|
+
return this.pods.get(id)?.pod ?? null;
|
|
93
|
+
}
|
|
94
|
+
async listPods() {
|
|
95
|
+
return [...this.pods.values()].map((entry) => entry.pod);
|
|
96
|
+
}
|
|
97
|
+
async terminatePod(id) {
|
|
98
|
+
if (this.terminateFails)
|
|
99
|
+
throw new Error("provider down");
|
|
100
|
+
this.log.push(`terminate ${id}`);
|
|
101
|
+
const entry = this.pods.get(id);
|
|
102
|
+
if (entry)
|
|
103
|
+
await entry.worker.close();
|
|
104
|
+
this.pods.delete(id);
|
|
105
|
+
return { id, alreadyAbsent: !entry };
|
|
106
|
+
}
|
|
107
|
+
async getVolume(id) {
|
|
108
|
+
return this.volumes.get(id) ?? null;
|
|
109
|
+
}
|
|
110
|
+
async listVolumes() {
|
|
111
|
+
return [...this.volumes.values()];
|
|
112
|
+
}
|
|
113
|
+
async deleteVolume(id) {
|
|
114
|
+
this.log.push(`deleteVolume ${id}`);
|
|
115
|
+
this.volumes.delete(id);
|
|
116
|
+
return { id, alreadyAbsent: false };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function memoryTokens() {
|
|
120
|
+
const data = new Map();
|
|
121
|
+
return { data, async set(id, token) { data.set(id, token); }, async get(id) { return data.get(id) ?? null; }, async delete(id) { data.delete(id); } };
|
|
122
|
+
}
|
|
123
|
+
function setup(overrides = {}) {
|
|
124
|
+
const fleet = new Fleet();
|
|
125
|
+
fleets.push(fleet);
|
|
126
|
+
const tokens = memoryTokens();
|
|
127
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk backend "));
|
|
128
|
+
dirs.push(home);
|
|
129
|
+
const journal = CloudJournal.openFile(path.join(home, "j.sqlite"));
|
|
130
|
+
const backend = new RunpodExecutionBackend({
|
|
131
|
+
provider: fleet,
|
|
132
|
+
tokenStore: tokens,
|
|
133
|
+
...(overrides.bundle === false ? {} : { workerBundle: { url: "https://example.com/worker.tgz", sha256: "d".repeat(64) } }),
|
|
134
|
+
endpointFor: (id) => fleet.pods.get(id).endpoint,
|
|
135
|
+
createClient: (endpoint, token) => new WorkerClient({ endpoint, token, allowInsecureLoopback: true, sleep: async () => undefined }),
|
|
136
|
+
sleep: async () => undefined,
|
|
137
|
+
terminationWaitMs: 100,
|
|
138
|
+
pollIntervalMs: 1,
|
|
139
|
+
});
|
|
140
|
+
const runId = (n) => `2026-10-13T11-55-45-123Z-0a1b2c3d-1111-2222-3333-44445555666${n}`;
|
|
141
|
+
const inputPath = path.join(home, "in.txt");
|
|
142
|
+
fs.writeFileSync(inputPath, "job");
|
|
143
|
+
const job = (n, patch = {}) => ({
|
|
144
|
+
version: 1,
|
|
145
|
+
runId: runId(n),
|
|
146
|
+
model: "m",
|
|
147
|
+
modelVersion: "1.0.0",
|
|
148
|
+
manifestDigest: "md",
|
|
149
|
+
cachePolicy: "ephemeral",
|
|
150
|
+
gpuId: "a40",
|
|
151
|
+
datacenterId: "DC-1",
|
|
152
|
+
images: manifest.cloud.images,
|
|
153
|
+
containerDiskGb: 40,
|
|
154
|
+
volumeGb: 40,
|
|
155
|
+
assetLayoutRevision: "1",
|
|
156
|
+
cpuAssetPreparation: true,
|
|
157
|
+
cacheKey: "k".repeat(64),
|
|
158
|
+
params: {},
|
|
159
|
+
inputs: [{ role: "job", relativePath: "in.txt", sha256: "3f7b3f21d1d8f2fe8f9f3a3dc5f0c0ca7c3fbb5e0cf0c7f9ad7f3b4d5d9c3d3c", sizeBytes: 3, sourcePath: inputPath }],
|
|
160
|
+
deadlineAt: "2099-01-01T00:00:00.000Z",
|
|
161
|
+
...patch,
|
|
162
|
+
});
|
|
163
|
+
// Real sha so the worker's upload verification passes.
|
|
164
|
+
const realSha = require("node:crypto").createHash("sha256").update("job").digest("hex");
|
|
165
|
+
const ctx = (n, persistent) => ({
|
|
166
|
+
ledger: {
|
|
167
|
+
beginCreate: (role) => {
|
|
168
|
+
const intent = journal.recordResourceIntent({ ...(role === "volume" ? {} : { runId: runId(n) }), role: role, deadlineAt: "2099-01-01T00:00:00.000Z" });
|
|
169
|
+
return { intentId: intent.intentId, name: intent.name };
|
|
170
|
+
},
|
|
171
|
+
created: (intentId, providerId) => void journal.recordResourceCreated(intentId, providerId),
|
|
172
|
+
beginDelete: (intentId) => void journal.markResourceDeletePending(intentId),
|
|
173
|
+
deleted: (intentId) => void journal.markResourceDeleted(intentId),
|
|
174
|
+
},
|
|
175
|
+
...(persistent ? { cache: cacheLedgerFor(journal, { accountScope: "acct", runId: runId(n), manifest, cloud: manifest.cloud, cacheKey: "k".repeat(64), manifestDigest: "md" }) } : {}),
|
|
176
|
+
});
|
|
177
|
+
const withSha = (j) => ({ ...j, inputs: j.inputs.map((file) => ({ ...file, sha256: realSha })) });
|
|
178
|
+
return { fleet, tokens, journal, backend, job: (n, patch = {}) => withSha(job(n, patch)), ctx, runId };
|
|
179
|
+
}
|
|
180
|
+
async function finish(backend, handle) {
|
|
181
|
+
for (let i = 0; i < 400; i += 1) {
|
|
182
|
+
const snapshot = await backend.observe(handle);
|
|
183
|
+
if (snapshot.state !== "running")
|
|
184
|
+
return snapshot;
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
186
|
+
}
|
|
187
|
+
throw new Error("did not finish");
|
|
188
|
+
}
|
|
189
|
+
afterEach(async () => {
|
|
190
|
+
for (const fleet of fleets.splice(0))
|
|
191
|
+
for (const entry of fleet.pods.values())
|
|
192
|
+
await entry.worker.close();
|
|
193
|
+
for (const dir of dirs.splice(0))
|
|
194
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
195
|
+
});
|
|
196
|
+
describe("RunpodExecutionBackend (ephemeral)", () => {
|
|
197
|
+
it("provisions a GPU pod, uploads, runs, collects and terminates with confirmation", async () => {
|
|
198
|
+
const box = setup();
|
|
199
|
+
const handle = await box.backend.start(box.job(1), box.ctx(1, false));
|
|
200
|
+
expect(box.fleet.pods.get(handle.podId).request).toMatchObject({
|
|
201
|
+
image: manifest.cloud.images.gpu,
|
|
202
|
+
gpu: { id: "a40", count: 1 },
|
|
203
|
+
ports: ["8000/http"],
|
|
204
|
+
entrypoint: ["/bin/bash", "-c"],
|
|
205
|
+
});
|
|
206
|
+
expect(box.fleet.pods.get(handle.podId).request.env).toMatchObject({ MOLDESK_WORKER_DEADLINE_AT: "2099-01-01T00:00:00.000Z", MOLDESK_WORKER_BUNDLE_SHA256: "d".repeat(64) });
|
|
207
|
+
expect(box.tokens.data.get(handle.runId)).toMatch(/^[0-9a-f]{64}$/);
|
|
208
|
+
expect((await finish(box.backend, handle)).state).toBe("succeeded");
|
|
209
|
+
const out = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk out "));
|
|
210
|
+
dirs.push(out);
|
|
211
|
+
await box.backend.collect(handle, out);
|
|
212
|
+
expect(fs.readFileSync(path.join(out, "a.out"), "utf8")).toBe("result");
|
|
213
|
+
await box.backend.release(handle, box.ctx(1, false));
|
|
214
|
+
expect(box.fleet.pods.size).toBe(0);
|
|
215
|
+
expect(box.tokens.data.size).toBe(0);
|
|
216
|
+
expect(box.journal.listResources({ states: ["create_pending", "active", "delete_pending"] })).toHaveLength(0);
|
|
217
|
+
});
|
|
218
|
+
it("refuses to start without a configured worker bundle and creates nothing", async () => {
|
|
219
|
+
const box = setup({ bundle: false });
|
|
220
|
+
await expect(box.backend.start(box.job(1), box.ctx(1, false))).rejects.toMatchObject({ code: "CLOUD_WORKER_BUNDLE_UNCONFIGURED" });
|
|
221
|
+
expect(box.fleet.log).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
it("stops waiting and surfaces a boot failure when the pod enters ERROR, leaving it releasable", async () => {
|
|
224
|
+
const box = setup();
|
|
225
|
+
box.fleet.podStatus = "ERROR";
|
|
226
|
+
// Make the worker unreachable so readiness polling consults the pod status.
|
|
227
|
+
const original = box.fleet.createPod.bind(box.fleet);
|
|
228
|
+
box.fleet.createPod = async (request) => {
|
|
229
|
+
const pod = await original(request);
|
|
230
|
+
await box.fleet.pods.get(pod.id).worker.close();
|
|
231
|
+
return pod;
|
|
232
|
+
};
|
|
233
|
+
await expect(box.backend.start(box.job(1), box.ctx(1, false))).rejects.toMatchObject({ code: "CLOUD_WORKER_BOOT_FAILED" });
|
|
234
|
+
await box.backend.release({ runId: box.job(1).runId }, box.ctx(1, false));
|
|
235
|
+
expect(box.fleet.pods.size).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
it("does not mark a pod deleted when the provider cannot terminate it", async () => {
|
|
238
|
+
const box = setup();
|
|
239
|
+
const handle = await box.backend.start(box.job(1), box.ctx(1, false));
|
|
240
|
+
box.fleet.terminateFails = true;
|
|
241
|
+
await box.backend.release(handle, box.ctx(1, false));
|
|
242
|
+
expect(box.fleet.pods.size).toBe(1);
|
|
243
|
+
expect(box.journal.listResources({ states: ["delete_pending"] })).toHaveLength(1);
|
|
244
|
+
});
|
|
245
|
+
it("clamps the readiness timeout to at least five minutes", () => {
|
|
246
|
+
expect(MIN_WORKER_READY_TIMEOUT_MS).toBe(300_000);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
describe("RunpodExecutionBackend (persistent)", () => {
|
|
250
|
+
const persistent = { cachePolicy: "persistent" };
|
|
251
|
+
it("cold: prepares on CPU, terminates the CPU pod before any GPU exists, retains the volume, marks the cache READY", async () => {
|
|
252
|
+
const box = setup();
|
|
253
|
+
const handle = await box.backend.start(box.job(1, persistent), box.ctx(1, true));
|
|
254
|
+
expect(box.fleet.log).toEqual(["createVolume vol1", "createPod cpu pod2", "terminate pod2", expect.stringMatching(/^createPod gpu pod3$/)]);
|
|
255
|
+
expect(handle.volumeId).toBe("vol1");
|
|
256
|
+
expect((await finish(box.backend, handle)).state).toBe("succeeded");
|
|
257
|
+
await box.backend.release(handle, box.ctx(1, true));
|
|
258
|
+
expect(box.fleet.pods.size).toBe(0);
|
|
259
|
+
expect(box.fleet.volumes.has("vol1")).toBe(true); // volume survives ordinary cleanup
|
|
260
|
+
expect(box.fleet.log).not.toContain("deleteVolume vol1");
|
|
261
|
+
const cache = box.journal.listCaches()[0];
|
|
262
|
+
expect(cache).toMatchObject({ state: "READY", volumeId: "vol1", sizeBytes: 123, checksumManifestDigest: "c".repeat(64), leaseOwner: null });
|
|
263
|
+
});
|
|
264
|
+
it("warm: a second run reuses the volume with zero CPU pods and no new volume", async () => {
|
|
265
|
+
const box = setup();
|
|
266
|
+
const first = await box.backend.start(box.job(1, persistent), box.ctx(1, true));
|
|
267
|
+
await finish(box.backend, first);
|
|
268
|
+
await box.backend.release(first, box.ctx(1, true));
|
|
269
|
+
box.fleet.log.length = 0;
|
|
270
|
+
const second = await box.backend.start(box.job(2, persistent), box.ctx(2, true));
|
|
271
|
+
expect(box.fleet.log).toEqual([expect.stringMatching(/^createPod gpu /)]);
|
|
272
|
+
expect((await finish(box.backend, second)).state).toBe("succeeded");
|
|
273
|
+
await box.backend.release(second, box.ctx(2, true));
|
|
274
|
+
expect(box.fleet.volumes.size).toBe(1);
|
|
275
|
+
});
|
|
276
|
+
it("warm but the volume lost its cache: the GPU pod is dropped, the CPU rebuilds it, then a fresh GPU runs", async () => {
|
|
277
|
+
const box = setup();
|
|
278
|
+
const first = await box.backend.start(box.job(1, persistent), box.ctx(1, true));
|
|
279
|
+
await finish(box.backend, first);
|
|
280
|
+
await box.backend.release(first, box.ctx(1, true));
|
|
281
|
+
box.fleet.log.length = 0;
|
|
282
|
+
box.fleet.forgetCache = true;
|
|
283
|
+
const second = await box.backend.start(box.job(2, persistent), box.ctx(2, true));
|
|
284
|
+
expect(box.fleet.log.map((line) => line.split(" ").slice(0, 2).join(" "))).toEqual(["createPod gpu", "terminate pod4", "createPod cpu", "terminate pod5", "createPod gpu"].map((line, index) => (index === 1 || index === 3 ? line.split(" ").slice(0, 2).join(" ") : line)));
|
|
285
|
+
expect((await finish(box.backend, second)).state).toBe("succeeded");
|
|
286
|
+
expect(box.journal.listCaches()[0].state).toBe("READY");
|
|
287
|
+
await box.backend.release(second, box.ctx(2, true));
|
|
288
|
+
expect(box.fleet.pods.size).toBe(0);
|
|
289
|
+
});
|
|
290
|
+
it("loses GPU capacity after preparation: the CPU pod is gone, the volume and READY cache are retained, with guidance", async () => {
|
|
291
|
+
const box = setup();
|
|
292
|
+
const originalCatalog = box.fleet.getCatalog.bind(box.fleet);
|
|
293
|
+
box.fleet.getCatalog = async () => {
|
|
294
|
+
const catalog = await originalCatalog();
|
|
295
|
+
return box.fleet.log.some((line) => line.startsWith("terminate")) ? { ...catalog, gpus: catalog.gpus.map((gpu) => ({ ...gpu, availability: "NONE", dataCenters: [] })) } : catalog;
|
|
296
|
+
};
|
|
297
|
+
await expect(box.backend.start(box.job(1, persistent), box.ctx(1, true))).rejects.toMatchObject({ code: "CLOUD_CAPACITY_UNAVAILABLE" });
|
|
298
|
+
expect(box.fleet.pods.size).toBe(0);
|
|
299
|
+
expect(box.fleet.volumes.has("vol1")).toBe(true);
|
|
300
|
+
expect(box.journal.listCaches()[0].state).toBe("READY");
|
|
301
|
+
expect(box.fleet.log.filter((line) => line.startsWith("createPod gpu"))).toHaveLength(0);
|
|
302
|
+
});
|
|
303
|
+
it("refuses to prepare while another process holds the cache lease", async () => {
|
|
304
|
+
const box = setup();
|
|
305
|
+
const context = box.ctx(1, true);
|
|
306
|
+
context.cache.begin({ volumeId: "volX", datacenterId: "DC-1" });
|
|
307
|
+
expect(box.journal.acquireCacheLease({ accountScope: "acct", datacenterId: "DC-1", volumeId: "volX", cacheKey: "k".repeat(64) }, "someone-else", 600)).toBe(true);
|
|
308
|
+
const job = box.job(1, { ...persistent, reuseVolumeId: "volX" });
|
|
309
|
+
await expect(box.backend.start(job, context)).rejects.toMatchObject({ code: "CLOUD_CACHE_LOCKED" });
|
|
310
|
+
expect(box.fleet.pods.size).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
it("terminates the CPU pod even when preparation fails and leaves the cache FAILED, never READY", async () => {
|
|
313
|
+
const box = setup();
|
|
314
|
+
const originalCreate = box.fleet.createPod.bind(box.fleet);
|
|
315
|
+
box.fleet.createPod = async (request) => {
|
|
316
|
+
const pod = await originalCreate(request);
|
|
317
|
+
if (!request.gpu) {
|
|
318
|
+
const entry = box.fleet.pods.get(pod.id);
|
|
319
|
+
await entry.worker.close();
|
|
320
|
+
const volumeId = request.volume.id;
|
|
321
|
+
const worker = createWorkerServer({
|
|
322
|
+
token: request.env.MOLDESK_WORKER_TOKEN,
|
|
323
|
+
rootDir: fs.mkdtempSync(path.join(os.tmpdir(), "moldesk fail ")),
|
|
324
|
+
pipeline: async () => {
|
|
325
|
+
void volumeId;
|
|
326
|
+
throw Object.assign(new Error("disk full"), { code: "CLOUD_ASSET_PREPARATION_FAILED" });
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
const port = await worker.listen(0, "127.0.0.1");
|
|
330
|
+
box.fleet.pods.set(pod.id, { ...entry, worker, endpoint: `http://127.0.0.1:${port}` });
|
|
331
|
+
}
|
|
332
|
+
return pod;
|
|
333
|
+
};
|
|
334
|
+
await expect(box.backend.start(box.job(1, persistent), box.ctx(1, true))).rejects.toMatchObject({ code: "CLOUD_ASSET_PREPARATION_FAILED" });
|
|
335
|
+
expect(box.fleet.pods.size).toBe(0);
|
|
336
|
+
expect(box.journal.listCaches()[0].state).toBe("FAILED");
|
|
337
|
+
expect(box.fleet.log.filter((line) => line.startsWith("createPod gpu"))).toHaveLength(0);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { CredentialStore, type MoldeskPaths, type TokenStore, type WorkerBundle } from "@moldesk/runtime";
|
|
2
|
+
import type { CloudRunDeps } from "./run.js";
|
|
3
|
+
export interface RunpodDepsOptions {
|
|
4
|
+
paths?: MoldeskPaths;
|
|
5
|
+
store?: CredentialStore;
|
|
6
|
+
tokenStore?: TokenStore;
|
|
7
|
+
workerBundle?: WorkerBundle;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Production wiring for Runpod: key from the credential store (read per request), account scope from
|
|
12
|
+
* a keyed credential fingerprint and per-job tokens in the keyring. There is no independent
|
|
13
|
+
* lifetime scheduler: shutdown is enforced by this client while connected and by the in-Pod watchdog.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createRunpodCloudDeps(options?: RunpodDepsOptions): Promise<CloudRunDeps>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CredentialStore, RunpodExecutionBackend, RunpodProvider, createKeyringTokenStore, credentialFingerprint, getMoldeskPaths, } from "@moldesk/runtime";
|
|
2
|
+
import { CloudJournal } from "./state.js";
|
|
3
|
+
import { resolveWorkerBundle } from "./worker-release.js";
|
|
4
|
+
/**
|
|
5
|
+
* Production wiring for Runpod: key from the credential store (read per request), account scope from
|
|
6
|
+
* a keyed credential fingerprint and per-job tokens in the keyring. There is no independent
|
|
7
|
+
* lifetime scheduler: shutdown is enforced by this client while connected and by the in-Pod watchdog.
|
|
8
|
+
*/
|
|
9
|
+
export async function createRunpodCloudDeps(options = {}) {
|
|
10
|
+
const paths = options.paths ?? getMoldeskPaths();
|
|
11
|
+
const store = options.store ?? new CredentialStore();
|
|
12
|
+
const credential = await store.require("runpod");
|
|
13
|
+
const journal = CloudJournal.open(paths);
|
|
14
|
+
const accountScope = (() => {
|
|
15
|
+
try {
|
|
16
|
+
return credentialFingerprint(credential.key, journal.clientId);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
journal.close();
|
|
20
|
+
}
|
|
21
|
+
})();
|
|
22
|
+
const provider = new RunpodProvider({ getApiKey: async () => (await store.require("runpod")).key });
|
|
23
|
+
const bundle = options.workerBundle ?? resolveWorkerBundle(options.env);
|
|
24
|
+
const backend = new RunpodExecutionBackend({
|
|
25
|
+
provider,
|
|
26
|
+
tokenStore: options.tokenStore ?? createKeyringTokenStore(),
|
|
27
|
+
...(bundle ? { workerBundle: bundle } : {}),
|
|
28
|
+
});
|
|
29
|
+
return { provider, paths, backend, accountScope };
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type ExecutionBackend } from "@moldesk/runtime";
|
|
2
|
+
import type { RunModelResult, RunRecord } from "../run.js";
|
|
3
|
+
import { type CleanupOutcome, type CloudRunDeps } from "./run.js";
|
|
4
|
+
import { type CloudCacheRecord } from "./state.js";
|
|
5
|
+
export type RecoverDeps = Pick<CloudRunDeps, "provider" | "paths" | "resolveModel" | "sleep" | "cleanupAttempts"> & {
|
|
6
|
+
backend?: ExecutionBackend;
|
|
7
|
+
};
|
|
8
|
+
export interface RecoverOptions {
|
|
9
|
+
/** Terminate even if the worker still reports the job as running (stops further rental; loses unfinished results). */
|
|
10
|
+
terminateRunning?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface RecoverResult {
|
|
13
|
+
runId: string;
|
|
14
|
+
/** Results were downloaded and verified during recovery. */
|
|
15
|
+
collected: boolean;
|
|
16
|
+
cleanup: CleanupOutcome;
|
|
17
|
+
/** Present when a previously failed run became a verified success. */
|
|
18
|
+
result?: RunModelResult;
|
|
19
|
+
status: RunRecord["status"];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resumes cleanup (and, when possible, result transfer) for a cloud run after a crash, cancellation
|
|
23
|
+
* or transfer failure. It never starts inference again: the only paid actions it can take are
|
|
24
|
+
* downloading from a Pod that already exists and terminating Pods this installation owns.
|
|
25
|
+
*/
|
|
26
|
+
export declare function recoverCloudRun(runId: string, deps: RecoverDeps, options?: RecoverOptions): Promise<RecoverResult>;
|
|
27
|
+
export interface CloudCacheListing {
|
|
28
|
+
cache: CloudCacheRecord;
|
|
29
|
+
/** Whether the provider still has the volume (a deleted volume means the cache is gone). */
|
|
30
|
+
volumeExists: boolean | undefined;
|
|
31
|
+
volumeSizeGb?: number;
|
|
32
|
+
}
|
|
33
|
+
export declare function listCloudCaches(deps: Pick<CloudRunDeps, "provider" | "paths" | "accountScope">): Promise<CloudCacheListing[]>;
|
|
34
|
+
export interface CacheDeletionPlan {
|
|
35
|
+
model: string;
|
|
36
|
+
/** Exactly what will be removed. Nothing else is touched. */
|
|
37
|
+
volumes: Array<{
|
|
38
|
+
volumeId: string;
|
|
39
|
+
datacenterId: string;
|
|
40
|
+
sizeGb?: number;
|
|
41
|
+
cacheKeys: string[];
|
|
42
|
+
}>;
|
|
43
|
+
/** Reasons deletion cannot proceed right now. */
|
|
44
|
+
blockers: string[];
|
|
45
|
+
}
|
|
46
|
+
export declare function planCloudCacheDeletion(model: string, deps: Pick<CloudRunDeps, "provider" | "paths" | "accountScope">): Promise<CacheDeletionPlan>;
|
|
47
|
+
export interface CacheDeletionResult {
|
|
48
|
+
deleted: Array<{
|
|
49
|
+
volumeId: string;
|
|
50
|
+
}>;
|
|
51
|
+
failed: Array<{
|
|
52
|
+
volumeId: string;
|
|
53
|
+
reason: string;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
/** Deletes exactly the volumes in a plan the caller already showed and had confirmed. Journal ownership is required. */
|
|
57
|
+
export declare function executeCloudCacheDeletion(plan: CacheDeletionPlan, deps: Pick<CloudRunDeps, "provider" | "paths" | "accountScope">): Promise<CacheDeletionResult>;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getMoldeskPaths, runDir } from "@moldesk/runtime";
|
|
4
|
+
import { MoldeskError } from "../errors.js";
|
|
5
|
+
import { collectRunOutputs, finalizeRun, writeRunRecord } from "../run-lifecycle.js";
|
|
6
|
+
import { confirmRunResourcesGone, defaultResolveModelForRecovery, recordLostLogs } from "./run.js";
|
|
7
|
+
import { CloudJournal } from "./state.js";
|
|
8
|
+
function failure(code, message, remediation, details) {
|
|
9
|
+
return new MoldeskError({ code, message, remediation, details });
|
|
10
|
+
}
|
|
11
|
+
function readRunRecord(paths, runId) {
|
|
12
|
+
const runDirPath = runDir(runId, { MOLDESK_HOME: paths.home });
|
|
13
|
+
const file = path.join(runDirPath, "run.json");
|
|
14
|
+
if (!fs.existsSync(file))
|
|
15
|
+
throw failure("CLOUD_RUN_UNKNOWN", `No run record for ${runId}.`, "Check the run id with `moldesk cloud status`.");
|
|
16
|
+
return { record: JSON.parse(fs.readFileSync(file, "utf8")), runDirPath };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resumes cleanup (and, when possible, result transfer) for a cloud run after a crash, cancellation
|
|
20
|
+
* or transfer failure. It never starts inference again: the only paid actions it can take are
|
|
21
|
+
* downloading from a Pod that already exists and terminating Pods this installation owns.
|
|
22
|
+
*/
|
|
23
|
+
export async function recoverCloudRun(runId, deps, options = {}) {
|
|
24
|
+
const paths = deps.paths ?? getMoldeskPaths();
|
|
25
|
+
const journal = CloudJournal.open(paths);
|
|
26
|
+
try {
|
|
27
|
+
const job = journal.getJob(runId);
|
|
28
|
+
if (!job)
|
|
29
|
+
throw failure("CLOUD_RUN_UNKNOWN", `No cloud job is recorded for ${runId}.`, "Only runs started with --compute runpod can be recovered.");
|
|
30
|
+
const { record, runDirPath } = readRunRecord(paths, runId);
|
|
31
|
+
let collected = false;
|
|
32
|
+
let result;
|
|
33
|
+
// --- Try to fetch results that were left on a still-running Pod. ---
|
|
34
|
+
const livePod = journal.listResources({ runId, states: ["active", "delete_pending"] }).find((resource) => resource.role === "gpu" && resource.providerId);
|
|
35
|
+
let stillRunning = false;
|
|
36
|
+
if (record.status !== "succeeded" && livePod?.providerId && deps.backend?.reconnect) {
|
|
37
|
+
const handle = deps.backend.reconnect(runId, livePod.providerId, job.volumeId ?? undefined);
|
|
38
|
+
try {
|
|
39
|
+
const snapshot = await deps.backend.observe(handle, record.compute?.workerLogCursor ?? 0);
|
|
40
|
+
// Keep the local logs complete: append whatever the worker produced since the last saved cursor.
|
|
41
|
+
if (record.compute)
|
|
42
|
+
recordLostLogs(snapshot, { stdoutPath: record.logs.stdout, stderrPath: record.logs.stderr, compute: record.compute, record });
|
|
43
|
+
if (snapshot.logs.stdout)
|
|
44
|
+
fs.appendFileSync(record.logs.stdout, snapshot.logs.stdout);
|
|
45
|
+
if (snapshot.logs.stderr)
|
|
46
|
+
fs.appendFileSync(record.logs.stderr, snapshot.logs.stderr);
|
|
47
|
+
if (record.compute)
|
|
48
|
+
record.compute.workerLogCursor = snapshot.logs.cursor;
|
|
49
|
+
if (snapshot.state === "succeeded") {
|
|
50
|
+
const outputDir = path.join(runDirPath, "output");
|
|
51
|
+
await deps.backend.collect(handle, outputDir);
|
|
52
|
+
const resolved = (deps.resolveModel ?? defaultResolveModelForRecovery)(record.model);
|
|
53
|
+
const input = record.input[0];
|
|
54
|
+
const context = {
|
|
55
|
+
manifestName: record.model,
|
|
56
|
+
inputPath: input.storedPath,
|
|
57
|
+
outputDir,
|
|
58
|
+
modelDir: path.join(runDirPath, "remote-model"),
|
|
59
|
+
assetsDir: path.join(runDirPath, "remote-assets"),
|
|
60
|
+
params: record.parameters.effective,
|
|
61
|
+
runtimeExecutable: "",
|
|
62
|
+
platform: "linux-x64",
|
|
63
|
+
};
|
|
64
|
+
record.outputs = await collectRunOutputs(resolved.adapter, context);
|
|
65
|
+
record.status = "succeeded";
|
|
66
|
+
delete record.error;
|
|
67
|
+
journal.updateJob(runId, { transferState: "verified" });
|
|
68
|
+
collected = true;
|
|
69
|
+
result = { status: "succeeded", record };
|
|
70
|
+
}
|
|
71
|
+
else if (snapshot.state === "running") {
|
|
72
|
+
stillRunning = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Still unreachable or incomplete: fall through to cleanup; the failure record stays as is.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// A job that is genuinely still running keeps its (already approved) rental unless told otherwise.
|
|
80
|
+
if (stillRunning && !options.terminateRunning) {
|
|
81
|
+
return { runId, collected: false, cleanup: { confirmed: false, pending: [{ role: "gpu", providerId: livePod.providerId }] }, status: record.status };
|
|
82
|
+
}
|
|
83
|
+
// A run interrupted before it finished must not stay "running" forever.
|
|
84
|
+
if (record.status === "preparing" || record.status === "running") {
|
|
85
|
+
const finishedAt = new Date().toISOString();
|
|
86
|
+
record.status = "failed";
|
|
87
|
+
record.error = { code: "CLOUD_RUN_INTERRUPTED", message: "The run was interrupted before it finished; recovery cleaned up its cloud resources." };
|
|
88
|
+
record.finishedAt = finishedAt;
|
|
89
|
+
record.durationMs = Date.parse(finishedAt) - Date.parse(record.startedAt);
|
|
90
|
+
}
|
|
91
|
+
// --- Cleanup: terminate and verify only what the journal says this installation owns. ---
|
|
92
|
+
const cleanup = await confirmRunResourcesGone(runId, deps, journal);
|
|
93
|
+
if (record.compute)
|
|
94
|
+
record.compute.cleanup = cleanup.confirmed ? "confirmed" : "pending";
|
|
95
|
+
journal.updateJob(runId, {
|
|
96
|
+
stage: "done",
|
|
97
|
+
status: cleanup.confirmed ? (record.status === "succeeded" ? "succeeded" : record.status === "cancelled" ? "cancelled" : "failed") : "cleanup_pending",
|
|
98
|
+
error: cleanup.confirmed ? record.error ?? null : { code: "CLOUD_CLEANUP_PENDING", message: "Paid resources could not be confirmed deleted." },
|
|
99
|
+
});
|
|
100
|
+
if (collected && result) {
|
|
101
|
+
const resolved = (deps.resolveModel ?? defaultResolveModelForRecovery)(record.model);
|
|
102
|
+
const input = record.input[0];
|
|
103
|
+
result = await finalizeRun({
|
|
104
|
+
adapter: resolved.adapter,
|
|
105
|
+
context: {
|
|
106
|
+
manifestName: record.model,
|
|
107
|
+
inputPath: input.storedPath,
|
|
108
|
+
outputDir: path.join(runDirPath, "output"),
|
|
109
|
+
modelDir: path.join(runDirPath, "remote-model"),
|
|
110
|
+
assetsDir: path.join(runDirPath, "remote-assets"),
|
|
111
|
+
params: record.parameters.effective,
|
|
112
|
+
runtimeExecutable: "",
|
|
113
|
+
platform: "linux-x64",
|
|
114
|
+
},
|
|
115
|
+
runDirPath,
|
|
116
|
+
record,
|
|
117
|
+
startedAt: record.startedAt,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
writeRunRecord(runDirPath, record);
|
|
122
|
+
}
|
|
123
|
+
return { runId, collected, cleanup, ...(result ? { result } : {}), status: record.status };
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
journal.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export async function listCloudCaches(deps) {
|
|
130
|
+
const journal = CloudJournal.open(deps.paths ?? getMoldeskPaths());
|
|
131
|
+
try {
|
|
132
|
+
const volumes = await deps.provider.listVolumes().catch(() => undefined);
|
|
133
|
+
const byId = new Map((volumes ?? []).map((volume) => [volume.id, volume]));
|
|
134
|
+
return journal
|
|
135
|
+
.listCaches()
|
|
136
|
+
.filter((cache) => !deps.accountScope || cache.accountScope === deps.accountScope)
|
|
137
|
+
.map((cache) => ({
|
|
138
|
+
cache,
|
|
139
|
+
volumeExists: volumes ? byId.has(cache.volumeId) : undefined,
|
|
140
|
+
...(byId.get(cache.volumeId) ? { volumeSizeGb: byId.get(cache.volumeId).sizeGb } : {}),
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
journal.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export async function planCloudCacheDeletion(model, deps) {
|
|
148
|
+
const journal = CloudJournal.open(deps.paths ?? getMoldeskPaths());
|
|
149
|
+
try {
|
|
150
|
+
const caches = journal.listCaches().filter((cache) => cache.model === model && (!deps.accountScope || cache.accountScope === deps.accountScope));
|
|
151
|
+
const volumes = new Map();
|
|
152
|
+
const volumeList = await deps.provider.listVolumes();
|
|
153
|
+
for (const cache of caches) {
|
|
154
|
+
const entry = volumes.get(cache.volumeId) ?? { volumeId: cache.volumeId, datacenterId: cache.datacenterId, sizeGb: volumeList.find((volume) => volume.id === cache.volumeId)?.sizeGb, cacheKeys: [] };
|
|
155
|
+
entry.cacheKeys.push(cache.cacheKey);
|
|
156
|
+
volumes.set(cache.volumeId, entry);
|
|
157
|
+
}
|
|
158
|
+
const blockers = [];
|
|
159
|
+
for (const job of journal.listOpenJobs()) {
|
|
160
|
+
if (job.volumeId && volumes.has(job.volumeId))
|
|
161
|
+
blockers.push(`Run ${job.runId} is still using volume ${job.volumeId} (${job.stage}, ${job.status}).`);
|
|
162
|
+
}
|
|
163
|
+
// A volume other caches also live on (another model) must not be deleted from under them.
|
|
164
|
+
for (const volumeId of volumes.keys()) {
|
|
165
|
+
const sharedBy = journal.listCaches().filter((cache) => cache.volumeId === volumeId && cache.model !== model);
|
|
166
|
+
if (sharedBy.length > 0)
|
|
167
|
+
blockers.push(`Volume ${volumeId} also holds caches for: ${[...new Set(sharedBy.map((cache) => cache.model))].join(", ")}.`);
|
|
168
|
+
}
|
|
169
|
+
return { model, volumes: [...volumes.values()], blockers };
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
journal.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Deletes exactly the volumes in a plan the caller already showed and had confirmed. Journal ownership is required. */
|
|
176
|
+
export async function executeCloudCacheDeletion(plan, deps) {
|
|
177
|
+
if (plan.blockers.length > 0)
|
|
178
|
+
throw failure("CLOUD_CACHE_IN_USE", plan.blockers.join(" "), "Wait for active runs to finish, or delete the other caches first.", { blockers: plan.blockers });
|
|
179
|
+
const journal = CloudJournal.open(deps.paths ?? getMoldeskPaths());
|
|
180
|
+
const result = { deleted: [], failed: [] };
|
|
181
|
+
try {
|
|
182
|
+
for (const volume of plan.volumes) {
|
|
183
|
+
const owner = journal.findOwnedByProviderId(volume.volumeId);
|
|
184
|
+
if (!owner || owner.role !== "volume") {
|
|
185
|
+
result.failed.push({ volumeId: volume.volumeId, reason: "This installation did not create that volume, so it will not delete it." });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
journal.markResourceDeletePending(owner.intentId);
|
|
190
|
+
await deps.provider.deleteVolume(volume.volumeId);
|
|
191
|
+
if ((await deps.provider.getVolume(volume.volumeId)) !== null) {
|
|
192
|
+
result.failed.push({ volumeId: volume.volumeId, reason: "The provider still lists the volume after deletion." });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
journal.markResourceDeleted(owner.intentId);
|
|
196
|
+
for (const key of volume.cacheKeys) {
|
|
197
|
+
journal.deleteCache({ accountScope: deps.accountScope ?? journal.listCaches().find((cache) => cache.volumeId === volume.volumeId)?.accountScope ?? "", datacenterId: volume.datacenterId, volumeId: volume.volumeId, cacheKey: key });
|
|
198
|
+
}
|
|
199
|
+
result.deleted.push({ volumeId: volume.volumeId });
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
result.failed.push({ volumeId: volume.volumeId, reason: error instanceof Error ? error.message : String(error) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
journal.close();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|