@quenty/nevermore-cli 4.48.0 → 4.50.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.
- package/CHANGELOG.md +12 -0
- package/dist/commands/batch-command/batch-test-command.d.ts +1 -0
- package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-test-command.js +11 -0
- package/dist/commands/batch-command/batch-test-command.js.map +1 -1
- package/dist/utils/build/place-recompression.d.ts +25 -0
- package/dist/utils/build/place-recompression.d.ts.map +1 -0
- package/dist/utils/build/place-recompression.js +166 -0
- package/dist/utils/build/place-recompression.js.map +1 -0
- package/dist/utils/build/place-recompression.test.d.ts +2 -0
- package/dist/utils/build/place-recompression.test.d.ts.map +1 -0
- package/dist/utils/build/place-recompression.test.js +68 -0
- package/dist/utils/build/place-recompression.test.js.map +1 -0
- package/dist/utils/job-context/batch-script-job-context.d.ts +20 -8
- package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.js +94 -22
- package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts +12 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +37 -1
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/testing/batch-run.e2e.test.js +78 -13
- package/dist/utils/testing/batch-run.e2e.test.js.map +1 -1
- package/dist/utils/testing/runner/combined-project-generator.d.ts +33 -5
- package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
- package/dist/utils/testing/runner/combined-project-generator.js +116 -19
- package/dist/utils/testing/runner/combined-project-generator.js.map +1 -1
- package/dist/utils/testing/runner/combined-project-generator.test.d.ts +2 -0
- package/dist/utils/testing/runner/combined-project-generator.test.d.ts.map +1 -0
- package/dist/utils/testing/runner/combined-project-generator.test.js +26 -0
- package/dist/utils/testing/runner/combined-project-generator.test.js.map +1 -0
- package/package.json +6 -6
- package/src/commands/batch-command/batch-test-command.ts +14 -0
- package/src/utils/build/place-recompression.test.ts +104 -0
- package/src/utils/build/place-recompression.ts +236 -0
- package/src/utils/job-context/batch-script-job-context.ts +140 -31
- package/src/utils/open-cloud/open-cloud-client.ts +58 -1
- package/src/utils/testing/batch-run.e2e.test.ts +97 -13
- package/src/utils/testing/runner/combined-project-generator.test.ts +30 -0
- package/src/utils/testing/runner/combined-project-generator.ts +198 -30
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import * as zlib from 'zlib';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rewrite a binary place file's compressed chunks as long-window zstd.
|
|
5
|
+
*
|
|
6
|
+
* Every script's source lives in one `PROP` chunk per class, and Rojo and Lune
|
|
7
|
+
* compress chunks as LZ4, whose 64 KB window cannot reach a module the place
|
|
8
|
+
* already holds a copy of. A Nevermore place carries its dependencies by copy
|
|
9
|
+
* — a combined batch place a copy per package — so the same modules repeat
|
|
10
|
+
* many times over and LZ4 stores every one. zstd with a long window and
|
|
11
|
+
* long-distance matching finds them: a 40-package batch place measured 38.3 MB
|
|
12
|
+
* as LZ4 and 1.3 MB recompressed, and ran identically.
|
|
13
|
+
*
|
|
14
|
+
* The binary format picks the codec per chunk from its leading bytes, so this
|
|
15
|
+
* changes how the file is stored, not what it holds. Chunk payloads are
|
|
16
|
+
* decompressed and recompressed byte for byte; nothing is parsed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** zstd frame magic; the format reads any other compressed payload as LZ4. */
|
|
20
|
+
const ZSTD_MAGIC = 0xfd2fb528;
|
|
21
|
+
|
|
22
|
+
const PLACE_MAGIC = Buffer.from('<roblox!', 'latin1');
|
|
23
|
+
const HEADER_LENGTH = 32;
|
|
24
|
+
const CHUNK_HEADER_LENGTH = 16;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 2^27, zstd's default decoder window limit. A larger window would need the
|
|
28
|
+
* decoder to opt in, and this is the size Roblox has been seen to decode.
|
|
29
|
+
*/
|
|
30
|
+
const WINDOW_LOG = 27;
|
|
31
|
+
/** Level 9 is within 15% of level 19's size here at a fourteenth of the time. */
|
|
32
|
+
const COMPRESSION_LEVEL = 9;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `zstdCompressSync` landed in Node 22.15 / 23.8 and the installed typings
|
|
36
|
+
* predate it, so it is looked up rather than imported.
|
|
37
|
+
*/
|
|
38
|
+
interface ZstdZlib {
|
|
39
|
+
zstdCompressSync(
|
|
40
|
+
buffer: Buffer,
|
|
41
|
+
options: { params: Record<number, number> }
|
|
42
|
+
): Buffer;
|
|
43
|
+
zstdDecompressSync(buffer: Buffer): Buffer;
|
|
44
|
+
constants: Record<string, number>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type PlaceRecompressionResult =
|
|
48
|
+
| { kind: 'recompressed'; data: Buffer; originalBytes: number }
|
|
49
|
+
/** Not a binary place, or every compressed chunk is already zstd. */
|
|
50
|
+
| { kind: 'unchanged'; data: Buffer }
|
|
51
|
+
| { kind: 'unsupported'; data: Buffer; reason: string };
|
|
52
|
+
|
|
53
|
+
/** Whether this Node can recompress places at all. */
|
|
54
|
+
export function getZstdSupport():
|
|
55
|
+
| { supported: true }
|
|
56
|
+
| { supported: false; reason: string } {
|
|
57
|
+
const z = zlib as unknown as Partial<ZstdZlib>;
|
|
58
|
+
if (
|
|
59
|
+
typeof z.zstdCompressSync === 'function' &&
|
|
60
|
+
typeof z.zstdDecompressSync === 'function'
|
|
61
|
+
) {
|
|
62
|
+
return { supported: true };
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
supported: false,
|
|
66
|
+
reason: `Node ${process.version} has no zstd (needs 22.15+ or 23.8+)`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function recompressPlace(data: Buffer): PlaceRecompressionResult {
|
|
71
|
+
if (
|
|
72
|
+
data.length < HEADER_LENGTH ||
|
|
73
|
+
!data.subarray(0, PLACE_MAGIC.length).equals(PLACE_MAGIC)
|
|
74
|
+
) {
|
|
75
|
+
// An XML place, or not a place: nothing to recompress.
|
|
76
|
+
return { kind: 'unchanged', data };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const chunks = _readChunks(data);
|
|
80
|
+
if (!chunks.some((c) => c.compressedLength > 0 && !c.isZstd)) {
|
|
81
|
+
return { kind: 'unchanged', data };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const support = getZstdSupport();
|
|
85
|
+
if (!support.supported) {
|
|
86
|
+
return { kind: 'unsupported', data, reason: support.reason };
|
|
87
|
+
}
|
|
88
|
+
const z = zlib as unknown as ZstdZlib;
|
|
89
|
+
const params = {
|
|
90
|
+
[z.constants.ZSTD_c_compressionLevel]: COMPRESSION_LEVEL,
|
|
91
|
+
[z.constants.ZSTD_c_windowLog]: WINDOW_LOG,
|
|
92
|
+
[z.constants.ZSTD_c_enableLongDistanceMatching]: 1,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const parts: Buffer[] = [data.subarray(0, HEADER_LENGTH)];
|
|
96
|
+
for (const chunk of chunks) {
|
|
97
|
+
if (chunk.compressedLength === 0 || chunk.isZstd) {
|
|
98
|
+
parts.push(data.subarray(chunk.start, chunk.end));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const raw = decompressLz4Block(chunk.payload, chunk.uncompressedLength);
|
|
103
|
+
const compressed = z.zstdCompressSync(raw, { params });
|
|
104
|
+
|
|
105
|
+
const header = Buffer.from(
|
|
106
|
+
data.subarray(chunk.start, chunk.start + CHUNK_HEADER_LENGTH)
|
|
107
|
+
);
|
|
108
|
+
header.writeUInt32LE(compressed.length, 4);
|
|
109
|
+
parts.push(header, compressed);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
kind: 'recompressed',
|
|
114
|
+
data: Buffer.concat(parts),
|
|
115
|
+
originalBytes: data.length,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface PlaceChunk {
|
|
120
|
+
/** Offset of the chunk header. */
|
|
121
|
+
start: number;
|
|
122
|
+
/** Offset just past the payload. */
|
|
123
|
+
end: number;
|
|
124
|
+
compressedLength: number;
|
|
125
|
+
uncompressedLength: number;
|
|
126
|
+
payload: Buffer;
|
|
127
|
+
isZstd: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _readChunks(data: Buffer): PlaceChunk[] {
|
|
131
|
+
const chunks: PlaceChunk[] = [];
|
|
132
|
+
let pos = HEADER_LENGTH;
|
|
133
|
+
let sawEnd = false;
|
|
134
|
+
|
|
135
|
+
while (!sawEnd) {
|
|
136
|
+
if (pos + CHUNK_HEADER_LENGTH > data.length) {
|
|
137
|
+
throw new Error('Place file is truncated before its END chunk');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const name = data.toString('latin1', pos, pos + 4);
|
|
141
|
+
const compressedLength = data.readUInt32LE(pos + 4);
|
|
142
|
+
const uncompressedLength = data.readUInt32LE(pos + 8);
|
|
143
|
+
const payloadStart = pos + CHUNK_HEADER_LENGTH;
|
|
144
|
+
const end = payloadStart + (compressedLength || uncompressedLength);
|
|
145
|
+
if (end > data.length) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Place file is truncated inside its ${name.trim()} chunk`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const payload = data.subarray(payloadStart, end);
|
|
152
|
+
chunks.push({
|
|
153
|
+
start: pos,
|
|
154
|
+
end,
|
|
155
|
+
compressedLength,
|
|
156
|
+
uncompressedLength,
|
|
157
|
+
payload,
|
|
158
|
+
isZstd: compressedLength >= 4 && payload.readUInt32LE(0) === ZSTD_MAGIC,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pos = end;
|
|
162
|
+
sawEnd = name === 'END\0';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Anything after END is not the format's, but it is kept rather than dropped.
|
|
166
|
+
if (pos < data.length) {
|
|
167
|
+
chunks.push({
|
|
168
|
+
start: pos,
|
|
169
|
+
end: data.length,
|
|
170
|
+
compressedLength: 0,
|
|
171
|
+
uncompressedLength: data.length - pos,
|
|
172
|
+
payload: data.subarray(pos),
|
|
173
|
+
isZstd: false,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return chunks;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Decode one raw LZ4 block, the format's default chunk compression. */
|
|
181
|
+
export function decompressLz4Block(
|
|
182
|
+
src: Buffer,
|
|
183
|
+
uncompressedLength: number
|
|
184
|
+
): Buffer {
|
|
185
|
+
const out = Buffer.alloc(uncompressedLength);
|
|
186
|
+
let s = 0;
|
|
187
|
+
let d = 0;
|
|
188
|
+
|
|
189
|
+
while (s < src.length) {
|
|
190
|
+
const token = src[s++]!;
|
|
191
|
+
|
|
192
|
+
let literalLength = token >> 4;
|
|
193
|
+
if (literalLength === 15) {
|
|
194
|
+
let b: number;
|
|
195
|
+
do {
|
|
196
|
+
b = src[s++]!;
|
|
197
|
+
literalLength += b;
|
|
198
|
+
} while (b === 255);
|
|
199
|
+
}
|
|
200
|
+
src.copy(out, d, s, s + literalLength);
|
|
201
|
+
s += literalLength;
|
|
202
|
+
d += literalLength;
|
|
203
|
+
|
|
204
|
+
// The last sequence is literals only.
|
|
205
|
+
if (s >= src.length) break;
|
|
206
|
+
|
|
207
|
+
const offset = src[s]! | (src[s + 1]! << 8);
|
|
208
|
+
s += 2;
|
|
209
|
+
if (offset === 0 || offset > d) {
|
|
210
|
+
throw new Error('Corrupt LZ4 block: match offset out of range');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let matchLength = token & 15;
|
|
214
|
+
if (matchLength === 15) {
|
|
215
|
+
let b: number;
|
|
216
|
+
do {
|
|
217
|
+
b = src[s++]!;
|
|
218
|
+
matchLength += b;
|
|
219
|
+
} while (b === 255);
|
|
220
|
+
}
|
|
221
|
+
matchLength += 4;
|
|
222
|
+
|
|
223
|
+
// Byte by byte: a match may overlap the bytes it is producing.
|
|
224
|
+
for (let i = 0; i < matchLength; i++) {
|
|
225
|
+
out[d] = out[d - offset]!;
|
|
226
|
+
d++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (d !== uncompressedLength) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Corrupt LZ4 block: decoded ${d} bytes, expected ${uncompressedLength}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
@@ -18,7 +18,9 @@ import { type BuildPlaceOptions } from '../build/build.js';
|
|
|
18
18
|
import { type BatchTarget } from '../batch/changed-packages-utils.js';
|
|
19
19
|
import {
|
|
20
20
|
type CombinedBuildProgress,
|
|
21
|
+
type CombinedPlaceChunk,
|
|
21
22
|
type CombinedProjectResult,
|
|
23
|
+
formatMegabytes,
|
|
22
24
|
generateCombinedProjectAsync,
|
|
23
25
|
} from '../testing/runner/combined-project-generator.js';
|
|
24
26
|
import {
|
|
@@ -40,16 +42,22 @@ class BatchDeployment implements Deployment {
|
|
|
40
42
|
) {}
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
/** One combined place's upload and execution, each started at most once. */
|
|
46
|
+
interface ChunkRun {
|
|
47
|
+
chunk: CombinedPlaceChunk;
|
|
48
|
+
deployPromise?: Promise<Deployment>;
|
|
49
|
+
deployment?: Deployment;
|
|
50
|
+
executionPromise?: Promise<BatchExecutionOutcome>;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/**
|
|
49
|
-
* JobContext wrapper that batches all packages into a single build,
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* JobContext wrapper that batches all packages into a single build, and as few
|
|
55
|
+
* uploads and executions as the place size budget allows — one, unless the
|
|
56
|
+
* combined place is too big even recompressed, in which case it is split into
|
|
57
|
+
* chunks that each upload and run on their own, in parallel. Wraps an inner
|
|
58
|
+
* context (cloud or local) and intercepts all methods using the lazy-promise
|
|
59
|
+
* pattern: the first concurrent call triggers the shared operation, all others
|
|
60
|
+
* await the same promise.
|
|
53
61
|
*
|
|
54
62
|
* Note that a `basePlace` cannot be honoured here: every package is combined
|
|
55
63
|
* into one place, so there is no per-package place to merge Studio content
|
|
@@ -86,16 +94,16 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
86
94
|
private _batchPlaceId?: number;
|
|
87
95
|
private _batchUniverseId?: number;
|
|
88
96
|
private _batchTimeoutMs: number;
|
|
97
|
+
private _maxChunkBytes?: number;
|
|
89
98
|
private _reporter?: Reporter;
|
|
90
99
|
|
|
91
100
|
// Lazy-promise state
|
|
92
|
-
private _combinedBuildPromise?: Promise<
|
|
93
|
-
private
|
|
101
|
+
private _combinedBuildPromise?: Promise<CombinedProjectResult>;
|
|
102
|
+
private _chunkRuns?: ChunkRun[];
|
|
94
103
|
private _executionPromise?: Promise<BatchExecutionOutcome>;
|
|
95
104
|
|
|
96
105
|
// State for cleanup
|
|
97
106
|
private _combinedBuildContext?: BuildContext;
|
|
98
|
-
private _sharedDeployment?: Deployment;
|
|
99
107
|
|
|
100
108
|
constructor(
|
|
101
109
|
inner: JobContext,
|
|
@@ -105,6 +113,11 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
105
113
|
batchPlaceId?: number;
|
|
106
114
|
batchUniverseId?: number;
|
|
107
115
|
batchTimeoutMs?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Per-place size budget; see `DEFAULT_MAX_CHUNK_BYTES`. Only an upload
|
|
118
|
+
* is size-limited, so a local run passes `Infinity`.
|
|
119
|
+
*/
|
|
120
|
+
maxChunkBytes?: number;
|
|
108
121
|
reporter?: Reporter;
|
|
109
122
|
}
|
|
110
123
|
) {
|
|
@@ -114,6 +127,7 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
114
127
|
this._batchPlaceId = options?.batchPlaceId;
|
|
115
128
|
this._batchUniverseId = options?.batchUniverseId;
|
|
116
129
|
this._batchTimeoutMs = options?.batchTimeoutMs ?? 300_000;
|
|
130
|
+
this._maxChunkBytes = options?.maxChunkBytes;
|
|
117
131
|
this._reporter = options?.reporter;
|
|
118
132
|
|
|
119
133
|
const withBasePlace = batchTargets.filter((t) => t.target.basePlace);
|
|
@@ -130,12 +144,14 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
async buildPlaceAsync(options: BuildPlaceOptions): Promise<BuiltPlace> {
|
|
133
|
-
const
|
|
147
|
+
const chunkRun = options.packageName
|
|
148
|
+
? await this._getChunkRunAsync(options.packageName)
|
|
149
|
+
: (await this._getChunkRunsAsync())[0]!;
|
|
134
150
|
// The script content baked into BuiltPlace.target is read by
|
|
135
151
|
// runSingleTestAsync but discarded in aggregated batch mode — we just
|
|
136
152
|
// echo back the caller's resolved place.
|
|
137
153
|
return {
|
|
138
|
-
rbxlPath:
|
|
154
|
+
rbxlPath: chunkRun.chunk.rbxlPath,
|
|
139
155
|
target: options.target,
|
|
140
156
|
};
|
|
141
157
|
}
|
|
@@ -143,7 +159,8 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
143
159
|
async deployBuiltPlaceAsync(
|
|
144
160
|
options: DeployPlaceOptions
|
|
145
161
|
): Promise<Deployment> {
|
|
146
|
-
const
|
|
162
|
+
const chunkRun = await this._getChunkRunAsync(options.packageName);
|
|
163
|
+
const innerDeployment = await this._getChunkDeploymentAsync(chunkRun);
|
|
147
164
|
|
|
148
165
|
return new BatchDeployment(options.packageName, innerDeployment);
|
|
149
166
|
}
|
|
@@ -208,10 +225,11 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
208
225
|
}
|
|
209
226
|
|
|
210
227
|
async disposeAsync(): Promise<void> {
|
|
211
|
-
// Release
|
|
212
|
-
|
|
228
|
+
// Release each chunk's inner deployment
|
|
229
|
+
for (const { deployment } of this._chunkRuns ?? []) {
|
|
230
|
+
if (!deployment) continue;
|
|
213
231
|
try {
|
|
214
|
-
await this._inner.releaseAsync(
|
|
232
|
+
await this._inner.releaseAsync(deployment);
|
|
215
233
|
} catch {
|
|
216
234
|
// best effort
|
|
217
235
|
}
|
|
@@ -231,14 +249,14 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
231
249
|
|
|
232
250
|
// ---- Lazy-promise internals ----
|
|
233
251
|
|
|
234
|
-
private _getCombinedBuildAsync(): Promise<
|
|
252
|
+
private _getCombinedBuildAsync(): Promise<CombinedProjectResult> {
|
|
235
253
|
if (!this._combinedBuildPromise) {
|
|
236
254
|
this._combinedBuildPromise = this._doCombinedBuildAsync();
|
|
237
255
|
}
|
|
238
256
|
return this._combinedBuildPromise;
|
|
239
257
|
}
|
|
240
258
|
|
|
241
|
-
private async _doCombinedBuildAsync(): Promise<
|
|
259
|
+
private async _doCombinedBuildAsync(): Promise<CombinedProjectResult> {
|
|
242
260
|
OutputHelper.verbose('Building combined batch place...');
|
|
243
261
|
|
|
244
262
|
// Set all packages to "waiting" — they're queued for building
|
|
@@ -276,37 +294,61 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
276
294
|
repoRoot: this._repoRoot,
|
|
277
295
|
batchPlaceId: this._batchPlaceId,
|
|
278
296
|
batchUniverseId: this._batchUniverseId,
|
|
297
|
+
maxChunkBytes: this._maxChunkBytes,
|
|
279
298
|
progress,
|
|
280
299
|
});
|
|
281
300
|
|
|
282
301
|
this._combinedBuildContext = combinedResult.buildContext;
|
|
302
|
+
this._chunkRuns = combinedResult.chunks.map((chunk) => ({ chunk }));
|
|
283
303
|
|
|
284
|
-
return
|
|
304
|
+
return combinedResult;
|
|
285
305
|
}
|
|
286
306
|
|
|
287
|
-
private
|
|
288
|
-
|
|
289
|
-
|
|
307
|
+
private async _getChunkRunsAsync(): Promise<ChunkRun[]> {
|
|
308
|
+
await this._getCombinedBuildAsync();
|
|
309
|
+
return this._chunkRuns!;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async _getChunkRunAsync(packageName: string): Promise<ChunkRun> {
|
|
313
|
+
const chunkRuns = await this._getChunkRunsAsync();
|
|
314
|
+
const chunkRun = chunkRuns.find((run) =>
|
|
315
|
+
run.chunk.slugMap.has(packageName)
|
|
316
|
+
);
|
|
317
|
+
if (!chunkRun) {
|
|
318
|
+
throw new Error(`${packageName} is not part of this batch`);
|
|
290
319
|
}
|
|
291
|
-
return
|
|
320
|
+
return chunkRun;
|
|
292
321
|
}
|
|
293
322
|
|
|
294
|
-
private
|
|
295
|
-
|
|
296
|
-
|
|
323
|
+
private _getChunkDeploymentAsync(chunkRun: ChunkRun): Promise<Deployment> {
|
|
324
|
+
if (!chunkRun.deployPromise) {
|
|
325
|
+
chunkRun.deployPromise = this._doChunkDeployAsync(chunkRun);
|
|
326
|
+
// A failed upload is retried with its chunk, not remembered as failed
|
|
327
|
+
chunkRun.deployPromise.catch(() => {
|
|
328
|
+
chunkRun.deployPromise = undefined;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return chunkRun.deployPromise;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async _doChunkDeployAsync(chunkRun: ChunkRun): Promise<Deployment> {
|
|
335
|
+
const { primaryTarget } = await this._getCombinedBuildAsync();
|
|
297
336
|
|
|
298
337
|
const builtPlace: BuiltPlace = {
|
|
299
|
-
rbxlPath:
|
|
338
|
+
rbxlPath: chunkRun.chunk.rbxlPath,
|
|
300
339
|
target: primaryTarget,
|
|
301
340
|
};
|
|
302
341
|
|
|
342
|
+
// Every chunk uploads to the same place. Each upload is its own version and
|
|
343
|
+
// each execution is pinned to the version it was given, so a later upload
|
|
344
|
+
// cannot change what an earlier chunk runs against.
|
|
303
345
|
const deployment = await this._inner.deployBuiltPlaceAsync({
|
|
304
346
|
builtPlace,
|
|
305
347
|
packageName: '_batch_',
|
|
306
348
|
packagePath: this._repoRoot,
|
|
307
349
|
});
|
|
308
350
|
|
|
309
|
-
|
|
351
|
+
chunkRun.deployment = deployment;
|
|
310
352
|
return deployment;
|
|
311
353
|
}
|
|
312
354
|
|
|
@@ -322,9 +364,48 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
322
364
|
}
|
|
323
365
|
|
|
324
366
|
private async _doBatchExecutionAsync(): Promise<BatchExecutionOutcome> {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
367
|
+
const chunkRuns = await this._getChunkRunsAsync();
|
|
368
|
+
|
|
369
|
+
if (chunkRuns.length > 1) {
|
|
370
|
+
const sizes = chunkRuns
|
|
371
|
+
.map(
|
|
372
|
+
({ chunk }) =>
|
|
373
|
+
`${chunk.slugMap.size} packages, ${formatMegabytes(
|
|
374
|
+
chunk.sizeBytes
|
|
375
|
+
)}`
|
|
376
|
+
)
|
|
377
|
+
.join('; ');
|
|
378
|
+
OutputHelper.info(`Running ${chunkRuns.length} places: ${sizes}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// All at once: every upload is its own version and each execution is
|
|
382
|
+
// pinned to the version it was given, so the places cannot interfere.
|
|
383
|
+
// Promise.all keeps chunk order, so the merged log stays in package order.
|
|
384
|
+
const outcomes = await Promise.all(
|
|
385
|
+
chunkRuns.map((chunkRun) => this._getChunkExecutionAsync(chunkRun))
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
return _mergeOutcomes(outcomes);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private _getChunkExecutionAsync(
|
|
392
|
+
chunkRun: ChunkRun
|
|
393
|
+
): Promise<BatchExecutionOutcome> {
|
|
394
|
+
if (!chunkRun.executionPromise) {
|
|
395
|
+
chunkRun.executionPromise = this._doChunkExecutionAsync(chunkRun);
|
|
396
|
+
// Cleared on rejection, so a retry re-runs only the chunks that failed
|
|
397
|
+
chunkRun.executionPromise.catch(() => {
|
|
398
|
+
chunkRun.executionPromise = undefined;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return chunkRun.executionPromise;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async _doChunkExecutionAsync(
|
|
405
|
+
chunkRun: ChunkRun
|
|
406
|
+
): Promise<BatchExecutionOutcome> {
|
|
407
|
+
const deployment = await this._getChunkDeploymentAsync(chunkRun);
|
|
408
|
+
const { slugMap } = chunkRun.chunk;
|
|
328
409
|
|
|
329
410
|
// Build the batch Luau script from the template
|
|
330
411
|
const templatePath = resolveTemplatePath(
|
|
@@ -404,3 +485,31 @@ export class BatchScriptJobContext implements JobContext {
|
|
|
404
485
|
};
|
|
405
486
|
}
|
|
406
487
|
}
|
|
488
|
+
|
|
489
|
+
/** Fold each chunk's outcome into one, in chunk order. */
|
|
490
|
+
function _mergeOutcomes(
|
|
491
|
+
outcomes: BatchExecutionOutcome[]
|
|
492
|
+
): BatchExecutionOutcome {
|
|
493
|
+
if (outcomes.length === 1) {
|
|
494
|
+
return outcomes[0]!;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const allStats = outcomes.map((o) => o.stats);
|
|
498
|
+
const stats = allStats.every((s): s is LogFetchStats => s !== undefined)
|
|
499
|
+
? allStats.reduce((sum, s) => ({
|
|
500
|
+
requests: sum.requests + s.requests,
|
|
501
|
+
pages: sum.pages + s.pages,
|
|
502
|
+
entries: sum.entries + s.entries,
|
|
503
|
+
messages: sum.messages + s.messages,
|
|
504
|
+
chars: sum.chars + s.chars,
|
|
505
|
+
}))
|
|
506
|
+
: undefined;
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
results: new Map(outcomes.flatMap((o) => [...o.results])),
|
|
510
|
+
lines: outcomes.flatMap((o) => o.lines),
|
|
511
|
+
stats,
|
|
512
|
+
diagnostics: outcomes.flatMap((o) => o.diagnostics),
|
|
513
|
+
slugToPackage: new Map(outcomes.flatMap((o) => [...o.slugToPackage])),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
@@ -13,6 +13,20 @@ import {
|
|
|
13
13
|
type ParsedTestLogs,
|
|
14
14
|
} from '../testing/test-log-parser.js';
|
|
15
15
|
import { type LogFetchStats } from '../testing/log-fetch-stats.js';
|
|
16
|
+
import { recompressPlace } from '../build/place-recompression.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The largest place file the place version endpoint accepts, measured: 150 MiB
|
|
20
|
+
* exactly uploads, one byte more is refused from `Content-Length` before the
|
|
21
|
+
* body is sent, as `400 InvalidRequest: "Request upload was interrupted before
|
|
22
|
+
* completion."` — and somewhere past ~200 MB as `413 Payload Too Large`.
|
|
23
|
+
* Neither says anything about size, so it is checked here first.
|
|
24
|
+
*
|
|
25
|
+
* Not the 100 MB Roblox documents as supported
|
|
26
|
+
* (https://create.roblox.com/docs/projects/place-files); that is where Studio
|
|
27
|
+
* saving "might fail", not where the API cuts off.
|
|
28
|
+
*/
|
|
29
|
+
export const PLACE_UPLOAD_MAX_BYTES = 150 * 1024 * 1024;
|
|
16
30
|
|
|
17
31
|
/**
|
|
18
32
|
* One engine message, with the severity Open Cloud reported for it.
|
|
@@ -276,8 +290,20 @@ export class OpenCloudClient {
|
|
|
276
290
|
);
|
|
277
291
|
|
|
278
292
|
const apiKey = await this._resolveApiKeyAsync();
|
|
279
|
-
const fileBuffer =
|
|
293
|
+
const fileBuffer = _recompressForUpload(
|
|
294
|
+
rbxlPath,
|
|
295
|
+
await fs.readFile(rbxlPath)
|
|
296
|
+
);
|
|
280
297
|
const totalBytes = fileBuffer.length;
|
|
298
|
+
if (totalBytes > PLACE_UPLOAD_MAX_BYTES) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Upload failed: ${path.basename(rbxlPath)} is ${_formatMegabytes(
|
|
301
|
+
totalBytes
|
|
302
|
+
)}, over the Open Cloud ${_formatMegabytes(
|
|
303
|
+
PLACE_UPLOAD_MAX_BYTES
|
|
304
|
+
)} place upload limit`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
281
307
|
const versionType = publish ? 'Published' : 'Saved';
|
|
282
308
|
const url = `https://apis.roblox.com/universes/v1/${universeId}/places/${placeId}/versions?versionType=${versionType}`;
|
|
283
309
|
|
|
@@ -1097,3 +1123,34 @@ async function _readBodyWithProgressAsync(
|
|
|
1097
1123
|
|
|
1098
1124
|
return Buffer.concat(chunks);
|
|
1099
1125
|
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* The bytes to upload for a place file: recompressed when that is possible,
|
|
1129
|
+
* the file as read when it is not. An upload never fails for want of zstd —
|
|
1130
|
+
* it only goes up larger.
|
|
1131
|
+
*/
|
|
1132
|
+
function _recompressForUpload(rbxlPath: string, fileBuffer: Buffer): Buffer {
|
|
1133
|
+
const name = path.basename(rbxlPath);
|
|
1134
|
+
const result = recompressPlace(fileBuffer);
|
|
1135
|
+
|
|
1136
|
+
if (result.kind === 'recompressed') {
|
|
1137
|
+
OutputHelper.verbose(
|
|
1138
|
+
`Recompressed ${name}: ${_formatMegabytes(
|
|
1139
|
+
result.originalBytes
|
|
1140
|
+
)} -> ${_formatMegabytes(result.data.length)}`
|
|
1141
|
+
);
|
|
1142
|
+
} else if (result.kind === 'unsupported') {
|
|
1143
|
+
OutputHelper.warn(
|
|
1144
|
+
`Uploading ${name} as built (${_formatMegabytes(
|
|
1145
|
+
fileBuffer.length
|
|
1146
|
+
)}) without recompressing it: ${result.reason}`
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
return result.data;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** In Roblox's MB, which are 1024 * 1024 bytes. */
|
|
1154
|
+
function _formatMegabytes(bytes: number): string {
|
|
1155
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
1156
|
+
}
|