/** * AI Software Factory — fixture: 500 MB SPARSE file generator (Phase 9, * TASK-0105). * * The paid-plan per-file ceiling is 500 MB, and the ROADMAP exit criterion is * "upload of a 500 MB file works". Running that on a CI runner must not cost * 500 MB of DISK: `createSparseFile` writes a file whose LOGICAL size is * `sizeBytes` but whose physical usage is a single small block — the content * is a short marker at offset 0 followed by a file-system HOLE (a `truncate` * extend, which allocates no data blocks). Holes read back as zeros, so the * 500 MB buffer that gets streamed to the presigned PUT is deterministic * (marker + zeros) and the object's sha256 is stable across runs. * * Only real disk cost in the whole 500 MB scenario: * - this file: ~4 KB (one block, ext4/xfs) * - the MinIO object: 500 MB (the point of the test) * * Usage (tests): * * import { SPARSE_500_MB, makeSparse500 } from "../../fixtures/files/make-sparse"; * const sparse = await makeSparse500(); // unique path under os.tmpdir() * await put(sparse.bytes); // 500,000,000 bytes to the presigned PUT * await rm(sparse.path); // ~4 KB on disk * * `makeSparse500` materializes the full buffer, which costs 500 MB of HEAP * (the upload must stream the actual bytes) — disk stays at ~4 KB. */ import { truncateSync, writeFileSync } from "node:fs"; import { readFile, rm, stat, truncate, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; /** The paid-plan per-file ceiling — the exact ROADMAP exit-criterion size. */ export const SPARSE_500_MB = 500_000_000; // 500 MB decimal (matches the plan limit, 500 * 1000 * 1000) /** A short, recognizable marker written at offset 0 (the rest is a hole). */ export const SPARSE_MARKER = Buffer.from( "factory-files-sparse-task-0105 — trailing bytes are a filesystem hole (zeros)", "utf8", ); /** * Create a sparse file of exactly `sizeBytes` LOGICAL size at `filePath`. * * @returns `{ logicalSize, allocatedBytes }` — `allocatedBytes` (st_blocks * * 512) proves the sparse-ness: it is a few KiB, not `sizeBytes`. */ export async function createSparseFile( filePath: string, sizeBytes: number = SPARSE_500_MB, ): Promise<{ logicalSize: number; allocatedBytes: number }> { if (!Number.isInteger(sizeBytes) || sizeBytes < SPARSE_MARKER.length) { throw new Error(`createSparseFile: sizeBytes must be >= ${SPARSE_MARKER.length}, got ${sizeBytes}`); } // 1) the only real data: one small block with the marker await writeFile(filePath, SPARSE_MARKER); // 2) extend to the full logical size — a single seek, NO block allocation await truncate(filePath, sizeBytes); const st = await stat(filePath); return { logicalSize: st.size, allocatedBytes: st.blocks * 512 }; } /** * Convenience: a unique 500 MB sparse file under `os.tmpdir()` plus the full * byte buffer ready for the presigned PUT. Callers delete the file with * `rm(path, { force: true })` when done (it is ~4 KB of disk). */ export async function makeSparse500(): Promise<{ path: string; logicalSize: number; allocatedBytes: number; bytes: Buffer; }> { const file = path.join(os.tmpdir(), `factory-sparse-${Date.now()}-${Math.random().toString(36).slice(2)}.bin`); const created = await createSparseFile(file, SPARSE_500_MB); // Holes read as zeros — the buffer is deterministic (marker + zeros). const bytes = Buffer.from(await readFile(file)); if (bytes.length !== SPARSE_500_MB) { await rm(file, { force: true }); throw new Error(`makeSparse500: expected ${SPARSE_500_MB} bytes, got ${bytes.length}`); } return { path: file, ...created, bytes }; } /** * Sync variant (fixture bootstrap before an async context exists): same * sparse file, no buffer. Kept sync-on-purpose — it is one small write + a * truncate, never a hot path. */ export function createSparseFileSync(filePath: string, sizeBytes: number = SPARSE_500_MB): string { if (!Number.isInteger(sizeBytes) || sizeBytes < SPARSE_MARKER.length) { throw new Error(`createSparseFileSync: sizeBytes must be >= ${SPARSE_MARKER.length}, got ${sizeBytes}`); } writeFileSync(filePath, SPARSE_MARKER); truncateSync(filePath, sizeBytes); return filePath; }