From e050e988e68623a38162ece018c157b032a02499 Mon Sep 17 00:00:00 2001 From: Ethan Davidson <31261035+EthanThatOneKid@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:51:47 -0700 Subject: [PATCH] fill examples and benchmarks gaps - Add examples/libsql-hello-world with main.ts and README - Add benchmarks/ with SPARQL perf, hybrid search, idempotency, index maintenance, and synchronization benches - Add shared helpers (synthetic-data, perf-db-cache, sparql-perf-shared) - Add bench tasks and example task to deno.json - Expand README.md with install, usage, and publishing docs - Fix AGENTS.md (remove stray Deno KV copy-paste) --- AGENTS.md | 2 - README.md | 47 ++- benchmarks/README.md | 182 +++++++++++ .../discussion-69-hexastore-perf-draft.md | 45 +++ benchmarks/hybrid-search.bench.ts | 86 +++++ benchmarks/idempotency-guard.bench.ts | 60 ++++ benchmarks/index-maintenance.bench.ts | 55 ++++ benchmarks/shared/perf-db-cache.test.ts | 129 ++++++++ benchmarks/shared/perf-db-cache.ts | 282 +++++++++++++++++ benchmarks/shared/sparql-perf-shared.ts | 296 ++++++++++++++++++ benchmarks/shared/synthetic-data.ts | 33 ++ benchmarks/sparql-perf-large-libsql.bench.ts | 28 ++ benchmarks/sparql-perf-libsql.bench.ts | 21 ++ benchmarks/synchronization.bench.ts | 69 ++++ deno.json | 9 +- examples/libsql-hello-world/README.md | 56 ++++ examples/libsql-hello-world/main.ts | 28 ++ 17 files changed, 1422 insertions(+), 6 deletions(-) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/discussion-69-hexastore-perf-draft.md create mode 100644 benchmarks/hybrid-search.bench.ts create mode 100644 benchmarks/idempotency-guard.bench.ts create mode 100644 benchmarks/index-maintenance.bench.ts create mode 100644 benchmarks/shared/perf-db-cache.test.ts create mode 100644 benchmarks/shared/perf-db-cache.ts create mode 100644 benchmarks/shared/sparql-perf-shared.ts create mode 100644 benchmarks/shared/synthetic-data.ts create mode 100644 benchmarks/sparql-perf-large-libsql.bench.ts create mode 100644 benchmarks/sparql-perf-libsql.bench.ts create mode 100644 benchmarks/synchronization.bench.ts create mode 100644 examples/libsql-hello-world/README.md create mode 100644 examples/libsql-hello-world/main.ts diff --git a/AGENTS.md b/AGENTS.md index 6d9ab9f..8c9f074 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,5 +6,3 @@ barrel. - Follow the existing JSDoc and naming style in the source files. - Run `deno fmt` before committing, then `deno task ci` before merging. -- For Deno KV work in `@worlds/libsql`, remember to use `--unstable-kv` in tests - and scripts. diff --git a/README.md b/README.md index d93adc9..f477192 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,47 @@ # Worlds LibSQL -Standalone LibSQL package extracted from @worlds/client. +Standalone LibSQL package extracted from +[`@worlds/client`](https://jsr.io/@worlds/client). -- Install: `deno add jsr:@worlds/libsql` -- Development: `deno task ci` +## Install + +```bash +deno add jsr:@worlds/libsql +``` + +## Usage + +```typescript +import { createClient } from "@libsql/client"; +import { createLibsqlClient } from "@worlds/libsql"; +import { LibsqlQuadStore } from "@worlds/libsql/quad-store"; +import { LibsqlSearchIndex } from "@worlds/libsql/search-index"; +import { LibsqlRdfjsStore } from "@worlds/libsql/rdfjs-store"; +``` + +## Development + +```bash +deno task ci +``` + +Dry-run a JSR publish locally: + +```bash +deno task publish:dry +``` + +## Publishing to JSR + +Releases publish automatically when changes merge to `main`. Bump `"version"` in +[`deno.json`](deno.json) in each release PR — JSR rejects duplicate versions. + +One-time setup on [jsr.io/@worlds/libsql](https://jsr.io/@worlds/libsql): + +1. Open package settings and link `https://github.com/wazootech/worlds-libsql`. +2. Enable **GitHub Actions publishing** (OIDC). The + [publish workflow](.github/workflows/publish.yml) uses `id-token: write`; no + JSR token secret is required when OIDC is configured. +3. Confirm your GitHub account can publish to the `@worlds` org. + +After setup, merging to `main` runs CI, a publish dry-run, and `deno publish`. diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..fa0fedc --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,182 @@ +# Benchmarks + +Performance benchmarks for `@worlds/libsql`. **Local only** — there is no CI +regression gate; compare results manually on the same OS and Deno version. + +| Resource | Purpose | +| :----------------------------------------------------------------------------- | :------------------------------------------------------------- | +| [Discussion #69](https://github.com/wazootech/worlds-client-ts/discussions/69) | Canonical post-preload SPARQL quad index perf write-up | +| [Discussion #45](https://github.com/wazootech/worlds-client-ts/discussions/45) | Historical hydrate+N3 vs libsql crossover (pre-preload) | +| [#68](https://github.com/wazootech/worlds-client-ts/issues/68) | Millions-of-quads production guidance (README + query helpers) | + +Do not comment on closed perf threads +([#2](https://github.com/wazootech/worlds-client-ts/issues/2), +[#3](https://github.com/wazootech/worlds-client-ts/issues/3), +[#8](https://github.com/wazootech/worlds-client-ts/issues/8), +[#11](https://github.com/wazootech/worlds-client-ts/issues/11)). File a new +issue with before/after `deno bench` output instead. + +**JSR:** [`@worlds/libsql`](https://jsr.io/@worlds/libsql) is published on JSR. +Tables below reflect **main** branch methodology (module preload); they are not +a substitute for re-running on your machine. + +## Layout + +- `*.bench.ts` — runnable benchmarks (`deno bench` discovers these at the repo + root of `benchmarks/`, not under `shared/`). +- [`shared/`](shared/) — helpers imported by benches (`synthetic-data.ts`, + `sparql-perf-shared.ts`). + +## Run all benchmarks + +```bash +deno task bench +``` + +Or directly: + +```bash +deno bench --allow-all benchmarks/ +``` + +### SPARQL quad index performance + +The LibSQL bench is the production-default quad index execute harness. + +```bash +deno bench --allow-all benchmarks/sparql-perf-libsql.bench.ts +# or +deno task bench:sparql-perf-libsql +``` + +**Default query shape is selective only** (subject-bound +`SELECT ?p ?o WHERE { ?p ?o }`). Unbound dev-scan (`fullScan`) is +opt-in — it is slow and not the production hot path: + +```bash +# .env or shell +BENCH_HEXASTORE_PERF_FULL_SCAN=1 +deno task bench:sparql-perf-libsql:full-scan +``` + +Large benches use the same env via `:full-scan` tasks: + +```bash +deno task bench:sparql-perf-large-libsql:full-scan +``` + +**Large (100k–1M):** separate libsql large bench +([#68](https://github.com/wazootech/worlds-client-ts/issues/68)). Supports +`:reuse` and `:full-scan` tasks. + +Hexastore perf preload uses `searchIndexOnImport: "disabled"` (quads only; the +timed slice is `execute()`). Do **not** call `Client.reindex()` in these +harnesses — it rebuilds FTS/chunks and does not affect execute timings. Batched +quad `INSERT`s speed the untimed preload / `BENCH_REUSE_DB` cache build. + +Apps that need `search()` at scale use normal import with inline indexing +(`"incremental"`, the default), `searchIndexOnImport: "deferred"` (rebuild after +each import), or `searchIndexOnImport: "disabled"` plus `await client.reindex()` +once after bulk load. + +### SPARQL quad index perf at 100k–1M (opt-in, local only) + +Not part of `deno task bench` — preload can take a long time and needs ample RAM +(16 GB+ for 1M preload). + +```bash +deno task bench:sparql-perf-large-libsql +``` + +Or with a larger V8 heap if preload OOMs: + +```bash +deno bench --allow-all --v8-flags=--max-old-space-size=8192 benchmarks/sparql-perf-large-libsql.bench.ts +``` + +Module load logs `console.time` lines per scale. Only `sparqlEngine.execute()` +is timed inside `Deno.bench`. + +For full import + search preload timing (not the quad index perf execute table), +use `searchIndexOnImport: "deferred"` on a dedicated bulk-load client (quads +first, search index rebuilt after import), or `searchIndexOnImport: "disabled"` +followed by `await client.reindex()` when you want quads and search repair as +separate timed steps. + +#### Reusing large fixtures (dev only) + +Opt-in file cache for **large libsqlStore** preload (`BENCH_REUSE_DB=1`). The +first run imports into `benchmarks/.cache/perf-large/` (`libsqlStore-{n}.db`); +later runs open cached storage and skip import when the manifest checksum +matches (corpus version, backend schema version, quad count, quads-only import). +`Deno.bench` still measures `execute()` only. + +```bash +# shell or .env +BENCH_REUSE_DB=1 +deno task bench:sparql-perf-large-libsql:reuse +``` + +Published baselines in the table below use default `:memory:` unless labeled +**file cache**. File-backed execute can differ slightly from `:memory:` (OS page +cache). Invalidate cache: delete `benchmarks/.cache/perf-large/` or bump +`SYNTHETIC_CORPUS_VERSION` or `BENCH_LIBSQL_SCHEMA_VERSION` in +[`shared/perf-db-cache.ts`](shared/perf-db-cache.ts) and +[`shared/synthetic-data.ts`](shared/synthetic-data.ts). Override directory: +`BENCH_DB_CACHE_DIR`. + +## Measurement notes + +Benchmarks preload datasets and SPARQL engines at **module load**; only the hot +path runs inside `benchContext.start()` / `end()`. Write-pressure benches still +create a fresh database per iteration and use `warmup: 5`, `n: 50`. + +- **avg** is the primary signal; compare like-for-like OS and Deno versions + only. +- Large **p99** gaps vs **avg** on older runs usually meant per-iteration import + and GC between timed slices, not multi-second SPARQL alone. After preload, + quad index perf p99 should stay within a fewx of avg. +- Optional GC trace (local only): + + ```bash + deno bench --allow-all --v8-flags=--trace-gc benchmarks/sparql-perf-libsql.bench.ts + ``` + +**Production (millions of quads):** default to +[`createLibsqlClient`](../src/libsql/create-libsql-client.ts) for hybrid search +and faster preload. Track guidance in +[#68](https://github.com/wazootech/worlds-client-ts/issues/68). + +## Baseline table (2026-05-21, pre-preload) + +Captured on **Deno 2.7.14 (Windows x86_64)** before module-level preload. +Historical reference only. + +| Benchmark | Avg | +| :--------------------------------------------- | :------------------------ | +| Import 10 / 100 / 1000 quads | 4.9 ms / 57.3 ms / 613 ms | +| Full graph export 100 / 1k / 5k | 3.1 ms / 12.6 ms / 152 ms | +| FTS search (2k corpus) specific / multi / miss | 2.1 ms / 10.0 ms / 1.9 ms | + +## Baseline table (post-preload, 2026-05-22) + +Captured on **Deno 2.8.0 (Windows x86_64)** with module-level preload. + +| Benchmark | Avg | +| :--------------------------------------------- | :------------------------- | +| Import 10 / 100 / 1000 quads | 4.3 ms / 60.5 ms / 615 ms | +| Full graph export 100 / 1k / 5k | 1.4 ms / 14.1 ms / 73.0 ms | +| FTS search (2k corpus) specific / multi / miss | 997 us / 7.3 ms / 948 us | + +## Regression policy + +- Investigate when a keyed benchmark regresses by **more than ~15%** average vs + the post-preload table on the same OS and Deno version. +- Open a **new issue** with pasted before/after `deno bench` output. +- Link + [discussion #69](https://github.com/wazootech/worlds-client-ts/discussions/69) + when SPARQL quad index perf numbers change. + +```bash +deno task bench +``` diff --git a/benchmarks/discussion-69-hexastore-perf-draft.md b/benchmarks/discussion-69-hexastore-perf-draft.md new file mode 100644 index 0000000..87a84df --- /dev/null +++ b/benchmarks/discussion-69-hexastore-perf-draft.md @@ -0,0 +1,45 @@ +# SPARQL hexastore performance (libsql) — selective only + +Captured **2026-05-27** on **Windows x86_64**, **Deno 2.8.0**. Standard scales +1k–50k. Synthetic corpus `SYNTHETIC_CORPUS_VERSION = 1`. + +## Methodology + +- **Preload** (untimed): `console.time` at module load — generate synthetic + quads, import into backend (`searchIndexOnImport: "disabled"`), wire Comunica + `queryEngine`. +- **Execute** (timed): `Deno.bench` calls `sparqlEngine.execute()` only, + post-preload. +- **Query shape**: **selective** — `SELECT ?p ?o WHERE { ?p ?o }` + (subject-bound; production hot path). +- **Not measured**: peak RSS / heap (profile preload separately if needed). + +Unbound `?s ?p ?o LIMIT 100` (**fullScan**) benches are opt-in +(`BENCH_HEXASTORE_PERF_FULL_SCAN=1`); skipped here — slow and not the primary +integration shape. + +## Preload (import + engine wiring) + +| Quads | libsqlStore | +| :----- | :---------- | +| 1 000 | 139 ms | +| 5 000 | 584 ms | +| 10 000 | 874 ms | +| 25 000 | 2.2 s | +| 50 000 | 4.4 s | + +## Execute (selective SPARQL avg) + +| Quads | libsqlStore | +| :----- | :---------- | +| 1 000 | 2.9 ms | +| 5 000 | 4.8 ms | +| 10 000 | 8.1 ms | +| 25 000 | 19.2 ms | +| 50 000 | 32.7 ms | + +## Commands + +```bash +deno task bench:sparql-perf-libsql +``` diff --git a/benchmarks/hybrid-search.bench.ts b/benchmarks/hybrid-search.bench.ts new file mode 100644 index 0000000..e445942 --- /dev/null +++ b/benchmarks/hybrid-search.bench.ts @@ -0,0 +1,86 @@ +import { createClient } from "@libsql/client"; +import { LibsqlSearchIndex } from "@/libsql/search-index/libsql-search-index.ts"; +import { FakeEmbeddingService } from "@worlds/client/search-index/embedding-service"; +import type { EmbeddingService } from "@worlds/client/search-index/embedding-service"; +import { + setupLibsqlSchemaForTest, + testLibsqlSearchQueryBuilder, +} from "@/libsql/libsql-test-fixtures.ts"; + +class FailingEmbeddingService implements EmbeddingService { + public embed(_texts: string[]): Promise> { + return Promise.reject( + new Error("Simulated network timeout/offline service."), + ); + } +} + +const databaseClient = createClient({ url: ":memory:" }); +await setupLibsqlSchemaForTest(databaseClient); + +const vectorArray = new Array(32).fill(0); +vectorArray[0] = 1.0; +const vectorJsonString = JSON.stringify(vectorArray); + +for (let index = 0; index < 1000; index++) { + await databaseClient.execute({ + sql: + `INSERT INTO chunks (quad_id, subject, predicate, graph, value, fts_value, vector) VALUES (?, ?, ?, ?, ?, ?, vector32(?))`, + args: [ + `id-${index}`, + `urn:entity:${index}`, + "urn:property:name", + "urn:graph:main", + `Document payload text index ${index} with unique keywords.`, + `Document payload text index ${index} with unique keywords.`, + vectorJsonString, + ], + }); +} + +const ftsSearchIndex = new LibsqlSearchIndex({ + client: databaseClient, + searchQueryBuilder: testLibsqlSearchQueryBuilder, +}); + +const hybridSearchIndex = new LibsqlSearchIndex({ + client: databaseClient, + embeddingService: new FakeEmbeddingService(), + searchQueryBuilder: testLibsqlSearchQueryBuilder, +}); + +const fallbackSearchIndex = new LibsqlSearchIndex({ + client: databaseClient, + embeddingService: new FailingEmbeddingService(), + searchQueryBuilder: testLibsqlSearchQueryBuilder, +}); + +Deno.bench({ + name: "Search: FTS5 Keyword-Only Search (Vectorless Mode)", + group: "Hybrid Search Performance", + async fn(benchContext) { + benchContext.start(); + await ftsSearchIndex.search({ query: "unique keywords" }); + benchContext.end(); + }, +}); + +Deno.bench({ + name: "Search: Hybrid RRF Fusion Search (Vector + FTS5)", + group: "Hybrid Search Performance", + async fn(benchContext) { + benchContext.start(); + await hybridSearchIndex.search({ query: "unique keywords" }); + benchContext.end(); + }, +}); + +Deno.bench({ + name: "Search: Graceful Degradation (Fallback to FTS5 on Error)", + group: "Hybrid Search Performance", + async fn(benchContext) { + benchContext.start(); + await fallbackSearchIndex.search({ query: "unique keywords" }); + benchContext.end(); + }, +}); diff --git a/benchmarks/idempotency-guard.bench.ts b/benchmarks/idempotency-guard.bench.ts new file mode 100644 index 0000000..edc7da9 --- /dev/null +++ b/benchmarks/idempotency-guard.bench.ts @@ -0,0 +1,60 @@ +import { createClient } from "@libsql/client"; +import { DataFactory } from "n3"; +import { createLibsqlClient } from "@/libsql/mod.ts"; +import { generateSyntheticQuads } from "./shared/synthetic-data.ts"; + +const { quad, namedNode, literal } = DataFactory; + +const databaseClient = createClient({ url: ":memory:" }); +const client = await createLibsqlClient({ + client: databaseClient, + searchIndexOnImport: "disabled", +}); + +const payload100 = generateSyntheticQuads(100); + +let indexCounter = 1000; +function generateFreshPayload(count: number) { + const freshQuads = []; + for (let i = 0; i < count; i++) { + indexCounter++; + freshQuads.push( + quad( + namedNode(`urn:entity:fresh:${indexCounter}`), + namedNode("urn:property:name"), + literal(`Fresh payload text for unique entity number ${indexCounter}`), + ), + ); + } + return freshQuads; +} + +await client.import({ + source: { kind: "quads", quads: payload100 }, +}); + +Deno.bench({ + name: "Idempotency: Novel Insert (New Quads Ingested)", + group: "Idempotency Guard Performance", + async fn(benchContext) { + const freshPayload = generateFreshPayload(100); + + benchContext.start(); + await client.import({ + source: { kind: "quads", quads: freshPayload }, + }); + benchContext.end(); + }, +}); + +Deno.bench({ + name: "Idempotency: Redundant Insert (Duplicate Quads Suppressed)", + group: "Idempotency Guard Performance", + async fn(benchContext) { + benchContext.start(); + await client.import({ + source: { kind: "quads", quads: payload100 }, + }); + benchContext.end(); + }, +}); diff --git a/benchmarks/index-maintenance.bench.ts b/benchmarks/index-maintenance.bench.ts new file mode 100644 index 0000000..207b438 --- /dev/null +++ b/benchmarks/index-maintenance.bench.ts @@ -0,0 +1,55 @@ +import { createClient } from "@libsql/client"; +import { createLibsqlClient } from "@/libsql/mod.ts"; +import { rebuildLibsqlSearchIndexFromQuads } from "@/libsql/search-index/rebuild-libsql-search-index-from-quads.ts"; +import { refreshSearchChunksForSubjects } from "@/libsql/search-index/refresh-search-chunks-for-subjects.ts"; +import { FakeEmbeddingService } from "@worlds/client/search-index/embedding-service"; +import { + setupLibsqlSchemaForTest, + sharedTextSplitter, + testLibsqlSearchQueryBuilder, +} from "@/libsql/libsql-test-fixtures.ts"; +import { generateSyntheticQuads } from "./shared/synthetic-data.ts"; + +const databaseClient = createClient({ url: ":memory:" }); +await setupLibsqlSchemaForTest(databaseClient); + +const worldsClient = await createLibsqlClient({ + client: databaseClient, + searchIndexOnImport: "disabled", +}); + +const sampleQuads = generateSyntheticQuads(1000); +await worldsClient.import({ + source: { kind: "quads", quads: sampleQuads }, +}); + +const sampleSubjects = sampleQuads.map((quad) => quad.subject.value); + +const maintenanceOptions = { + client: databaseClient, + searchQueryBuilder: testLibsqlSearchQueryBuilder, + embeddingService: new FakeEmbeddingService(), + textSplitter: sharedTextSplitter, +}; + +Deno.bench({ + name: "Maintenance: Subject-scoped Refresh (100 Subjects)", + group: "Index Maintenance", + async fn(benchContext) { + const subjectsToRefresh = sampleSubjects.slice(0, 100); + + benchContext.start(); + await refreshSearchChunksForSubjects(subjectsToRefresh, maintenanceOptions); + benchContext.end(); + }, +}); + +Deno.bench({ + name: "Maintenance: Full Index Rebuild (1,000 Quads)", + group: "Index Maintenance", + async fn(benchContext) { + benchContext.start(); + await rebuildLibsqlSearchIndexFromQuads(maintenanceOptions); + benchContext.end(); + }, +}); diff --git a/benchmarks/shared/perf-db-cache.test.ts b/benchmarks/shared/perf-db-cache.test.ts new file mode 100644 index 0000000..b451316 --- /dev/null +++ b/benchmarks/shared/perf-db-cache.test.ts @@ -0,0 +1,129 @@ +import { assertEquals, assertExists, assertNotEquals } from "@std/assert"; +import { createClient } from "@libsql/client"; +import * as path from "@std/path"; +import { createLibsqlClient } from "@worlds/libsql"; +import type { Quad } from "@rdfjs/types"; +import { + buildHexastorePerfFixtureChecksumInputs, + computeHexastorePerfFixtureChecksum, + readHexastorePerfFixtureManifest, + resolveHexastorePerfDbCachePaths, + validateCachedLibsqlHexastorePerfDatabase, + writeHexastorePerfFixtureManifest, +} from "./perf-db-cache.ts"; +import { generateSyntheticQuads } from "./synthetic-data.ts"; + +async function importCorpusIntoLibsqlHexastoreForTest( + databaseClient: ReturnType, + corpusQuads: Quad[], +): Promise { + const worldsClient = await createLibsqlClient({ + client: databaseClient, + searchIndexOnImport: "disabled", + }); + await worldsClient.import({ + source: { kind: "quads", quads: corpusQuads }, + }); +} + +Deno.test( + "computeHexastorePerfFixtureChecksum - stable digest for identical inputs", + async () => { + const checksumInputs = buildHexastorePerfFixtureChecksumInputs( + 1000, + "libsqlStore", + ); + const firstChecksum = await computeHexastorePerfFixtureChecksum( + checksumInputs, + ); + const secondChecksum = await computeHexastorePerfFixtureChecksum( + checksumInputs, + ); + assertEquals(firstChecksum, secondChecksum); + }, +); + +Deno.test( + "computeHexastorePerfFixtureChecksum - different corpus version changes digest", + async () => { + const baselineInputs = buildHexastorePerfFixtureChecksumInputs( + 1000, + "libsqlStore", + ); + const baselineChecksum = await computeHexastorePerfFixtureChecksum( + baselineInputs, + ); + const alteredInputs = { + ...baselineInputs, + syntheticCorpusVersion: baselineInputs.syntheticCorpusVersion + 1, + }; + const alteredChecksum = await computeHexastorePerfFixtureChecksum( + alteredInputs, + ); + assertNotEquals(baselineChecksum, alteredChecksum); + }, +); + +Deno.test( + "resolveHexastorePerfDbCachePaths - names libsqlStore database and manifest files", + () => { + const cachePaths = resolveHexastorePerfDbCachePaths(10, "libsqlStore"); + assertEquals(cachePaths.databasePath.endsWith("libsqlStore-10.db"), true); + assertEquals(cachePaths.manifestPath.endsWith("libsqlStore-10.json"), true); + assertEquals( + cachePaths.databaseFileUrl.includes("libsqlStore-10.db"), + true, + ); + }, +); + +Deno.test( + "validateCachedLibsqlHexastorePerfDatabase - accepts quads-only in-memory fixture", + async () => { + const databaseClient = createClient({ url: ":memory:" }); + try { + await importCorpusIntoLibsqlHexastoreForTest( + databaseClient, + generateSyntheticQuads(10), + ); + const expectedChecksum = await computeHexastorePerfFixtureChecksum( + buildHexastorePerfFixtureChecksumInputs(10, "libsqlStore"), + ); + const isValid = await validateCachedLibsqlHexastorePerfDatabase( + databaseClient, + { quadCount: 10, expectedChecksum }, + ); + assertEquals(isValid, true); + } finally { + databaseClient.close(); + } + }, +); + +Deno.test( + "writeHexastorePerfFixtureManifest - round-trips manifest JSON", + async () => { + const temporaryDirectory = await Deno.makeTempDir(); + const manifestPath = path.join(temporaryDirectory, "libsqlStore-10.json"); + try { + const checksumInputs = buildHexastorePerfFixtureChecksumInputs( + 10, + "libsqlStore", + ); + const expectedChecksum = await computeHexastorePerfFixtureChecksum( + checksumInputs, + ); + const manifest = { ...checksumInputs, checksum: expectedChecksum }; + await writeHexastorePerfFixtureManifest(manifestPath, manifest); + const parsedManifest = await readHexastorePerfFixtureManifest( + manifestPath, + ); + assertExists(parsedManifest); + assertEquals(parsedManifest.checksum, expectedChecksum); + assertEquals(parsedManifest.quadCount, 10); + } finally { + await Deno.remove(manifestPath); + await Deno.remove(temporaryDirectory); + } + }, +); diff --git a/benchmarks/shared/perf-db-cache.ts b/benchmarks/shared/perf-db-cache.ts new file mode 100644 index 0000000..fcc3a3e --- /dev/null +++ b/benchmarks/shared/perf-db-cache.ts @@ -0,0 +1,282 @@ +import { type Client, createClient } from "@libsql/client"; +import { encodeHex } from "@std/encoding/hex"; +import * as path from "@std/path"; +import { SYNTHETIC_CORPUS_VERSION } from "./synthetic-data.ts"; + +/** + * BENCH_LIBSQL_SCHEMA_VERSION bumps when LibSQL quad index schema or perf fixture layout changes. + */ +export const BENCH_LIBSQL_SCHEMA_VERSION = 1; + +/** HexastorePerfCacheBackend labels backends that support large on-disk perf fixture reuse. */ +export type HexastorePerfCacheBackend = "libsqlStore"; + +/** DEFAULT_HEXASTORE_PERF_CACHE_DIR is the default on-disk cache root for large perf fixtures. */ +const BENCHMARKS_ROOT = path.fromFileUrl(new URL("../..", import.meta.url)); + +/** DEFAULT_HEXASTORE_PERF_CACHE_DIR stores large database files under benchmarks/.cache. */ +export const DEFAULT_HEXASTORE_PERF_CACHE_DIR = path.join( + BENCHMARKS_ROOT, + ".cache", + "perf-large", +); + +/** HexastorePerfFixtureChecksumInputsBase lists manifest fields shared across backends. */ +interface HexastorePerfFixtureChecksumInputsBase { + /** syntheticCorpusVersion tracks generateSyntheticQuads revisions. */ + syntheticCorpusVersion: number; + /** quadCount is the synthetic corpus size for this fixture. */ + quadCount: number; + /** searchIndexOnImport records the indexing mode used during import. */ + searchIndexOnImport: "disabled"; +} + +/** + * LibsqlHexastorePerfFixtureChecksumInputs is the canonical checksum input for libsqlStore fixtures. + */ +export interface LibsqlHexastorePerfFixtureChecksumInputs + extends HexastorePerfFixtureChecksumInputsBase { + /** backend labels the quad index wiring. */ + backend: "libsqlStore"; + /** benchLibsqlSchemaVersion tracks LibSQL schema revisions relevant to perf fixtures. */ + benchLibsqlSchemaVersion: number; +} + +/** + * HexastorePerfFixtureChecksumInputs lists manifest fields hashed for bench cache identity. + */ +export type HexastorePerfFixtureChecksumInputs = + LibsqlHexastorePerfFixtureChecksumInputs; + +/** + * HexastorePerfFixtureManifest persists checksum inputs plus the computed digest on disk. + */ +export type HexastorePerfFixtureManifest = + & HexastorePerfFixtureChecksumInputs + & { + /** checksum is the SHA-256 hex digest of canonical HexastorePerfFixtureChecksumInputs JSON. */ + checksum: string; + }; + +/** + * LibsqlHexastorePerfDbCachePaths locates the SQLite file and JSON sidecar for a cached libsql fixture. + */ +export interface LibsqlHexastorePerfDbCachePaths { + /** backend labels the quad index wiring. */ + backend: "libsqlStore"; + /** databasePath is the absolute path to the LibSQL SQLite file. */ + databasePath: string; + /** databaseFileUrl is the libsql client URL for databasePath. */ + databaseFileUrl: string; + /** manifestPath is the absolute path to the JSON manifest sidecar. */ + manifestPath: string; +} + +/** + * HexastorePerfDbCachePaths locates on-disk storage and manifest paths for a cached perf fixture. + */ +export type HexastorePerfDbCachePaths = LibsqlHexastorePerfDbCachePaths; + +/** + * CachedHexastorePerfFixtureValidation holds expected row counts for a cache hit check. + */ +export interface CachedHexastorePerfFixtureValidation { + /** quadCount is the expected number of quads in the fixture. */ + quadCount: number; + /** expectedChecksum is the digest that must match the manifest sidecar. */ + expectedChecksum: string; +} + +/** + * isBenchReuseDbEnabled returns true when BENCH_REUSE_DB=1 enables on-disk perf fixture reuse. + */ +export function isBenchReuseDbEnabled(): boolean { + return Deno.env.get("BENCH_REUSE_DB") === "1"; +} + +/** + * isBenchHexastorePerfFullScanEnabled returns true when fullScan benches are registered (opt-in). + */ +export function isBenchHexastorePerfFullScanEnabled(): boolean { + return Deno.env.get("BENCH_HEXASTORE_PERF_FULL_SCAN") === "1" || + Deno.env.get("BENCH_CROSSOVER_FULL_SCAN") === "1"; +} + +/** + * resolveHexastorePerfDbCacheDirectory returns the cache root from BENCH_DB_CACHE_DIR or the default. + */ +export function resolveHexastorePerfDbCacheDirectory(): string { + const overrideDirectory = Deno.env.get("BENCH_DB_CACHE_DIR"); + if (overrideDirectory && overrideDirectory.length > 0) { + return path.resolve(overrideDirectory); + } + return DEFAULT_HEXASTORE_PERF_CACHE_DIR; +} + +/** + * resolveHexastorePerfDbCachePaths builds storage and manifest paths for a large libsqlStore scale. + */ +export function resolveHexastorePerfDbCachePaths( + quadCount: number, + backend: "libsqlStore", +): LibsqlHexastorePerfDbCachePaths { + const cacheDirectory = resolveHexastorePerfDbCacheDirectory(); + const manifestPath = path.join( + cacheDirectory, + `${backend}-${quadCount}.json`, + ); + const databasePath = path.join( + cacheDirectory, + `${backend}-${quadCount}.db`, + ); + return { + backend: "libsqlStore", + databasePath, + databaseFileUrl: path.toFileUrl(databasePath).href, + manifestPath, + }; +} + +/** + * buildHexastorePerfFixtureChecksumInputs constructs canonical checksum inputs for a quads-only fixture. + */ +export function buildHexastorePerfFixtureChecksumInputs( + quadCount: number, + _backend: "libsqlStore", +): LibsqlHexastorePerfFixtureChecksumInputs { + return { + syntheticCorpusVersion: SYNTHETIC_CORPUS_VERSION, + quadCount, + searchIndexOnImport: "disabled", + backend: "libsqlStore", + benchLibsqlSchemaVersion: BENCH_LIBSQL_SCHEMA_VERSION, + }; +} + +/** + * computeHexastorePerfFixtureChecksum returns a SHA-256 hex digest of canonical fixture checksum inputs. + */ +export async function computeHexastorePerfFixtureChecksum( + inputs: HexastorePerfFixtureChecksumInputs, +): Promise { + const canonicalJson = JSON.stringify(inputs); + const encodedInputs = new TextEncoder().encode(canonicalJson); + const digestBuffer = await crypto.subtle.digest("SHA-256", encodedInputs); + return encodeHex(new Uint8Array(digestBuffer)); +} + +/** + * readHexastorePerfFixtureManifest loads a manifest sidecar when present. + */ +export async function readHexastorePerfFixtureManifest( + manifestPath: string, +): Promise { + try { + const manifestText = await Deno.readTextFile(manifestPath); + return JSON.parse(manifestText) as HexastorePerfFixtureManifest; + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return undefined; + } + throw error; + } +} + +/** + * writeHexastorePerfFixtureManifest persists a manifest sidecar after a successful cache build. + */ +export async function writeHexastorePerfFixtureManifest( + manifestPath: string, + manifest: HexastorePerfFixtureManifest, +): Promise { + await Deno.writeTextFile( + manifestPath, + `${JSON.stringify(manifest, null, 2)}\n`, + ); +} + +/** + * ensureHexastorePerfCacheDirectoryExists creates the cache directory when missing. + */ +export async function ensureHexastorePerfCacheDirectoryExists( + cacheDirectory: string, +): Promise { + await Deno.mkdir(cacheDirectory, { recursive: true }); +} + +/** + * removeStaleHexastorePerfCacheFiles deletes a partial or invalid storage and manifest pair. + */ +export async function removeStaleHexastorePerfCacheFiles( + cachePaths: HexastorePerfDbCachePaths, +): Promise { + await Promise.all([ + Deno.remove(cachePaths.databasePath).catch(() => undefined), + Deno.remove(cachePaths.manifestPath).catch(() => undefined), + ]); +} + +/** + * validateCachedLibsqlHexastorePerfDatabase checks quad/chunk counts for a quads-only cache hit. + */ +export async function validateCachedLibsqlHexastorePerfDatabase( + databaseClient: Client, + validation: CachedHexastorePerfFixtureValidation, +): Promise { + const quadCountResult = await databaseClient.execute( + "SELECT COUNT(*) AS total FROM quads", + ); + const chunkCountResult = await databaseClient.execute( + "SELECT COUNT(*) AS total FROM chunks", + ); + const storedQuadCount = Number(quadCountResult.rows[0]?.total ?? -1); + const storedChunkCount = Number(chunkCountResult.rows[0]?.total ?? -1); + return storedQuadCount === validation.quadCount && + storedChunkCount === 0; +} + +/** + * tryResolveHexastorePerfCacheHit validates manifest and storage state; logs cache miss reasons. + */ +export async function tryResolveHexastorePerfCacheHit( + cachePaths: HexastorePerfDbCachePaths, + expectedChecksum: string, + quadCount: number, +): Promise { + const manifest = await readHexastorePerfFixtureManifest( + cachePaths.manifestPath, + ); + if (!manifest) { + console.log(`cache miss (${cachePaths.manifestPath}: no manifest)`); + return false; + } + if (manifest.checksum !== expectedChecksum) { + console.log("cache miss (manifest checksum mismatch)"); + return false; + } + + try { + await Deno.stat(cachePaths.databasePath); + } catch { + console.log(`cache miss (${cachePaths.databasePath}: database missing)`); + return false; + } + + const databaseClient = createClient({ url: cachePaths.databaseFileUrl }); + + try { + const isValid = await validateCachedLibsqlHexastorePerfDatabase( + databaseClient, + { + quadCount, + expectedChecksum, + }, + ); + if (!isValid) { + console.log("cache miss (quad or chunk count mismatch)"); + } + return isValid; + } finally { + databaseClient.close(); + } +} diff --git a/benchmarks/shared/sparql-perf-shared.ts b/benchmarks/shared/sparql-perf-shared.ts new file mode 100644 index 0000000..e810cb4 --- /dev/null +++ b/benchmarks/shared/sparql-perf-shared.ts @@ -0,0 +1,296 @@ +import { createClient } from "@libsql/client"; +import type { Quad } from "@rdfjs/types"; +import { QueryEngine } from "@comunica/query-sparql-rdfjs-lite"; +import type { ClientInterface } from "@worlds/client"; +import { createLibsqlClient } from "@worlds/libsql"; +import { + buildHexastorePerfFixtureChecksumInputs, + computeHexastorePerfFixtureChecksum, + ensureHexastorePerfCacheDirectoryExists, + isBenchHexastorePerfFullScanEnabled, + removeStaleHexastorePerfCacheFiles, + resolveHexastorePerfDbCacheDirectory, + resolveHexastorePerfDbCachePaths, + tryResolveHexastorePerfCacheHit, + writeHexastorePerfFixtureManifest, +} from "./perf-db-cache.ts"; +import { generateSyntheticQuads } from "./synthetic-data.ts"; + +/** selectiveSubjectIri is the grounded subject for subject-bound SPARQL benchmarks. */ +export const selectiveSubjectIri = "urn:entity:0"; + +/** selectiveSparqlQuery exercises a subject-bound BGP (quad index-friendly). */ +export const selectiveSparqlQuery = + `SELECT ?p ?o WHERE { <${selectiveSubjectIri}> ?p ?o }`; + +/** fullScanSparqlQuery exercises an unbound triple pattern with a small result cap. */ +export const fullScanSparqlQuery = + "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 100"; + +const sharedQueryEngine = new QueryEngine(); + +/** SparqlQueryShape labels the SPARQL quad index perf query patterns. */ +export type SparqlQueryShape = "selective" | "fullScan"; + +/** standardHexastorePerfQueryShapes is the default dev iteration set (subject-bound only). */ +export const standardHexastorePerfQueryShapes = [ + "selective", +] as const satisfies readonly SparqlQueryShape[]; + +/** allHexastorePerfQueryShapes includes the unbound dev-scan shape (opt-in via BENCH_HEXASTORE_PERF_FULL_SCAN=1). */ +export const allHexastorePerfQueryShapes = [ + "selective", + "fullScan", +] as const satisfies readonly SparqlQueryShape[]; + +/** + * resolveHexastorePerfQueryShapes returns query shapes for quad index perf bench registration. + */ +export function resolveHexastorePerfQueryShapes(): readonly SparqlQueryShape[] { + return isBenchHexastorePerfFullScanEnabled() + ? allHexastorePerfQueryShapes + : standardHexastorePerfQueryShapes; +} + +/** SparqlBackend labels the quad index wiring under test. */ +export type SparqlBackend = "libsqlStore"; + +/** libsqlHexastorePerfBackends targets the production LibsqlRdfjsStore quad index path. */ +export const libsqlHexastorePerfBackends = [ + "libsqlStore", +] as const satisfies readonly SparqlBackend[]; + +/** PreloadedSparqlFixture holds a warmed Client and its storage handle. */ +export interface PreloadedSparqlFixture { + /** client executes SPARQL against the preloaded corpus. */ + client: ClientInterface; + /** databaseClient is set for libsqlStore fixtures. */ + databaseClient?: ReturnType; +} + +/** + * PreloadSparqlHexastorePerfOptions configures quad index perf module preload behavior. + */ +export interface PreloadSparqlHexastorePerfOptions { + /** reuseFileCache enables on-disk fixtures for libsqlStore when BENCH_REUSE_DB=1. */ + reuseFileCache?: boolean; +} + +/** + * LibsqlHexastoreFixtureResult reports whether a libsqlStore fixture used the file cache. + */ +interface LibsqlHexastoreFixtureResult { + /** fixture is the warmed SPARQL engine and database handle. */ + fixture: PreloadedSparqlFixture; + /** cacheHit is true when an on-disk database was reused without re-import. */ + cacheHit: boolean; +} + +/** + * sparqlEngineCacheKey builds a stable map key for preloaded quad index perf fixtures. + */ +export function sparqlEngineCacheKey( + backend: SparqlBackend, + quadCount: number, +): string { + return `${backend}:${quadCount}`; +} + +/** + * sparqlQueryForShape returns the SPARQL string for a quad index perf query shape. + */ +export function sparqlQueryForShape(queryShape: SparqlQueryShape): string { + return queryShape === "selective" + ? selectiveSparqlQuery + : fullScanSparqlQuery; +} + +/** + * importCorpusIntoLibsqlHexastore persists quads into LibSQL without timing SPARQL execute. + */ +async function importCorpusIntoLibsqlHexastore( + databaseClient: ReturnType, + corpusQuads: Quad[], +): Promise { + const worldsClient = await createLibsqlClient({ + client: databaseClient, + searchIndexOnImport: "disabled", + }); + await worldsClient.import({ + source: { kind: "quads", quads: corpusQuads }, + }); +} + +/** + * openLibsqlHexastoreSparqlEngine wires Comunica over an existing LibsqlRdfjsStore database. + */ +async function openLibsqlHexastoreSparqlEngine( + databaseClient: ReturnType, +): Promise { + const worldsClient = await createLibsqlClient({ + client: databaseClient, + searchIndexOnImport: "disabled", + queryEngine: sharedQueryEngine, + }); + return { databaseClient, client: worldsClient }; +} + +/** + * createLibsqlHexastoreSparqlEngine wires Comunica over LibsqlRdfjsStore (no N3 hydration). + */ +async function createLibsqlHexastoreSparqlEngine( + corpusQuads: Quad[], + options?: { reuseFileCache?: boolean }, +): Promise { + if (!options?.reuseFileCache) { + const databaseClient = createClient({ url: ":memory:" }); + await importCorpusIntoLibsqlHexastore(databaseClient, corpusQuads); + const fixture = await openLibsqlHexastoreSparqlEngine(databaseClient); + return { fixture, cacheHit: false }; + } + + const quadCount = corpusQuads.length; + const cachePaths = resolveHexastorePerfDbCachePaths(quadCount, "libsqlStore"); + const checksumInputs = buildHexastorePerfFixtureChecksumInputs( + quadCount, + "libsqlStore", + ); + const expectedChecksum = await computeHexastorePerfFixtureChecksum( + checksumInputs, + ); + + const cacheHit = await tryResolveHexastorePerfCacheHit( + cachePaths, + expectedChecksum, + quadCount, + ); + + if (cacheHit) { + const databaseClient = createClient({ url: cachePaths.databaseFileUrl }); + const fixture = await openLibsqlHexastoreSparqlEngine(databaseClient); + return { fixture, cacheHit: true }; + } + + await ensureHexastorePerfCacheDirectoryExists( + resolveHexastorePerfDbCacheDirectory(), + ); + await removeStaleHexastorePerfCacheFiles(cachePaths); + + const databaseClient = createClient({ url: cachePaths.databaseFileUrl }); + await importCorpusIntoLibsqlHexastore(databaseClient, corpusQuads); + + const manifest = { + ...checksumInputs, + checksum: expectedChecksum, + }; + await writeHexastorePerfFixtureManifest(cachePaths.manifestPath, manifest); + + const fixture = await openLibsqlHexastoreSparqlEngine(databaseClient); + return { fixture, cacheHit: false }; +} + +async function createSparqlEngineForBackend( + _backend: SparqlBackend, + corpusQuads: Quad[], + preloadOptions?: PreloadSparqlHexastorePerfOptions, +): Promise<{ fixture: PreloadedSparqlFixture; cacheHit: boolean }> { + const { fixture, cacheHit } = await createLibsqlHexastoreSparqlEngine( + corpusQuads, + { reuseFileCache: preloadOptions?.reuseFileCache }, + ); + return { fixture, cacheHit }; +} + +/** + * preloadSparqlHexastorePerfFixtures builds corpus+engine fixtures at module load. + * Generates synthetic quads once per scale and reuses the array across backends. + */ +export async function preloadSparqlHexastorePerfFixtures( + perfScales: readonly number[], + logPrefix: string, + perfBackends: readonly SparqlBackend[], + preloadOptions?: PreloadSparqlHexastorePerfOptions, +): Promise> { + const preloadedSparqlEngines = new Map(); + + console.log( + `Pre-populating SPARQL quad index perf engines (${logPrefix})...`, + ); + + for (const quadCount of perfScales) { + const corpusGenerationLabel = `${logPrefix} generate ${quadCount} quads`; + console.time(corpusGenerationLabel); + const corpusQuads = generateSyntheticQuads(quadCount); + console.timeEnd(corpusGenerationLabel); + + for (const backend of perfBackends) { + const preloadLabel = `${logPrefix} ${backend} ${quadCount}`; + console.time(preloadLabel); + const { fixture, cacheHit } = await createSparqlEngineForBackend( + backend, + corpusQuads, + preloadOptions, + ); + console.timeEnd(preloadLabel); + if (cacheHit) { + console.log(`${preloadLabel} (cache hit)`); + } else if (preloadOptions?.reuseFileCache) { + console.log(`${preloadLabel} (imported to file cache)`); + } + preloadedSparqlEngines.set( + sparqlEngineCacheKey(backend, quadCount), + fixture, + ); + } + } + + console.log(`SPARQL quad index perf engines ready (${logPrefix}).`); + return preloadedSparqlEngines; +} + +/** + * registerSparqlHexastorePerfUnloadCleanup closes database handles when the bench module unloads. + */ +export function registerSparqlHexastorePerfUnloadCleanup( + preloadedSparqlEngines: Map, +): void { + globalThis.addEventListener("unload", () => { + for (const fixture of preloadedSparqlEngines.values()) { + fixture.databaseClient?.close(); + } + }); +} + +/** + * registerSparqlHexastorePerfBenchmarks registers execute-only quad index perf Deno.bench entries. + */ +export function registerSparqlHexastorePerfBenchmarks( + perfScales: readonly number[], + preloadedSparqlEngines: Map, + perfBackends: readonly SparqlBackend[], + queryShapes: readonly SparqlQueryShape[] = resolveHexastorePerfQueryShapes(), +): void { + for (const quadCount of perfScales) { + for (const queryShape of queryShapes) { + for (const backend of perfBackends) { + const query = sparqlQueryForShape(queryShape); + const cacheKey = sparqlEngineCacheKey(backend, quadCount); + Deno.bench({ + name: + `SPARQL Hexastore Perf: ${quadCount} quads | ${queryShape} | ${backend}`, + group: `SPARQL Hexastore Perf (${quadCount})`, + async fn(benchContext) { + const fixture = preloadedSparqlEngines.get(cacheKey); + if (!fixture) { + throw new Error(`Missing preloaded SPARQL fixture: ${cacheKey}`); + } + + benchContext.start(); + await fixture.client.sparql({ query }); + benchContext.end(); + }, + }); + } + } + } +} diff --git a/benchmarks/shared/synthetic-data.ts b/benchmarks/shared/synthetic-data.ts new file mode 100644 index 0000000..5ecaa54 --- /dev/null +++ b/benchmarks/shared/synthetic-data.ts @@ -0,0 +1,33 @@ +import { DataFactory } from "n3"; +import type { Quad } from "@rdfjs/types"; + +const { quad, namedNode, literal } = DataFactory; + +/** + * SYNTHETIC_CORPUS_VERSION bumps when generateSyntheticQuads output changes; invalidates bench DB cache manifests. + */ +export const SYNTHETIC_CORPUS_VERSION = 1; + +/** + * generateSyntheticQuads yields a deterministic set of RDF quads tailored for scientific repeatability. + * Each quad contains a descriptive literal optimized for text splitting and indexing benchmarks. + * + * @param count The precise number of quads to generate. + * @returns Array of populated RDF quads. + */ +export function generateSyntheticQuads(count: number): Quad[] { + const syntheticQuads: Quad[] = []; + for (let index = 0; index < count; index++) { + syntheticQuads.push( + quad( + namedNode(`urn:entity:${index}`), + namedNode(`urn:property:${index % 10}`), + literal( + `This is synthetic data entry number ${index}. It contains enough words to provide a realistic payload for text splitters and vector encoders. Every system should process this reliably. Sequential verification token: SYNT-${index}.`, + ), + ), + ); + } + + return syntheticQuads; +} diff --git a/benchmarks/sparql-perf-large-libsql.bench.ts b/benchmarks/sparql-perf-large-libsql.bench.ts new file mode 100644 index 0000000..3159ac0 --- /dev/null +++ b/benchmarks/sparql-perf-large-libsql.bench.ts @@ -0,0 +1,28 @@ +import { isBenchReuseDbEnabled } from "./shared/perf-db-cache.ts"; +import { + libsqlHexastorePerfBackends, + preloadSparqlHexastorePerfFixtures, + registerSparqlHexastorePerfBenchmarks, + registerSparqlHexastorePerfUnloadCleanup, +} from "./shared/sparql-perf-shared.ts"; + +const largePerfScales = [ + 100_000, + 250_000, + 500_000, + 1_000_000, +] as const; + +const preloadedSparqlEngines = await preloadSparqlHexastorePerfFixtures( + largePerfScales, + "large libsql", + libsqlHexastorePerfBackends, + { reuseFileCache: isBenchReuseDbEnabled() }, +); + +registerSparqlHexastorePerfUnloadCleanup(preloadedSparqlEngines); +registerSparqlHexastorePerfBenchmarks( + largePerfScales, + preloadedSparqlEngines, + libsqlHexastorePerfBackends, +); diff --git a/benchmarks/sparql-perf-libsql.bench.ts b/benchmarks/sparql-perf-libsql.bench.ts new file mode 100644 index 0000000..1adcc59 --- /dev/null +++ b/benchmarks/sparql-perf-libsql.bench.ts @@ -0,0 +1,21 @@ +import { + libsqlHexastorePerfBackends, + preloadSparqlHexastorePerfFixtures, + registerSparqlHexastorePerfBenchmarks, + registerSparqlHexastorePerfUnloadCleanup, +} from "./shared/sparql-perf-shared.ts"; + +const standardPerfScales = [1_000, 5_000, 10_000, 25_000, 50_000] as const; + +const preloadedSparqlEngines = await preloadSparqlHexastorePerfFixtures( + standardPerfScales, + "standard libsql", + libsqlHexastorePerfBackends, +); + +registerSparqlHexastorePerfUnloadCleanup(preloadedSparqlEngines); +registerSparqlHexastorePerfBenchmarks( + standardPerfScales, + preloadedSparqlEngines, + libsqlHexastorePerfBackends, +); diff --git a/benchmarks/synchronization.bench.ts b/benchmarks/synchronization.bench.ts new file mode 100644 index 0000000..d75e9b0 --- /dev/null +++ b/benchmarks/synchronization.bench.ts @@ -0,0 +1,69 @@ +import { createClient } from "@libsql/client"; +import { DataFactory } from "n3"; +import { createLibsqlClient } from "@/libsql/mod.ts"; + +const { quad, namedNode, literal } = DataFactory; + +const databaseClient = createClient({ url: ":memory:" }); +const client = await createLibsqlClient({ + client: databaseClient, + searchIndexOnImport: "disabled", +}); + +let indexCounter = 3000; +function generateBatchPayload(count: number) { + const bulkQuads = []; + for (let i = 0; i < count; i++) { + indexCounter++; + bulkQuads.push( + quad( + namedNode(`urn:entity:sync-batch:${indexCounter}`), + namedNode("urn:property:name"), + literal(`Batch payload text for unique entity number ${indexCounter}`), + ), + ); + } + return bulkQuads; +} + +Deno.bench({ + name: "Sync: Consolidated Batch Commit (10 Quads)", + group: "Consolidated Batch Ingestion", + async fn(benchContext) { + const payload = generateBatchPayload(10); + + benchContext.start(); + await client.import({ + source: { kind: "quads", quads: payload }, + }); + benchContext.end(); + }, +}); + +Deno.bench({ + name: "Sync: Consolidated Batch Commit (100 Quads)", + group: "Consolidated Batch Ingestion", + async fn(benchContext) { + const payload = generateBatchPayload(100); + + benchContext.start(); + await client.import({ + source: { kind: "quads", quads: payload }, + }); + benchContext.end(); + }, +}); + +Deno.bench({ + name: "Sync: Consolidated Batch Commit (1,000 Quads)", + group: "Consolidated Batch Ingestion", + async fn(benchContext) { + const payload = generateBatchPayload(1000); + + benchContext.start(); + await client.import({ + source: { kind: "quads", quads: payload }, + }); + benchContext.end(); + }, +}); diff --git a/deno.json b/deno.json index d85407c..a39ce6d 100644 --- a/deno.json +++ b/deno.json @@ -57,6 +57,13 @@ "check", "test" ] - } + }, + "example:libsql-hello-world": "deno run -A examples/libsql-hello-world/main.ts", + "bench": "deno bench --allow-all benchmarks/", + "bench:sparql-perf-libsql": "deno bench --allow-all benchmarks/sparql-perf-libsql.bench.ts", + "bench:sparql-perf-libsql:full-scan": "deno bench --allow-all --env BENCH_HEXASTORE_PERF_FULL_SCAN=1 benchmarks/sparql-perf-libsql.bench.ts", + "bench:sparql-perf-large-libsql": "deno bench --allow-all --v8-flags=--max-old-space-size=8192 benchmarks/sparql-perf-large-libsql.bench.ts", + "bench:sparql-perf-large-libsql:full-scan": "deno bench --allow-all --env BENCH_HEXASTORE_PERF_FULL_SCAN=1 --v8-flags=--max-old-space-size=8192 benchmarks/sparql-perf-large-libsql.bench.ts", + "bench:sparql-perf-large-libsql:reuse": "deno bench --allow-all --env BENCH_REUSE_DB=1 --v8-flags=--max-old-space-size=8192 benchmarks/sparql-perf-large-libsql.bench.ts" } } diff --git a/examples/libsql-hello-world/README.md b/examples/libsql-hello-world/README.md new file mode 100644 index 0000000..e3e8c2d --- /dev/null +++ b/examples/libsql-hello-world/README.md @@ -0,0 +1,56 @@ +# LibSQL hello world example + +This example demonstrates how to configure and use a single process-lifetime +`Client` with a production-ready, durable LibSQL backend using Deno. + +It consolidates process-scoped lifecycle patterns, hybrid search capabilities, +and optimized SPARQL queries. + +## Key capabilities + +This example demonstrates: + +- **Process-lifetime client reuse**: Instantiating a single database client and + reusing it for the entire process lifespan (ideal for platforms like Fly.io, + DigitalOcean, or long-lived servers). +- **Hybrid retrieval**: Querying graph literals using a blend of keyword SQLite + FTS5 matching and TF.js Universal Sentence Encoder (USE) vector semantic + similarity. +- **SPARQL query optimization**: Grounded, selective subject-bound SPARQL query + structures optimized for edge database quad index indices, contrasted against + full-scan unbound queries. + +## Running the example + +Run the example with Deno: + +```bash +deno task example:libsql-hello-world +``` + +## Core concepts + +### Hybrid search + +The hybrid search combines: + +- Keyword similarity (using high-speed SQLite FTS5 tables). +- Semantic similarity (using pre-cached TensorFlow.js embeddings). + +These two relevance metrics are blended together via Reciprocal Rank Fusion +(RRF, $k = 60$) to deliver high-precision context discovery. + +### SPARQL at scale + +To query high-scale graph structures, you should avoid full unbound triple scans +`?subject ?property ?object` because they scale poorly on large datasets. + +Instead, production hot paths should leverage **selective, subject-bound +queries** where at least one term is grounded: + +```sparql +SELECT ?property ?object WHERE { ?property ?object } +``` + +Grounded patterns allow `LibsqlRdfjsStore` to resolve the triples in logarithmic +time via quad index indices instead of executing slow sequential scans. diff --git a/examples/libsql-hello-world/main.ts b/examples/libsql-hello-world/main.ts new file mode 100644 index 0000000..dbfa850 --- /dev/null +++ b/examples/libsql-hello-world/main.ts @@ -0,0 +1,28 @@ +import { createClient } from "@libsql/client"; +import { createLibsqlClient } from "@worlds/libsql"; +import { QueryEngine } from "@comunica/query-sparql-rdfjs-lite"; + +if (import.meta.main) { + const databaseClient = createClient({ url: ":memory:" }); + const client = await createLibsqlClient({ + client: databaseClient, + queryEngine: new QueryEngine(), + }); + + await client.import({ + source: { + kind: "serialized", + data: + ` "Hello, World!" .`, + contentType: "text/turtle", + }, + }); + + const searchResponse = await client.search({ query: "Hello" }); + console.log(JSON.stringify(searchResponse, null, 2)); + + const sparqlResponse = await client.sparql({ + query: `SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, + }); + console.log(JSON.stringify(sparqlResponse, null, 2)); +}