@malloy-publisher/server 0.0.198-dev1 → 0.0.198-dev2
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 +12 -22
- package/dist/instrumentation.mjs +57 -36
- package/dist/server.mjs +650 -926
- package/dist/service/schema_worker.mjs +61 -0
- package/package.json +1 -1
- package/src/health.ts +0 -13
- package/src/instrumentation.ts +50 -0
- package/src/server.ts +5 -0
- package/src/service/environment_store.ts +9 -0
- package/src/service/model.ts +3 -226
- package/src/service/package.spec.ts +11 -7
- package/src/service/package.ts +49 -53
- package/src/service/process_stats_reporter.ts +169 -0
- package/src/service/schema_worker.ts +123 -0
- package/src/service/schema_worker_pool.ts +278 -0
- package/tests/integration/concurrent_environment/concurrent_environment.integration.spec.ts +235 -0
- package/dist/compile_worker.mjs +0 -628
- package/src/compile/compile_pool.spec.ts +0 -227
- package/src/compile/compile_pool.ts +0 -729
- package/src/compile/compile_worker.ts +0 -683
- package/src/compile/protocol.ts +0 -251
- package/src/service/model_worker_path.spec.ts +0 -125
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Regression test for per-environment load/scaffold races.
|
|
5
|
+
*
|
|
6
|
+
* Before fix: concurrent GET (especially ?reload=true), POST, and PATCH against
|
|
7
|
+
* the same environment name could enter `getEnvironment` / `addEnvironment` in
|
|
8
|
+
* parallel. Multiple callers would scaffold or re-load the same directory
|
|
9
|
+
* concurrently, leaving publisher.db and on-disk state inconsistent. Lazy loads
|
|
10
|
+
* then failed with `Environment "…" could not be resolved to a path.`
|
|
11
|
+
*
|
|
12
|
+
* After fix: environment operations serialize on a per-environment mutex in
|
|
13
|
+
* `EnvironmentStore.getEnvironment`, so concurrent callers share one load path.
|
|
14
|
+
* All N requests must succeed and the environment must remain usable afterwards.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
18
|
+
import { RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
|
|
19
|
+
|
|
20
|
+
const ENV_NAME = `concurrent-environment-test-env-${Date.now()}`;
|
|
21
|
+
const PACKAGE_NAME = "gcs_faa";
|
|
22
|
+
const FIXTURE_LOCATION = "gs://publisher_test_packages/gcs_faa.zip";
|
|
23
|
+
const CONCURRENCY = 12;
|
|
24
|
+
|
|
25
|
+
const FORBIDDEN_ERROR_FRAGMENTS = [
|
|
26
|
+
"could not be resolved to a path",
|
|
27
|
+
"Package manifest for",
|
|
28
|
+
"does not exist",
|
|
29
|
+
"compiling model path not found",
|
|
30
|
+
"model path not found",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function findForbiddenError(body: unknown): string | undefined {
|
|
34
|
+
const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
|
|
35
|
+
return FORBIDDEN_ERROR_FRAGMENTS.find((frag) => text.includes(frag));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function environmentPayload(description?: string) {
|
|
39
|
+
return {
|
|
40
|
+
name: ENV_NAME,
|
|
41
|
+
packages: [{ name: PACKAGE_NAME, location: FIXTURE_LOCATION }],
|
|
42
|
+
connections: [],
|
|
43
|
+
...(description !== undefined ? { description } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function waitForPackageReady(
|
|
48
|
+
baseUrl: string,
|
|
49
|
+
deadlineMs = 30_000,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const deadline = Date.now() + deadlineMs;
|
|
52
|
+
while (Date.now() < deadline) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(
|
|
55
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}/packages/${PACKAGE_NAME}`,
|
|
56
|
+
);
|
|
57
|
+
if (res.ok) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// not ready yet
|
|
62
|
+
}
|
|
63
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
64
|
+
}
|
|
65
|
+
throw new Error("Seeded package did not become available in time");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("Concurrent environment operations (E2E)", () => {
|
|
69
|
+
let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
|
|
70
|
+
let baseUrl: string;
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
env = await startRestE2E();
|
|
74
|
+
baseUrl = env.baseUrl;
|
|
75
|
+
|
|
76
|
+
const createRes = await fetch(`${baseUrl}/api/v0/environments`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "Content-Type": "application/json" },
|
|
79
|
+
body: JSON.stringify(environmentPayload()),
|
|
80
|
+
});
|
|
81
|
+
if (!createRes.ok) {
|
|
82
|
+
const body = await createRes.text();
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Failed to seed test environment (${createRes.status}): ${body}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
await waitForPackageReady(baseUrl);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(async () => {
|
|
91
|
+
if (baseUrl) {
|
|
92
|
+
try {
|
|
93
|
+
await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
|
|
94
|
+
method: "DELETE",
|
|
95
|
+
});
|
|
96
|
+
} catch {
|
|
97
|
+
// best-effort cleanup
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await env?.stop();
|
|
101
|
+
env = null;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("concurrent POST /environments for the same name all succeed", async () => {
|
|
105
|
+
const requests = Array.from({ length: CONCURRENCY }, () =>
|
|
106
|
+
fetch(`${baseUrl}/api/v0/environments`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify(environmentPayload()),
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const responses = await Promise.all(requests);
|
|
114
|
+
const bodies = await Promise.all(
|
|
115
|
+
responses.map(async (r) => ({
|
|
116
|
+
status: r.status,
|
|
117
|
+
body: await r.json().catch(() => null),
|
|
118
|
+
})),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
for (const { status, body } of bodies) {
|
|
122
|
+
expect(status).toBe(200);
|
|
123
|
+
const forbidden = findForbiddenError(body);
|
|
124
|
+
expect(forbidden).toBeUndefined();
|
|
125
|
+
const meta = body as { name?: string };
|
|
126
|
+
expect(meta.name).toBe(ENV_NAME);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await waitForPackageReady(baseUrl);
|
|
130
|
+
|
|
131
|
+
const getRes = await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`);
|
|
132
|
+
expect(getRes.status).toBe(200);
|
|
133
|
+
const forbidden = findForbiddenError(
|
|
134
|
+
await getRes.json().catch(() => null),
|
|
135
|
+
);
|
|
136
|
+
expect(forbidden).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("concurrent GET /environments/:name?reload=true all succeed", async () => {
|
|
140
|
+
const requests = Array.from({ length: CONCURRENCY }, () =>
|
|
141
|
+
fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}?reload=true`),
|
|
142
|
+
);
|
|
143
|
+
const responses = await Promise.all(requests);
|
|
144
|
+
const bodies = await Promise.all(
|
|
145
|
+
responses.map(async (r) => ({
|
|
146
|
+
status: r.status,
|
|
147
|
+
body: await r.json().catch(() => null),
|
|
148
|
+
})),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
for (const { status, body } of bodies) {
|
|
152
|
+
expect(status).toBe(200);
|
|
153
|
+
const forbidden = findForbiddenError(body);
|
|
154
|
+
expect(forbidden).toBeUndefined();
|
|
155
|
+
const meta = body as { name?: string };
|
|
156
|
+
expect(meta.name).toBe(ENV_NAME);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await waitForPackageReady(baseUrl);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("simultaneous POST + PATCH for the same environment serialize cleanly", async () => {
|
|
163
|
+
const newReadme = `concurrent-env-update-${Date.now()}`;
|
|
164
|
+
const [postRes, patchRes] = await Promise.all([
|
|
165
|
+
fetch(`${baseUrl}/api/v0/environments`, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json" },
|
|
168
|
+
body: JSON.stringify(environmentPayload()),
|
|
169
|
+
}),
|
|
170
|
+
fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`, {
|
|
171
|
+
method: "PATCH",
|
|
172
|
+
headers: { "Content-Type": "application/json" },
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
name: ENV_NAME,
|
|
175
|
+
readme: newReadme,
|
|
176
|
+
}),
|
|
177
|
+
}),
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
expect(postRes.status).toBe(200);
|
|
181
|
+
expect(
|
|
182
|
+
findForbiddenError(await postRes.json().catch(() => null)),
|
|
183
|
+
).toBeUndefined();
|
|
184
|
+
|
|
185
|
+
const patchBody = await patchRes.json().catch(() => null);
|
|
186
|
+
expect(findForbiddenError(patchBody)).toBeUndefined();
|
|
187
|
+
expect([200, 404]).toContain(patchRes.status);
|
|
188
|
+
|
|
189
|
+
const getRes = await fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}`);
|
|
190
|
+
expect(getRes.status).toBe(200);
|
|
191
|
+
const meta = (await getRes.json()) as { name?: string; readme?: string };
|
|
192
|
+
expect(meta.name).toBe(ENV_NAME);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("interleaved POST + GET-reload + package list never surface path errors", async () => {
|
|
196
|
+
const work: Array<Promise<{ status: number; body: unknown }>> = [];
|
|
197
|
+
for (let i = 0; i < CONCURRENCY; i++) {
|
|
198
|
+
work.push(
|
|
199
|
+
fetch(`${baseUrl}/api/v0/environments`, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { "Content-Type": "application/json" },
|
|
202
|
+
body: JSON.stringify(environmentPayload()),
|
|
203
|
+
}).then(async (r) => ({
|
|
204
|
+
status: r.status,
|
|
205
|
+
body: await r.json().catch(() => null),
|
|
206
|
+
})),
|
|
207
|
+
);
|
|
208
|
+
work.push(
|
|
209
|
+
fetch(
|
|
210
|
+
`${baseUrl}/api/v0/environments/${ENV_NAME}?reload=true`,
|
|
211
|
+
).then(async (r) => ({
|
|
212
|
+
status: r.status,
|
|
213
|
+
body: await r.json().catch(() => null),
|
|
214
|
+
})),
|
|
215
|
+
);
|
|
216
|
+
work.push(
|
|
217
|
+
fetch(`${baseUrl}/api/v0/environments/${ENV_NAME}/packages`).then(
|
|
218
|
+
async (r) => ({
|
|
219
|
+
status: r.status,
|
|
220
|
+
body: await r.json().catch(() => null),
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const results = await Promise.all(work);
|
|
227
|
+
for (const { status, body } of results) {
|
|
228
|
+
const forbidden = findForbiddenError(body);
|
|
229
|
+
expect(forbidden).toBeUndefined();
|
|
230
|
+
expect(status).toBeLessThan(500);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await waitForPackageReady(baseUrl);
|
|
234
|
+
});
|
|
235
|
+
});
|