@malloy-publisher/server 0.0.198 → 0.0.200
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/build.ts +30 -1
- package/dist/app/api-doc.yaml +127 -111
- package/dist/app/assets/{EnvironmentPage-C7rtH4mC.js → EnvironmentPage-CgKNjySu.js} +1 -1
- package/dist/app/assets/HomePage-BPIpMBjW.js +1 -0
- package/dist/app/assets/{MainPage-D38LtZDV.js → MainPage-CAwb8U82.js} +2 -2
- package/dist/app/assets/{ModelPage-DOol8Mz7.js → ModelPage-C0Uevsw9.js} +1 -1
- package/dist/app/assets/{PackagePage-0tgzA_kO.js → PackagePage-Cu-u9k1g.js} +1 -1
- package/dist/app/assets/{RouteError-BaMsOSly.js → RouteError-DVwPh2Ql.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Cx4SePkx.js → WorkbookPage-DW38R2Zv.js} +1 -1
- package/dist/app/assets/{core-CbsC6R_Y.es-Cwf6asf3.js → core-C0vCMRDQ.es-D_ytHhjS.js} +10 -10
- package/dist/app/assets/{index-DL6BZTuw.js → index-BGdcKsFF.js} +1 -1
- package/dist/app/assets/{index-DNofXMxi.js → index-CTx4v4_3.js} +1 -1
- package/dist/app/assets/index-DE6d5jEy.js +452 -0
- package/dist/app/assets/{index.umd-B68wGGkM.js → index.umd-C1Mi1uRm.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +57 -36
- package/dist/package_load_worker.mjs +12213 -0
- package/dist/server.mjs +4198 -3648
- package/package.json +2 -3
- package/src/config.spec.ts +246 -0
- package/src/config.ts +121 -1
- package/src/constants.ts +84 -1
- package/src/controller/compile.controller.ts +3 -1
- package/src/controller/connection.controller.spec.ts +803 -0
- package/src/controller/connection.controller.ts +207 -20
- package/src/controller/model.controller.ts +19 -1
- package/src/controller/query.controller.ts +22 -6
- package/src/controller/watch-mode.controller.ts +11 -2
- package/src/errors.spec.ts +44 -0
- package/src/errors.ts +34 -0
- package/src/health.spec.ts +90 -0
- package/src/health.ts +88 -45
- package/src/heap_check.spec.ts +144 -0
- package/src/heap_check.ts +144 -0
- package/src/instrumentation.ts +50 -0
- package/src/mcp/handler_utils.ts +14 -0
- package/src/mcp/tools/execute_query_tool.ts +52 -10
- package/src/oom_guards.integration.spec.ts +261 -0
- package/src/package_load/package_load_pool.spec.ts +252 -0
- package/src/package_load/package_load_pool.ts +920 -0
- package/src/package_load/package_load_worker.ts +980 -0
- package/src/package_load/protocol.ts +336 -0
- package/src/path_safety.ts +9 -3
- package/src/query_cap_metrics.spec.ts +89 -0
- package/src/query_cap_metrics.ts +115 -0
- package/src/query_concurrency.spec.ts +247 -0
- package/src/query_concurrency.ts +236 -0
- package/src/query_param_utils.ts +18 -0
- package/src/query_timeout.spec.ts +224 -0
- package/src/query_timeout.ts +178 -0
- package/src/server-old.ts +21 -1
- package/src/server.ts +61 -57
- package/src/service/connection.ts +8 -2
- package/src/service/db_utils.spec.ts +1 -1
- package/src/service/environment.ts +85 -4
- package/src/service/environment_admission.spec.ts +165 -1
- package/src/service/environment_store.spec.ts +103 -0
- package/src/service/environment_store.ts +98 -26
- package/src/service/filter_integration.spec.ts +110 -0
- package/src/service/given.ts +80 -0
- package/src/service/givens_integration.spec.ts +192 -0
- package/src/service/model.spec.ts +298 -3
- package/src/service/model.ts +362 -23
- package/src/service/model_limits.spec.ts +181 -0
- package/src/service/model_limits.ts +110 -0
- package/src/service/package.spec.ts +12 -6
- package/src/service/package.ts +263 -146
- package/src/service/package_worker_path.spec.ts +196 -0
- package/src/service/path_injection.spec.ts +39 -0
- package/src/stream_helpers.spec.ts +280 -0
- package/src/stream_helpers.ts +162 -0
- package/src/test_helpers/metrics_harness.ts +126 -0
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
- package/dist/app/assets/HomePage-DwkH7OrS.js +0 -1
- package/dist/app/assets/index-U38AyjJL.js +0 -451
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test helper: spin up an in-memory OpenTelemetry MeterProvider so
|
|
3
|
+
* unit tests can assert that the new guardrails (admission gate,
|
|
4
|
+
* query timeout, query concurrency, heap check) emit the expected
|
|
5
|
+
* counters and gauges.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
*
|
|
9
|
+
* const harness = await startMetricsHarness();
|
|
10
|
+
* // ... exercise production code ...
|
|
11
|
+
* const sums = await harness.collectCounter("publisher_query_timeout_total");
|
|
12
|
+
* expect(sums).toBe(1);
|
|
13
|
+
* await harness.shutdown();
|
|
14
|
+
*
|
|
15
|
+
* The OTel JS API resolves `metrics.getMeter(name)` lazily through a
|
|
16
|
+
* `ProxyMeter`, so registering the MeterProvider here AFTER the
|
|
17
|
+
* production modules have already cached their meter handles works
|
|
18
|
+
* — subsequent `.add()` / observable callbacks route to this
|
|
19
|
+
* harness's provider.
|
|
20
|
+
*
|
|
21
|
+
* The harness drains all of the provider's metrics via
|
|
22
|
+
* `MetricReader.collect()` and reads the cumulative data points
|
|
23
|
+
* directly, bypassing the exporter pipeline so test code doesn't
|
|
24
|
+
* have to thread async callbacks.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { metrics } from "@opentelemetry/api";
|
|
28
|
+
import { MeterProvider, MetricReader } from "@opentelemetry/sdk-metrics";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The simplest possible MetricReader implementation: a no-op that
|
|
32
|
+
* exists only so the `MeterProvider` has a reader to satisfy its
|
|
33
|
+
* collection invariants. Tests call `collect()` directly via the
|
|
34
|
+
* harness; the reader never pushes anywhere.
|
|
35
|
+
*/
|
|
36
|
+
class CollectingMetricReader extends MetricReader {
|
|
37
|
+
protected override async onForceFlush(): Promise<void> {
|
|
38
|
+
// no-op; tests pull via `collect()` on demand
|
|
39
|
+
}
|
|
40
|
+
protected override async onShutdown(): Promise<void> {
|
|
41
|
+
// no-op
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MetricsHarness {
|
|
46
|
+
readonly provider: MeterProvider;
|
|
47
|
+
/**
|
|
48
|
+
* Force a collection cycle and return the cumulative sum of all
|
|
49
|
+
* data points for the named counter. Returns 0 when the counter
|
|
50
|
+
* has not been emitted yet.
|
|
51
|
+
*
|
|
52
|
+
* `attributeFilter` lets a test scope the sum to a single label
|
|
53
|
+
* set (e.g. only the `environment: "test-env"` data point).
|
|
54
|
+
*/
|
|
55
|
+
collectCounter(
|
|
56
|
+
name: string,
|
|
57
|
+
attributeFilter?: Record<string, string | number | boolean>,
|
|
58
|
+
): Promise<number>;
|
|
59
|
+
/**
|
|
60
|
+
* Force a collection cycle and return the most-recently observed
|
|
61
|
+
* value of the named gauge, or `undefined` if no callback has
|
|
62
|
+
* fired for this gauge yet.
|
|
63
|
+
*/
|
|
64
|
+
collectGauge(name: string): Promise<number | undefined>;
|
|
65
|
+
shutdown(): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function startMetricsHarness(): Promise<MetricsHarness> {
|
|
69
|
+
const reader = new CollectingMetricReader();
|
|
70
|
+
const provider = new MeterProvider({ readers: [reader] });
|
|
71
|
+
// OTel JS's `setGlobalMeterProvider` silently refuses to
|
|
72
|
+
// overwrite an existing global (`registerGlobal` returns false).
|
|
73
|
+
// Tests that run back-to-back would otherwise route their
|
|
74
|
+
// metrics into a dead provider from the previous test. Disable
|
|
75
|
+
// first to clear the slot.
|
|
76
|
+
metrics.disable();
|
|
77
|
+
metrics.setGlobalMeterProvider(provider);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
provider,
|
|
81
|
+
async collectCounter(
|
|
82
|
+
name: string,
|
|
83
|
+
attributeFilter?: Record<string, string | number | boolean>,
|
|
84
|
+
): Promise<number> {
|
|
85
|
+
const result = await reader.collect();
|
|
86
|
+
let total = 0;
|
|
87
|
+
for (const rm of result.resourceMetrics.scopeMetrics) {
|
|
88
|
+
for (const metric of rm.metrics) {
|
|
89
|
+
if (metric.descriptor.name !== name) continue;
|
|
90
|
+
for (const dp of metric.dataPoints) {
|
|
91
|
+
if (attributeFilter) {
|
|
92
|
+
const allMatch = Object.entries(attributeFilter).every(
|
|
93
|
+
([k, v]) => dp.attributes?.[k] === v,
|
|
94
|
+
);
|
|
95
|
+
if (!allMatch) continue;
|
|
96
|
+
}
|
|
97
|
+
total += dp.value as number;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return total;
|
|
102
|
+
},
|
|
103
|
+
async collectGauge(name: string): Promise<number | undefined> {
|
|
104
|
+
const result = await reader.collect();
|
|
105
|
+
for (const rm of result.resourceMetrics.scopeMetrics) {
|
|
106
|
+
for (const metric of rm.metrics) {
|
|
107
|
+
if (metric.descriptor.name !== name) continue;
|
|
108
|
+
// Observable gauges yield one data point per
|
|
109
|
+
// attribute set per collection. Return the last
|
|
110
|
+
// observed value across all data points so unlabeled
|
|
111
|
+
// gauges (the common case in this code base) just
|
|
112
|
+
// work without attribute plumbing.
|
|
113
|
+
const last = metric.dataPoints[metric.dataPoints.length - 1];
|
|
114
|
+
return last?.value as number | undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
},
|
|
119
|
+
async shutdown(): Promise<void> {
|
|
120
|
+
await reader.shutdown();
|
|
121
|
+
// Clear the global so the next harness's
|
|
122
|
+
// `setGlobalMeterProvider` is accepted.
|
|
123
|
+
metrics.disable();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression test for the per-package download/unzip race.
|
|
5
|
+
*
|
|
6
|
+
* Before fix: concurrent POST/PATCH/GET-with-reload against the same package
|
|
7
|
+
* name landed in `downloadPackage` *before* acquiring the per-package mutex.
|
|
8
|
+
* Multiple callers would `rm -rf <targetPath>` and then `cp -r` / `extractAllTo`
|
|
9
|
+
* in parallel, leaving the directory in an inconsistent state. Subsequent
|
|
10
|
+
* reads failed with "Package manifest for ... does not exist" and any
|
|
11
|
+
* in-flight model compilation would 500 with "compiling model path not found".
|
|
12
|
+
*
|
|
13
|
+
* After fix: download is run inside the per-package mutex, so concurrent
|
|
14
|
+
* callers serialize on the same target path. All N requests must succeed
|
|
15
|
+
* and the package must be usable afterwards.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
19
|
+
import path from "path";
|
|
20
|
+
import { fileURLToPath } from "url";
|
|
21
|
+
import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
|
|
22
|
+
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = path.dirname(__filename);
|
|
25
|
+
|
|
26
|
+
// Use a unique env name per run so stale state from a previous run (env
|
|
27
|
+
// directory, persisted env metadata in publisher.db) can't poison startup.
|
|
28
|
+
const ENV_NAME = `concurrent-package-test-env-${Date.now()}`;
|
|
29
|
+
const PACKAGE_NAME = "gcs_faa";
|
|
30
|
+
const CONCURRENCY = 12;
|
|
31
|
+
|
|
32
|
+
const FORBIDDEN_ERROR_FRAGMENTS = [
|
|
33
|
+
"Package manifest for",
|
|
34
|
+
"does not exist",
|
|
35
|
+
"compiling model path not found",
|
|
36
|
+
"model path not found",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function findForbiddenError(body: unknown): string | undefined {
|
|
40
|
+
const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
|
|
41
|
+
return FORBIDDEN_ERROR_FRAGMENTS.find((frag) => text.includes(frag));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("Concurrent package operations (E2E)", () => {
|
|
45
|
+
let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
|
|
46
|
+
let baseUrl: string;
|
|
47
|
+
let fixtureDir: string;
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
env = await startRestE2E();
|
|
51
|
+
baseUrl = env.baseUrl;
|
|
52
|
+
fixtureDir = "gs://publisher_test_packages/gcs_faa.zip";
|
|
53
|
+
|
|
54
|
+
// Seed the environment with the package once so the env exists on disk.
|
|
55
|
+
// addEnvironment requires at least one package.
|
|
56
|
+
const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
name: ENV_NAME,
|
|
61
|
+
packages: [{ name: PACKAGE_NAME, location: fixtureDir }],
|
|
62
|
+
connections: [],
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
if (!createRes.ok) {
|
|
66
|
+
const body = await createRes.text();
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Failed to seed test environment (${createRes.status}): ${body}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Wait for the seeded package to be loadable.
|
|
73
|
+
const deadline = Date.now() + 30_000;
|
|
74
|
+
let ready = false;
|
|
75
|
+
while (!ready && Date.now() < deadline) {
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(
|
|
78
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
|
|
79
|
+
);
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
ready = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// not ready yet
|
|
86
|
+
}
|
|
87
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
88
|
+
}
|
|
89
|
+
if (!ready) {
|
|
90
|
+
throw new Error("Seeded package did not become available in time");
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterAll(async () => {
|
|
95
|
+
if (baseUrl) {
|
|
96
|
+
try {
|
|
97
|
+
await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
|
|
98
|
+
method: "DELETE",
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
// best-effort cleanup
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await env?.stop();
|
|
105
|
+
env = null;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("concurrent POST /packages for the same name all succeed", async () => {
|
|
109
|
+
const requests = Array.from({ length: CONCURRENCY }, () =>
|
|
110
|
+
fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
name: PACKAGE_NAME,
|
|
115
|
+
location: fixtureDir,
|
|
116
|
+
}),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const responses = await Promise.all(requests);
|
|
121
|
+
const bodies = await Promise.all(
|
|
122
|
+
responses.map(async (r) => ({
|
|
123
|
+
status: r.status,
|
|
124
|
+
body: await r.json().catch(() => null),
|
|
125
|
+
})),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
for (const { status, body } of bodies) {
|
|
129
|
+
expect(status).toBe(200);
|
|
130
|
+
const forbidden = findForbiddenError(body);
|
|
131
|
+
expect(forbidden).toBeUndefined();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Package must be loadable after the storm.
|
|
135
|
+
const res = await fetch(
|
|
136
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
|
|
137
|
+
);
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
const meta = (await res.json()) as { name?: string };
|
|
140
|
+
expect(meta.name).toBe(PACKAGE_NAME);
|
|
141
|
+
|
|
142
|
+
// Models must be listable — proves the model files survived the unzip
|
|
143
|
+
// race and Package.create read a consistent directory.
|
|
144
|
+
const modelsRes = await fetch(
|
|
145
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
|
|
146
|
+
);
|
|
147
|
+
expect(modelsRes.status).toBe(200);
|
|
148
|
+
const models = (await modelsRes.json()) as Array<{ path?: string }>;
|
|
149
|
+
expect(Array.isArray(models)).toBe(true);
|
|
150
|
+
expect(models.length).toBeGreaterThan(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("concurrent GET /packages/:name?reload=true all succeed", async () => {
|
|
154
|
+
const requests = Array.from({ length: CONCURRENCY }, () =>
|
|
155
|
+
fetch(
|
|
156
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}?reload=true`,
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
const responses = await Promise.all(requests);
|
|
160
|
+
const bodies = await Promise.all(
|
|
161
|
+
responses.map(async (r) => ({
|
|
162
|
+
status: r.status,
|
|
163
|
+
body: await r.json().catch(() => null),
|
|
164
|
+
})),
|
|
165
|
+
);
|
|
166
|
+
for (const { status, body } of bodies) {
|
|
167
|
+
expect(status).toBe(200);
|
|
168
|
+
const forbidden = findForbiddenError(body);
|
|
169
|
+
expect(forbidden).toBeUndefined();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Models must still list cleanly.
|
|
173
|
+
const modelsRes = await fetch(
|
|
174
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
|
|
175
|
+
);
|
|
176
|
+
expect(modelsRes.status).toBe(200);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("simultaneous POST + PATCH for the same package serialize cleanly", async () => {
|
|
180
|
+
// Fire create and update at the same time. Both should land under the
|
|
181
|
+
// same per-package mutex. Whichever wins the mutex first runs first;
|
|
182
|
+
// the loser sees a coherent post-condition. After both settle the
|
|
183
|
+
// package must be loadable and the description must reflect the PATCH
|
|
184
|
+
// when the PATCH ran *after* a successful POST.
|
|
185
|
+
const newDescription = `concurrent-update-${Date.now()}`;
|
|
186
|
+
const [postRes, patchRes] = await Promise.all([
|
|
187
|
+
fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "Content-Type": "application/json" },
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
name: PACKAGE_NAME,
|
|
192
|
+
location: fixtureDir,
|
|
193
|
+
}),
|
|
194
|
+
}),
|
|
195
|
+
fetch(
|
|
196
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
|
|
197
|
+
{
|
|
198
|
+
method: "PATCH",
|
|
199
|
+
headers: { "Content-Type": "application/json" },
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
name: PACKAGE_NAME,
|
|
202
|
+
description: newDescription,
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
// POST is idempotent here — always succeeds.
|
|
209
|
+
expect(postRes.status).toBe(200);
|
|
210
|
+
const postBody = await postRes.json().catch(() => null);
|
|
211
|
+
expect(findForbiddenError(postBody)).toBeUndefined();
|
|
212
|
+
|
|
213
|
+
// PATCH either succeeds (ran after POST loaded the package) or 404s
|
|
214
|
+
// (ran before POST populated `this.packages`). It must NOT silently
|
|
215
|
+
// rewrite disk and then 404, and must never surface the forbidden
|
|
216
|
+
// error fragments.
|
|
217
|
+
const patchBody = await patchRes.json().catch(() => null);
|
|
218
|
+
expect(findForbiddenError(patchBody)).toBeUndefined();
|
|
219
|
+
expect([200, 404]).toContain(patchRes.status);
|
|
220
|
+
|
|
221
|
+
// Whatever happened, the package must remain loadable.
|
|
222
|
+
const getRes = await fetch(
|
|
223
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
|
|
224
|
+
);
|
|
225
|
+
expect(getRes.status).toBe(200);
|
|
226
|
+
const meta = (await getRes.json()) as {
|
|
227
|
+
name?: string;
|
|
228
|
+
description?: string;
|
|
229
|
+
};
|
|
230
|
+
expect(meta.name).toBe(PACKAGE_NAME);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("interleaved POST + GET-reload + model list never surface stale-dir errors", async () => {
|
|
234
|
+
// Worst-case interleave: writers and readers hammering the same
|
|
235
|
+
// package. None of them should observe a missing manifest or a
|
|
236
|
+
// half-extracted directory.
|
|
237
|
+
const work: Array<Promise<{ status: number; body: unknown }>> = [];
|
|
238
|
+
for (let i = 0; i < CONCURRENCY; i++) {
|
|
239
|
+
work.push(
|
|
240
|
+
fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify({
|
|
244
|
+
name: PACKAGE_NAME,
|
|
245
|
+
location: fixtureDir,
|
|
246
|
+
}),
|
|
247
|
+
}).then(async (r) => ({
|
|
248
|
+
status: r.status,
|
|
249
|
+
body: await r.json().catch(() => null),
|
|
250
|
+
})),
|
|
251
|
+
);
|
|
252
|
+
work.push(
|
|
253
|
+
fetch(
|
|
254
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}?reload=true`,
|
|
255
|
+
).then(async (r) => ({
|
|
256
|
+
status: r.status,
|
|
257
|
+
body: await r.json().catch(() => null),
|
|
258
|
+
})),
|
|
259
|
+
);
|
|
260
|
+
work.push(
|
|
261
|
+
fetch(
|
|
262
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}/models`,
|
|
263
|
+
).then(async (r) => ({
|
|
264
|
+
status: r.status,
|
|
265
|
+
body: await r.json().catch(() => null),
|
|
266
|
+
})),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const results = await Promise.all(work);
|
|
271
|
+
for (const { status, body } of results) {
|
|
272
|
+
const forbidden = findForbiddenError(body);
|
|
273
|
+
expect(forbidden).toBeUndefined();
|
|
274
|
+
// Model list while the package is being rewritten can transiently
|
|
275
|
+
// return 404 (the package is unloaded mid-rewrite), but never 5xx,
|
|
276
|
+
// and never with the forbidden error fragments.
|
|
277
|
+
expect(status).toBeLessThan(500);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{S as t,j as o,L as a}from"./index-U38AyjJL.js";function s(){const n=t();return o.jsx(a,{onClickEnvironment:n})}export{s as default};
|