@opengeni/worker-bundle 0.28.1-canary.0 → 0.28.3-canary.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.
@@ -1,4 +1,6 @@
1
- import type { ObjectStorage } from "@opengeni/storage";
1
+ import { uploadWorkspaceArchiveSpool, type ObjectStorage } from "@opengeni/storage";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { VerifiedHostWorkspaceArchive } from "@opengeni/runtime/sandbox";
2
4
  import {
3
5
  parseWorkspaceArchiveObjectRef,
4
6
  workspaceArchiveObjectKey,
@@ -35,43 +37,102 @@ export async function putTarWorkspaceArchiveObject(input: {
35
37
  accountId: string;
36
38
  workspaceId: string;
37
39
  sandboxGroupId: string;
38
- archive: { bytes: Uint8Array; descriptor: WorkspaceArchiveDescriptor };
40
+ archive:
41
+ | { bytes: Uint8Array; descriptor: WorkspaceArchiveDescriptor }
42
+ | VerifiedHostWorkspaceArchive;
39
43
  }): Promise<WorkspaceArchiveObjectRef> {
40
- if (input.archive.descriptor.version !== 1) {
44
+ // Snapshot caller-owned metadata before any provider callback can mutate it.
45
+ const descriptor = structuredClone(input.archive.descriptor);
46
+ if (descriptor.version !== 1) {
41
47
  throw new Error("Object-storage workspace archives are portable tar only");
42
48
  }
43
49
  const key = workspaceArchiveObjectKey({
44
50
  accountId: input.accountId,
45
51
  workspaceId: input.workspaceId,
46
52
  sandboxGroupId: input.sandboxGroupId,
47
- revision: input.archive.descriptor.revision,
48
- });
49
- await input.objectStorage.putObject({
50
- key,
51
- contentType: "application/x-tar",
52
- body: input.archive.bytes,
53
- sha256: input.archive.descriptor.archiveSha256,
53
+ revision: descriptor.revision,
54
+ uploadId: randomUUID(),
54
55
  });
56
+ const archive = input.archive;
57
+ if ("spool" in archive) {
58
+ if (
59
+ archive.spool.byteSize !== descriptor.archiveBytes ||
60
+ archive.spool.sha256 !== descriptor.archiveSha256
61
+ ) {
62
+ throw new Error("Workspace archive spool does not match its verified descriptor");
63
+ }
64
+ }
65
+ const source =
66
+ "spool" in archive
67
+ ? archive.spool
68
+ : {
69
+ path: "",
70
+ byteSize: archive.bytes.byteLength,
71
+ sha256: descriptor.archiveSha256,
72
+ async *open() {
73
+ for (let offset = 0; offset < archive.bytes.length; offset += 1024 * 1024)
74
+ yield archive.bytes.subarray(offset, offset + 1024 * 1024);
75
+ },
76
+ async dispose() {},
77
+ };
78
+ try {
79
+ await uploadWorkspaceArchiveSpool(input.objectStorage, key, {
80
+ ...source,
81
+ byteSize: descriptor.archiveBytes,
82
+ sha256: descriptor.archiveSha256,
83
+ open: source.open.bind(source),
84
+ });
85
+ } catch (error) {
86
+ // This fresh locator has never been offered to database publication. An
87
+ // ambiguous provider upload can leave an orphan, never overwrite a retained
88
+ // archive or justify deleting another attempt's object.
89
+ await input.objectStorage.deleteObject(key).catch(() => undefined);
90
+ throw error;
91
+ }
55
92
  return {
56
93
  schema: "sandbox_archive_object_v1",
57
94
  key,
58
- sha256: input.archive.descriptor.archiveSha256,
59
- bytes: input.archive.descriptor.archiveBytes,
95
+ sha256: descriptor.archiveSha256,
96
+ bytes: descriptor.archiveBytes,
60
97
  backend: input.objectStorage.backend,
61
98
  };
62
99
  }
63
100
 
101
+ /** The caller owns a fresh candidate returned by this module. A thrown or lost
102
+ * database response is UNKNOWN: it may have committed, so never delete on catch.
103
+ * Only the transaction's exact candidate disposition can authorize cleanup. */
104
+ export async function persistWorkspaceArchiveCandidate<
105
+ T extends {
106
+ wrote: boolean;
107
+ candidateDisposition?: "adopted" | "already_referenced" | "unused";
108
+ },
109
+ >(input: {
110
+ objectStorage?: ObjectStorage | null;
111
+ ref?: WorkspaceArchiveObjectRef;
112
+ persist: () => Promise<T>;
113
+ }): Promise<T> {
114
+ const objectStorage = input.objectStorage;
115
+ const ref = input.ref ? { ...input.ref } : undefined;
116
+ const result = await input.persist();
117
+ if (objectStorage && ref && result.candidateDisposition === "unused") {
118
+ await deleteUnpublishedWorkspaceArchiveObject(objectStorage, ref);
119
+ }
120
+ return result;
121
+ }
122
+
64
123
  export async function putVersion1TarArchiveOrInline(input: {
65
124
  backend: string;
66
125
  objectStorage?: ObjectStorage | null | undefined;
67
126
  accountId: string;
68
127
  workspaceId: string;
69
128
  sandboxGroupId: string;
70
- archive: {
71
- bytes: Uint8Array;
72
- descriptor: WorkspaceArchiveDescriptor;
73
- base64: string;
74
- };
129
+ archive:
130
+ | VerifiedHostWorkspaceArchive
131
+ | {
132
+ bytes: Uint8Array;
133
+ descriptor: WorkspaceArchiveDescriptor;
134
+ base64: string;
135
+ };
75
136
  metrics?: {
76
137
  onWorkspaceArchiveObject?: (input: {
77
138
  outcome: "put" | "put_failed" | "deleted_unpublished";
@@ -83,12 +144,15 @@ export async function putVersion1TarArchiveOrInline(input: {
83
144
  workspaceArchiveRef?: WorkspaceArchiveObjectRef;
84
145
  }> {
85
146
  if (input.archive.descriptor.version !== 1) {
147
+ if ("spool" in input.archive) throw new Error("Native snapshots cannot use a portable spool");
86
148
  return { workspaceArchive: input.archive.base64 };
87
149
  }
88
150
  if (input.backend === "opensandbox" && !input.objectStorage) {
89
151
  throw new WorkspaceArchiveObjectStorageRequiredError("opensandbox");
90
152
  }
91
153
  if (!input.objectStorage) {
154
+ if ("spool" in input.archive)
155
+ throw new WorkspaceArchiveObjectStorageRequiredError(input.backend);
92
156
  return { workspaceArchive: input.archive.base64 };
93
157
  }
94
158
  try {
@@ -97,7 +161,7 @@ export async function putVersion1TarArchiveOrInline(input: {
97
161
  accountId: input.accountId,
98
162
  workspaceId: input.workspaceId,
99
163
  sandboxGroupId: input.sandboxGroupId,
100
- archive: { bytes: input.archive.bytes, descriptor: input.archive.descriptor },
164
+ archive: input.archive,
101
165
  });
102
166
  try {
103
167
  input.metrics?.onWorkspaceArchiveObject?.({
@@ -55,7 +55,9 @@ import {
55
55
  type LeaseHolderKind,
56
56
  } from "@opengeni/db";
57
57
  import {
58
- captureVerifiedWorkspaceArchive,
58
+ captureWorkspaceArchiveForStorage,
59
+ disposeWorkspaceArchive,
60
+ type VerifiedWorkspaceArchivePayload,
59
61
  describeLegacyNativeSnapshotArchive,
60
62
  inlineWorkspaceArchiveForRestore,
61
63
  MODAL_EXEC_READINESS_TIMEOUT_MS,
@@ -79,13 +81,15 @@ import {
79
81
  type RuntimeMetricsHooks,
80
82
  type WorkspaceArchiveDescriptor,
81
83
  } from "@opengeni/runtime";
82
- import type { ObjectStorage } from "@opengeni/storage";
84
+ import {
85
+ downloadWorkspaceArchiveSpool,
86
+ WorkspaceArchiveStorageError,
87
+ type ObjectStorage,
88
+ } from "@opengeni/storage";
83
89
  import type { Observability } from "@opengeni/observability";
84
90
  import { parseWorkspaceArchiveObjectRef } from "@opengeni/contracts";
85
91
  import {
86
- collectWorkspaceArchiveObjectKeys,
87
- deleteUnpublishedWorkspaceArchiveObject,
88
- deleteWorkspaceArchiveObjectKeys,
92
+ persistWorkspaceArchiveCandidate,
89
93
  putVersion1TarArchiveOrInline,
90
94
  } from "./sandbox-archive-storage";
91
95
 
@@ -582,6 +586,14 @@ async function materializeSpawnEnvelopeArchive(
582
586
  "workspace archive object storage is not configured",
583
587
  );
584
588
  }
589
+ const descriptor = parseWorkspaceArchiveDescriptor(sessionState.workspaceArchiveMeta);
590
+ if (
591
+ process.platform === "linux" &&
592
+ descriptor?.version === 1 &&
593
+ descriptor.workspace.projection === "sdk_local_archive_v1"
594
+ ) {
595
+ return envelope;
596
+ }
585
597
  return {
586
598
  ...record,
587
599
  sessionState: await inlineWorkspaceArchiveForRestore(sessionState, (key) =>
@@ -898,9 +910,10 @@ async function persistWarmWorkspaceSnapshot(
898
910
  // first (the bounded-wait race Bugbot flagged).
899
911
  const capturedAtMs = Date.now();
900
912
  const registerCandidate = async (
901
- archive: Awaited<ReturnType<typeof captureVerifiedWorkspaceArchive>>,
913
+ archive: VerifiedWorkspaceArchivePayload,
902
914
  ): Promise<{ id: string } | null> => {
903
915
  if (
916
+ archive.kind === "host_spool" ||
904
917
  archive.descriptor.version !== 2 ||
905
918
  (archive.descriptor.provider !== "modal_snapshot_filesystem" &&
906
919
  archive.descriptor.provider !== "modal_snapshot_directory")
@@ -938,82 +951,65 @@ async function persistWarmWorkspaceSnapshot(
938
951
  // turn signal resolves first. Its finally block is the only normal release
939
952
  // of the exact admission gate; a late callback cannot release a successor.
940
953
  const captureAndPublish = (async (): Promise<boolean> => {
954
+ let archive: VerifiedWorkspaceArchivePayload | undefined;
941
955
  let candidate: { id: string } | null = null;
942
- let workspaceArchiveRef: Awaited<
943
- ReturnType<typeof putVersion1TarArchiveOrInline>
944
- >["workspaceArchiveRef"];
956
+ let publicationAttempted = false;
945
957
  try {
946
- const archive = await captureVerifiedWorkspaceArchive(session, capturedAtMs, {
947
- requestId: claimed.claim.providerRequestId,
948
- strategy: capturePolicy.strategy,
949
- });
950
- candidate = await registerCandidate(archive);
951
- const priorLease = await readLease(db, ids.workspaceId, ids.sandboxGroupId);
952
- const priorKeys = collectWorkspaceArchiveObjectKeys(
953
- (priorLease?.resumeState as Record<string, unknown> | null | undefined) ?? null,
958
+ archive = await captureWorkspaceArchiveForStorage(
959
+ session,
960
+ capturedAtMs,
961
+ {
962
+ requestId: claimed.claim.providerRequestId,
963
+ strategy: capturePolicy.strategy,
964
+ },
965
+ Boolean(services.objectStorage),
954
966
  );
967
+ candidate = await registerCandidate(archive);
968
+ const archiveDescriptor = archive.descriptor;
955
969
  const published = await putVersion1TarArchiveOrInline({
956
970
  backend: lease.backend,
957
971
  objectStorage: services.objectStorage,
958
972
  accountId: ids.accountId,
959
973
  workspaceId: ids.workspaceId,
960
974
  sandboxGroupId: ids.sandboxGroupId,
961
- archive: {
962
- bytes: archive.bytes,
963
- descriptor: archive.descriptor,
964
- base64: archive.base64,
965
- },
975
+ archive,
966
976
  ...(services.sandboxMetrics ? { metrics: services.sandboxMetrics } : {}),
967
977
  });
968
- workspaceArchiveRef = published.workspaceArchiveRef;
969
- const { wrote } = await persistWarmSnapshot(db, {
970
- accountId: ids.accountId,
971
- workspaceId: ids.workspaceId,
972
- sessionId: ids.sessionId,
973
- turnId: ids.turnId,
974
- attemptId: ids.attemptId,
975
- sandboxGroupId: ids.sandboxGroupId,
976
- expectedEpoch: leaseEpoch,
977
- expectedInstanceId: instanceId,
978
- expectedWorkspaceGeneration: claimed.claim.workspaceGeneration,
979
- captureId,
980
- workspaceArchiveMeta: archive.descriptor,
981
- ...published,
982
- checkpointArtifactId: candidate?.id ?? null,
983
- minIntervalMs: force ? 0 : intervalMs,
984
- capturedAtMs,
978
+ publicationAttempted = true;
979
+ const { wrote } = await persistWorkspaceArchiveCandidate({
980
+ ...(services.objectStorage ? { objectStorage: services.objectStorage } : {}),
981
+ ...(published.workspaceArchiveRef ? { ref: published.workspaceArchiveRef } : {}),
982
+ persist: () =>
983
+ persistWarmSnapshot(db, {
984
+ accountId: ids.accountId,
985
+ workspaceId: ids.workspaceId,
986
+ sessionId: ids.sessionId,
987
+ turnId: ids.turnId,
988
+ attemptId: ids.attemptId,
989
+ sandboxGroupId: ids.sandboxGroupId,
990
+ expectedEpoch: leaseEpoch,
991
+ expectedInstanceId: instanceId,
992
+ expectedWorkspaceGeneration: claimed.claim.workspaceGeneration,
993
+ captureId,
994
+ workspaceArchiveMeta: archiveDescriptor,
995
+ ...published,
996
+ checkpointArtifactId: candidate?.id ?? null,
997
+ minIntervalMs: force ? 0 : intervalMs,
998
+ capturedAtMs,
999
+ }),
985
1000
  });
986
- if (published.workspaceArchiveRef && services.objectStorage) {
987
- if (!wrote) {
988
- await deleteUnpublishedWorkspaceArchiveObject(
989
- services.objectStorage,
990
- published.workspaceArchiveRef,
991
- services.sandboxMetrics,
992
- );
993
- } else {
994
- const afterLease = await readLease(db, ids.workspaceId, ids.sandboxGroupId);
995
- const afterKeys = collectWorkspaceArchiveObjectKeys(
996
- (afterLease?.resumeState as Record<string, unknown> | null | undefined) ?? null,
997
- );
998
- await deleteWorkspaceArchiveObjectKeys(
999
- services.objectStorage,
1000
- [...priorKeys].filter((key) => !afterKeys.has(key)),
1001
- ).catch(() => undefined);
1002
- }
1003
- }
1004
1001
  if (!wrote && candidate) {
1005
1002
  await abandonCandidate(candidate.id, "snapshot_publication_fenced");
1006
1003
  }
1007
1004
  return wrote;
1008
1005
  } catch (error) {
1009
- await deleteUnpublishedWorkspaceArchiveObject(
1010
- services.objectStorage,
1011
- workspaceArchiveRef,
1012
- services.sandboxMetrics,
1013
- );
1014
- if (candidate) await abandonCandidate(candidate.id, "snapshot_capture_failed");
1006
+ if (candidate && !publicationAttempted)
1007
+ await abandonCandidate(candidate.id, "snapshot_capture_failed");
1015
1008
  throw error;
1016
1009
  } finally {
1010
+ await disposeWorkspaceArchive(archive).catch(() => {
1011
+ console.warn("workspace archive spool cleanup failed");
1012
+ });
1017
1013
  await releaseWorkspaceArchiveCapture(db, {
1018
1014
  accountId: ids.accountId,
1019
1015
  workspaceId: ids.workspaceId,
@@ -1504,6 +1500,26 @@ export async function resumeBoxForTurn(
1504
1500
  const established = await establishSandboxSessionFromEnvelope(settings, hydrateEnvelope, {
1505
1501
  sessionId: ids.sessionId,
1506
1502
  recovery: "create-or-restore",
1503
+ ...(services.objectStorage
1504
+ ? {
1505
+ loadHostWorkspaceArchive: async (ref) => {
1506
+ try {
1507
+ return await downloadWorkspaceArchiveSpool(services.objectStorage!, ref.key, {
1508
+ bytes: ref.bytes,
1509
+ sha256: ref.sha256,
1510
+ });
1511
+ } catch (error) {
1512
+ if (error instanceof WorkspaceArchiveStorageError) {
1513
+ throw new WorkspaceArchiveIntegrityError(error.code, error.message, {
1514
+ retryable: error.retryable,
1515
+ cause: error,
1516
+ });
1517
+ }
1518
+ throw error;
1519
+ }
1520
+ },
1521
+ }
1522
+ : {}),
1507
1523
  backendOverride: ids.backend as never,
1508
1524
  ...(ids.environment ? { environment: ids.environment } : {}),
1509
1525
  ...(services.sandboxMetrics ? { metrics: services.sandboxMetrics } : {}),