diff --git a/bench/bun-provider.bench.ts b/bench/bun-provider.bench.ts index 8e3bdea..c7a19b1 100644 --- a/bench/bun-provider.bench.ts +++ b/bench/bun-provider.bench.ts @@ -6,9 +6,16 @@ import { createFileSystem } from "../mod.ts"; import { createObjectAdapter } from "../src/adapter/object.ts"; import { createS3Client } from "../src/s3.ts"; -/** Local S3-compatible fixture shared with the provider test matrix. */ -const S3_ENDPOINT = "http://127.0.0.1:8333"; -const BUCKET = "opfs-test"; +import { S3_ACCESS_KEY, S3_SECRET_KEY, STORAGE_NAME } from "../tests/provider/fixture.ts"; + +/** SeaweedFS endpoint started by the Node Testcontainers benchmark owner. */ +const S3_ENDPOINT = Bun.env.OPFS_S3_ENDPOINT; +if (S3_ENDPOINT === undefined || S3_ENDPOINT.length === 0) { + throw new Error("OPFS_S3_ENDPOINT must be supplied by bench/providers.ts."); +} +/** Logical bucket shared with the other provider baselines. */ +const BUCKET = STORAGE_NAME; +/** Unique namespace prevents concurrent Bun benchmark runs from colliding. */ const PREFIX = `bench/bun/${crypto.randomUUID()}`; /** Small payload keeps client and abstraction overhead visible on loopback. */ const payload = new Uint8Array(256 * 1024); @@ -22,8 +29,8 @@ const bun = new BunS3Client({ endpoint: S3_ENDPOINT, bucket: BUCKET, region: "us-east-1", - accessKeyId: "admin", - secretAccessKey: "secret", + accessKeyId: S3_ACCESS_KEY, + secretAccessKey: S3_SECRET_KEY, virtualHostedStyle: false, retry: 0, partSize: 5 * 1024 * 1024, @@ -34,23 +41,28 @@ const s3 = createS3Client({ endpoint: S3_ENDPOINT, bucket: BUCKET, region: "us-east-1", - credentials: { accessKeyId: "admin", secretAccessKey: "secret" }, + credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY }, request: { retries: 0 }, metrics: "none", partSize: 5 * 1024 * 1024, concurrency: 4, }); +/** Direct object-adapter layer used to isolate translation overhead. */ const adapter = createObjectAdapter(s3, { prefix: `${PREFIX}/adapter` }); +/** Filesystem facade with metrics disabled. */ const facade = createFileSystem(createObjectAdapter(s3, { prefix: `${PREFIX}/facade` }), { coordination: "none", metrics: "none", }); +/** Filesystem facade with basic counters enabled. */ const measured = createFileSystem(createObjectAdapter(s3, { prefix: `${PREFIX}/metrics` }), { coordination: "none", metrics: "basic", }); +/** Stable object key reused by Bun S3 replacement/read samples. */ const bunKey = `${PREFIX}/bun.bin`; +/** Stable object key reused by direct project client samples. */ const directKey = `${PREFIX}/direct.bin`; await bun.write(bunKey, payload); await bun.file(bunKey).stat(); diff --git a/bench/bun.bench.ts b/bench/bun.bench.ts index 9fe3b34..157c6ba 100644 --- a/bench/bun.bench.ts +++ b/bench/bun.bench.ts @@ -6,19 +6,6 @@ import { join } from "node:path"; import { createFileSystem } from "../mod.ts"; import { createBunAdapter } from "../src/adapter/bun.ts"; -/** Minimal Bun runtime shape used by the benchmark without global Bun typings. */ -interface BunRuntimeType { - file(path: string): Blob & { bytes(): Promise }; - write(path: string, data: Blob | Response | ArrayBufferView | ArrayBuffer | string): Promise; -} - -/** Resolves Bun only when the benchmark actually runs under Bun. */ -function getBun(): BunRuntimeType { - const runtime = Reflect.get(globalThis, "Bun") as BunRuntimeType | undefined; - if (runtime === undefined) throw new TypeError("bun.bench.ts requires the Bun runtime."); - return runtime; -} - /** Temporary benchmark root that keeps raw, adapter, and facade data isolated. */ const root = await mkdtemp(join(tmpdir(), "okikio-opfs-bench-")); /** Host directory used by the direct runtime filesystem baseline. */ @@ -41,12 +28,10 @@ const adapter = createBunAdapter({ root: adapterRoot }); const none = createFileSystem(createBunAdapter({ root: noneRoot }), { coordination: "none", metrics: "none" }); /** Filesystem facade measured with same-realm local coordination. */ const local = createFileSystem(createBunAdapter({ root: localRoot }), { coordination: "local", metrics: "none" }); -/** Bun runtime resolved lazily through the ambient global. */ -const bun = getBun(); bench("bun/raw file: 64 KiB replace + read", async () => { - await bun.write(rawPath, payload); - await bun.file(rawPath).bytes(); + await Bun.write(rawPath, payload); + await Bun.file(rawPath).bytes(); }); bench("bun/adapter: 64 KiB replace + read", async () => { @@ -64,7 +49,7 @@ bench("bun/facade local: 64 KiB replace + read", async () => { await local.readFile("/bench.bin"); }); -await bun.write(join(rawRoot, "source.bin"), payload); +await Bun.write(join(rawRoot, "source.bin"), payload); await adapter.writeFile("/source.bin", payload, { mode: "replace" }); await none.writeFile("/source.bin", payload); @@ -73,7 +58,7 @@ bench("bun/raw node fs: 64 KiB copyFile", async () => { }); bench("bun/raw Bun.write: 64 KiB BunFile copy", async () => { - await bun.write(join(rawRoot, "copy-bun.bin"), bun.file(join(rawRoot, "source.bin"))); + await Bun.write(join(rawRoot, "copy-bun.bin"), Bun.file(join(rawRoot, "source.bin"))); }); bench("bun/adapter: 64 KiB native copy", async () => { diff --git a/bench/provider.bench.ts b/bench/provider.bench.ts index d8902cf..88d8230 100644 --- a/bench/provider.bench.ts +++ b/bench/provider.bench.ts @@ -1,3 +1,5 @@ +import { env } from "node:process"; + import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { BlobServiceClient, StorageSharedKeyCredential } from "@azure/storage-blob"; @@ -9,18 +11,34 @@ import { createObjectAdapter } from "../src/adapter/object.ts"; import { createAzureClient } from "../src/azure.ts"; import { createS3Client } from "../src/s3.ts"; -/** Local provider fixture names match tests/provider/compose.yml. */ -const STORAGE_NAME = "opfs-test"; -const S3_ENDPOINT = "http://127.0.0.1:8333"; -const AZURITE_ACCOUNT = "devstoreaccount1"; -const AZURITE_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="; -const AZURE_ENDPOINT = `http://127.0.0.1:10000/${AZURITE_ACCOUNT}`; +import { + AZURE_ACCOUNT, + AZURE_KEY, + S3_ACCESS_KEY, + S3_SECRET_KEY, + STORAGE_NAME, +} from "../tests/provider/fixture.ts"; + +/** Reads one provider endpoint supplied by the Testcontainers benchmark owner. */ +function getEndpoint(name: "OPFS_S3_ENDPOINT" | "OPFS_AZURE_ENDPOINT"): string { + const value = env[name]; + if (value === undefined || value.length === 0) { + throw new Error(`${name} must be supplied by bench/providers.ts.`); + } + return value; +} + +/** SeaweedFS endpoint started outside the timed benchmark region. */ +const S3_ENDPOINT = getEndpoint("OPFS_S3_ENDPOINT"); +/** Azurite Blob endpoint started outside the timed benchmark region. */ +const AZURE_ENDPOINT = getEndpoint("OPFS_AZURE_ENDPOINT"); /** Small transfer keeps request/setup overhead visible instead of saturating loopback bandwidth. */ const payload = new Uint8Array(256 * 1024); payload.fill(7); /** Multipart payload exercises each client's large-write scheduler separately. */ const multipart = new Uint8Array(6 * 1024 * 1024); multipart.fill(11); +/** Unique namespace prevents one benchmark process from colliding with another. */ const prefix = `bench/${crypto.randomUUID()}`; /** Official AWS SDK baseline against the same SeaweedFS endpoint. */ @@ -28,7 +46,7 @@ const aws = new AwsS3Client({ endpoint: S3_ENDPOINT, region: "us-east-1", forcePathStyle: true, - credentials: { accessKeyId: "admin", secretAccessKey: "secret" }, + credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY }, maxAttempts: 1, }); /** Direct project S3 client with retries/metrics disabled for pure path overhead. */ @@ -36,40 +54,48 @@ const s3 = createS3Client({ endpoint: S3_ENDPOINT, bucket: STORAGE_NAME, region: "us-east-1", - credentials: { accessKeyId: "admin", secretAccessKey: "secret" }, + credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY }, request: { retries: 0 }, metrics: "none", partSize: 5 * 1024 * 1024, }); +/** Direct object-adapter layer used to isolate translation overhead from facade overhead. */ const s3Adapter = createObjectAdapter(s3, { prefix: `${prefix}/s3-adapter` }); +/** Filesystem facade with instrumentation disabled for the lowest-overhead facade comparison. */ const s3Facade = createFileSystem(createObjectAdapter(s3, { prefix: `${prefix}/s3-facade` }), { coordination: "none", metrics: "none", }); +/** Filesystem facade with basic counters enabled to measure instrumentation cost. */ const s3Measured = createFileSystem(createObjectAdapter(s3, { prefix: `${prefix}/s3-metrics` }), { coordination: "none", metrics: "basic", }); /** Official Azure SDK baseline against the same Azurite endpoint. */ -const azureCredential = new StorageSharedKeyCredential(AZURITE_ACCOUNT, AZURITE_KEY); +const azureCredential = new StorageSharedKeyCredential(AZURE_ACCOUNT, AZURE_KEY); +/** Official Azure service client used only as the provider SDK baseline. */ const azureService = new BlobServiceClient(AZURE_ENDPOINT, azureCredential); +/** Official Azure container client scoped to the same logical container as project tests. */ const azureContainer = azureService.getContainerClient(STORAGE_NAME); await azureContainer.createIfNotExists(); /** Direct project Azure client with retries/metrics disabled for pure path overhead. */ const azure = createAzureClient({ endpoint: AZURE_ENDPOINT, container: STORAGE_NAME, - credential: { kind: "shared-key", account: AZURITE_ACCOUNT, key: AZURITE_KEY }, + credential: { kind: "shared-key", account: AZURE_ACCOUNT, key: AZURE_KEY }, request: { retries: 0 }, metrics: "none", blockSize: 1024 * 1024, }); +/** Direct Azure object-adapter layer used to isolate translation overhead. */ const azureAdapter = createObjectAdapter(azure, { prefix: `${prefix}/azure-adapter` }); +/** Azure facade with metrics disabled for the lowest-overhead facade comparison. */ const azureFacade = createFileSystem(createObjectAdapter(azure, { prefix: `${prefix}/azure-facade` }), { coordination: "none", metrics: "none", }); +/** Azure facade with basic counters enabled to measure instrumentation cost. */ const azureMeasured = createFileSystem(createObjectAdapter(azure, { prefix: `${prefix}/azure-metrics` }), { coordination: "none", metrics: "basic", @@ -91,9 +117,13 @@ function stream(bytes: Uint8Array): ReadableStream { }); } +/** Stable object key reused by the AWS SDK replacement/read samples. */ const awsKey = `${prefix}/aws.bin`; +/** Stable object key reused by the direct S3 client samples. */ const s3Key = `${prefix}/s3-client.bin`; +/** Stable blob key reused by the direct Azure client samples. */ const azureKey = `${prefix}/azure-client.bin`; +/** Official SDK blob client reused by replacement/read samples. */ const azureOfficial = azureContainer.getBlockBlobClient(`${prefix}/azure-sdk.bin`); await aws.send(new PutObjectCommand({ Bucket: STORAGE_NAME, Key: awsKey, Body: payload })); await s3.put(s3Key, payload); diff --git a/bench/providers.ts b/bench/providers.ts new file mode 100644 index 0000000..65e6b38 --- /dev/null +++ b/bench/providers.ts @@ -0,0 +1,47 @@ +import { spawn } from "node:child_process"; +import { env, execPath } from "node:process"; + +import { openProviders } from "../tests/provider/fixture.ts"; + +/** Environment names consumed by the provider benchmark programs. */ +interface ProviderEnvType extends NodeJS.ProcessEnv { + /** Testcontainers-selected host endpoint for the S3-compatible service. */ + OPFS_S3_ENDPOINT: string; + /** Testcontainers-selected host endpoint for the Azurite Blob service. */ + OPFS_AZURE_ENDPOINT: string; +} + +/** Runs one benchmark program with inherited stdio and fails on a non-zero exit. */ +async function run(command: string, args: readonly string[], providerEnv: ProviderEnvType): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: providerEnv, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${command} ${args.join(" ")} exited with ${code ?? signal ?? "unknown status"}.`)); + }); + }); +} + +/** Creates the child-process environment after provider endpoints are known. */ +function getProviderEnv(s3Endpoint: string, azureEndpoint: string): ProviderEnvType { + return { + ...env, + OPFS_S3_ENDPOINT: s3Endpoint, + OPFS_AZURE_ENDPOINT: azureEndpoint, + } as ProviderEnvType; +} + +/** Provider services live outside timed benchmark programs and close after both programs finish. */ +await using providers = await openProviders(); +/** Child-process environment carrying the Testcontainers-selected endpoints. */ +const providerEnv = getProviderEnv(providers.s3Endpoint, providers.azureEndpoint); + +await run(execPath, ["bench/provider.bench.ts"], providerEnv); +await run("bun", ["run", "bench/bun-provider.bench.ts"], providerEnv); diff --git a/bench/sqlite.bench.ts b/bench/sqlite.bench.ts index 18601c1..2bc126f 100644 --- a/bench/sqlite.bench.ts +++ b/bench/sqlite.bench.ts @@ -4,24 +4,6 @@ import { DatabaseSync } from "node:sqlite"; import { createFileSystem } from "../mod.ts"; import { createSqliteAdapter } from "../src/adapter/sqlite.ts"; -/** Node SQLite wrapper that matches the direct adapter's small statement contract. */ -class SqliteDatabase { - readonly #database: DatabaseSync; - - constructor(database: DatabaseSync) { - this.#database = database; - } - - prepare(sql: string) { - const statement = this.#database.prepare(sql); - return { - all: async (...params: never[]) => statement.all(...params), - get: async (...params: never[]) => statement.get(...params), - run: async (...params: never[]) => statement.run(...params), - }; - } -} - /** Fixed 64 KiB payload shared by raw SQLite, adapter, and facade measurements. */ const payload = new Uint8Array(64 * 1024); /** Raw in-memory Node SQLite database used as the backend baseline. */ @@ -37,12 +19,9 @@ const adapterDatabase = new DatabaseSync(":memory:"); /** Dedicated in-memory SQLite database used by the filesystem facade measurement. */ const facadeDatabase = new DatabaseSync(":memory:"); /** Direct SQLite adapter measured without facade semantics. */ -const adapter = await createSqliteAdapter(new SqliteDatabase(adapterDatabase)); +const adapter = await createSqliteAdapter(adapterDatabase); /** Filesystem facade backed by SQLite with coordination disabled. */ -const fileSystem = createFileSystem(await createSqliteAdapter(new SqliteDatabase(facadeDatabase)), { - coordination: "none", - metrics: "none", -}); +const fileSystem = createFileSystem(await createSqliteAdapter(facadeDatabase), { coordination: "none", metrics: "none" }); bench("sqlite/raw BLOB: 64 KiB replace + get", () => { rawSet.run("/bench.bin", payload);