diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index b4609f45..3129aaed 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -9,6 +9,11 @@ on: description: Pull request number required: true type: number + suite: + description: Benchmark suite (default, sync, hot-write, full) + required: false + default: default + type: string permissions: contents: read @@ -24,6 +29,20 @@ jobs: contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) ) || (github.event_name == 'workflow_dispatch') runs-on: ubuntu-22.04 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 10 concurrency: group: bench-${{ github.event.issue.number || inputs.pr }} cancel-in-progress: true @@ -31,6 +50,8 @@ jobs: EM_VERSION: 4.0.21 EM_CACHE_FOLDER: emsdk-cache PLAYWRIGHT_BROWSERS_PATH: /home/runner/.cache/ms-playwright + BENCH_BASE_DB: treecrdt_bench_base + BENCH_HEAD_DB: treecrdt_bench_head steps: - name: Resolve PR details @@ -39,7 +60,7 @@ jobs: with: script: | const prNumber = context.eventName === 'workflow_dispatch' - ? Number(core.getInput('pr')) + ? Number(context.payload.inputs?.pr ?? '') : context.payload.issue?.number; if (!prNumber) { @@ -100,6 +121,63 @@ jobs: }); } + - name: Parse benchmark request + if: steps.pr.outputs.same_repo == 'true' + id: request + uses: actions/github-script@v7 + with: + script: | + const rawRequest = context.eventName === 'workflow_dispatch' + ? String(context.payload.inputs?.suite ?? 'default') + : String(context.payload.comment?.body ?? '/bench'); + + const requestedToken = (() => { + if (context.eventName === 'workflow_dispatch') { + return rawRequest.trim().toLowerCase() || 'default'; + } + const match = rawRequest.trim().match(/^\/bench(?:\s+([A-Za-z0-9_-]+))?/i); + return match?.[1]?.trim().toLowerCase() ?? 'default'; + })(); + + const aliases = new Map([ + ['default', 'default'], + ['smoke', 'default'], + ['sync', 'sync'], + ['local-sync', 'sync'], + ['hot', 'hot-write'], + ['write', 'hot-write'], + ['hot-write', 'hot-write'], + ['full', 'full'], + ['all', 'full'], + ]); + + const suite = aliases.get(requestedToken); + if (!suite) { + throw new Error( + `Unknown benchmark suite "${requestedToken}". Supported suites: default, sync, hot-write, full.` + ); + } + + const suiteLabels = { + default: 'high-value default', + sync: 'scoped sync focus', + 'hot-write': 'hot-write focus', + full: 'full legacy suite', + }; + + const triggerLabel = + context.eventName === 'workflow_dispatch' + ? `workflow_dispatch (${suite})` + : requestedToken === 'default' + ? '/bench' + : `/bench ${requestedToken}`; + + core.setOutput('suite', suite); + core.setOutput('suite_label', suiteLabels[suite]); + core.setOutput('trigger_label', triggerLabel); + core.setOutput('needs_emscripten', suite === 'full' ? 'true' : 'false'); + core.setOutput('needs_playwright', suite === 'full' ? 'true' : 'false'); + - name: Checkout base if: steps.pr.outputs.same_repo == 'true' uses: actions/checkout@v4 @@ -139,29 +217,31 @@ jobs: - name: Add wasm targets if: steps.pr.outputs.same_repo == 'true' run: | - rustup target add wasm32-unknown-emscripten rustup target add wasm32-unknown-unknown + if [ '${{ steps.request.outputs.needs_emscripten }}' = 'true' ]; then + rustup target add wasm32-unknown-emscripten + fi - name: Install wasm-pack if: steps.pr.outputs.same_repo == 'true' run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - name: Cache Emscripten - if: steps.pr.outputs.same_repo == 'true' + if: steps.pr.outputs.same_repo == 'true' && steps.request.outputs.needs_emscripten == 'true' uses: actions/cache@v4 with: path: ${{ env.EM_CACHE_FOLDER }} key: ${{ env.EM_VERSION }}-${{ runner.os }}-bench - name: Setup Emscripten - if: steps.pr.outputs.same_repo == 'true' + if: steps.pr.outputs.same_repo == 'true' && steps.request.outputs.needs_emscripten == 'true' uses: mymindstorm/setup-emsdk@v14 with: version: ${{ env.EM_VERSION }} actions-cache-folder: ${{ env.EM_CACHE_FOLDER }} - name: Verify emcc - if: steps.pr.outputs.same_repo == 'true' + if: steps.pr.outputs.same_repo == 'true' && steps.request.outputs.needs_emscripten == 'true' run: emcc -v - name: Install dependencies (base) @@ -169,36 +249,257 @@ jobs: working-directory: bench-base run: pnpm install --frozen-lockfile=false + - name: Build benchmark TypeScript deps (base) + if: steps.pr.outputs.same_repo == 'true' + working-directory: bench-base + env: + BENCH_SUITE: ${{ steps.request.outputs.suite }} + run: | + pnpm -C packages/treecrdt-ts run build + pnpm -C packages/treecrdt-riblt-wasm-js run build + pnpm -C packages/sync/protocol run build + pnpm -C packages/sync/material/sqlite run build + pnpm -C packages/sync/material/postgres run build + pnpm -C packages/sync/server/core run build + pnpm -C packages/treecrdt-auth run build + pnpm -C packages/treecrdt-crypto run build + if [ "$BENCH_SUITE" = "full" ]; then + pnpm -C packages/treecrdt-sqlite-conformance run build + pnpm -C packages/treecrdt-wa-sqlite run build:ts + pnpm -C packages/treecrdt-wa-sqlite/e2e run build:wa-sqlite + fi + - name: Install dependencies (head) if: steps.pr.outputs.same_repo == 'true' working-directory: bench-head run: pnpm install --frozen-lockfile=false - - name: Install Playwright browsers + - name: Build benchmark TypeScript deps (head) if: steps.pr.outputs.same_repo == 'true' + working-directory: bench-head + env: + BENCH_SUITE: ${{ steps.request.outputs.suite }} + run: | + pnpm -C packages/treecrdt-ts run build + pnpm -C packages/treecrdt-riblt-wasm-js run build + pnpm -C packages/sync/protocol run build + pnpm -C packages/sync/material/sqlite run build + pnpm -C packages/sync/material/postgres run build + pnpm -C packages/sync/server/core run build + pnpm -C packages/treecrdt-auth run build + pnpm -C packages/treecrdt-crypto run build + if [ "$BENCH_SUITE" = "full" ]; then + pnpm -C packages/treecrdt-sqlite-conformance run build + pnpm -C packages/treecrdt-wa-sqlite run build:ts + pnpm -C packages/treecrdt-wa-sqlite/e2e run build:wa-sqlite + fi + + - name: Install Playwright browsers + if: steps.pr.outputs.same_repo == 'true' && steps.request.outputs.needs_playwright == 'true' env: PLAYWRIGHT_BROWSERS_PATH: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} run: pnpm -C bench-head/packages/treecrdt-wa-sqlite/e2e exec playwright install --with-deps chromium + - name: Prepare benchmark Postgres databases + if: steps.pr.outputs.same_repo == 'true' + env: + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: postgres + BENCH_BASE_DB: ${{ env.BENCH_BASE_DB }} + BENCH_HEAD_DB: ${{ env.BENCH_HEAD_DB }} + run: | + pnpm -C bench-head/packages/sync/server/postgres-node exec node <<'EOF' + const { Client } = require('pg'); + + async function recreateDatabase(name) { + const client = new Client({ database: 'postgres' }); + await client.connect(); + try { + await client.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); + await client.query(`CREATE DATABASE "${name}"`); + } finally { + await client.end(); + } + } + + (async () => { + await recreateDatabase(process.env.BENCH_BASE_DB); + await recreateDatabase(process.env.BENCH_HEAD_DB); + })().catch((err) => { + console.error(err); + process.exit(1); + }); + EOF + - name: Run benchmarks (base) if: steps.pr.outputs.same_repo == 'true' working-directory: bench-base env: + BENCH_SUITE: ${{ steps.request.outputs.suite }} PLAYWRIGHT_BROWSERS_PATH: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - run: pnpm run benchmark + TREECRDT_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/${{ env.BENCH_BASE_DB }} + run: | + has_hot_write_suite() { + node -e "const p=require('./package.json'); process.exit(p.scripts?.['benchmark:hot-write'] ? 0 : 1)" + } + + run_hot_write_suite() { + if ! has_hot_write_suite; then + echo "Skipping hot-write benchmarks (scripts not present on this branch)" + return 0 + fi + + pnpm -C packages/treecrdt-sqlite-node run benchmark:hot-write -- "$@" + + if [ -n "$TREECRDT_POSTGRES_URL" ]; then + pnpm -C packages/treecrdt-postgres-napi run benchmark:hot-write -- "$@" + else + echo "Skipping postgres hot-write benchmark (TREECRDT_POSTGRES_URL not set)" + fi + } + + case "$BENCH_SUITE" in + default) + pnpm run benchmark:sync:local -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --counts=100000 \ + --first-view \ + --iterations=3 \ + --warmup=1 \ + --server-fixture-cache=rebuild + run_hot_write_suite \ + --benches=payload-edit,insert-sibling \ + --counts=100000 \ + --writes-per-sample=10 \ + --warmup-writes=2 + ;; + sync) + pnpm run benchmark:sync:local -- \ + --workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \ + --counts=10000,100000 \ + --first-view \ + --iterations=3 \ + --warmup=1 \ + --server-fixture-cache=rebuild + ;; + hot-write) + run_hot_write_suite \ + --benches=payload-edit,insert-sibling \ + --counts=10000,100000 \ + --writes-per-sample=10 \ + --warmup-writes=2 + ;; + full) + pnpm run benchmark + pnpm run benchmark:sync:local -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --counts=100000 \ + --first-view \ + --iterations=3 \ + --warmup=1 \ + --server-fixture-cache=rebuild + run_hot_write_suite \ + --benches=payload-edit,insert-sibling \ + --counts=100000 \ + --writes-per-sample=10 \ + --warmup-writes=2 + ;; + *) + echo "Unknown BENCH_SUITE=$BENCH_SUITE" >&2 + exit 1 + ;; + esac + + pnpm run benchmark:aggregate - name: Run benchmarks (head) if: steps.pr.outputs.same_repo == 'true' working-directory: bench-head env: + BENCH_SUITE: ${{ steps.request.outputs.suite }} PLAYWRIGHT_BROWSERS_PATH: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} - run: pnpm run benchmark + TREECRDT_POSTGRES_URL: postgresql://postgres:postgres@127.0.0.1:5432/${{ env.BENCH_HEAD_DB }} + run: | + has_hot_write_suite() { + node -e "const p=require('./package.json'); process.exit(p.scripts?.['benchmark:hot-write'] ? 0 : 1)" + } + + run_hot_write_suite() { + if ! has_hot_write_suite; then + echo "Skipping hot-write benchmarks (scripts not present on this branch)" + return 0 + fi + + pnpm -C packages/treecrdt-sqlite-node run benchmark:hot-write -- "$@" + + if [ -n "$TREECRDT_POSTGRES_URL" ]; then + pnpm -C packages/treecrdt-postgres-napi run benchmark:hot-write -- "$@" + else + echo "Skipping postgres hot-write benchmark (TREECRDT_POSTGRES_URL not set)" + fi + } + + case "$BENCH_SUITE" in + default) + pnpm run benchmark:sync:local -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --counts=100000 \ + --first-view \ + --iterations=3 \ + --warmup=1 \ + --server-fixture-cache=rebuild + run_hot_write_suite \ + --benches=payload-edit,insert-sibling \ + --counts=100000 \ + --writes-per-sample=10 \ + --warmup-writes=2 + ;; + sync) + pnpm run benchmark:sync:local -- \ + --workloads=sync-balanced-children-cold-start,sync-balanced-children-payloads-cold-start \ + --counts=10000,100000 \ + --first-view \ + --iterations=3 \ + --warmup=1 \ + --server-fixture-cache=rebuild + ;; + hot-write) + run_hot_write_suite \ + --benches=payload-edit,insert-sibling \ + --counts=10000,100000 \ + --writes-per-sample=10 \ + --warmup-writes=2 + ;; + full) + pnpm run benchmark + pnpm run benchmark:sync:local -- \ + --workloads=sync-balanced-children-payloads-cold-start \ + --counts=100000 \ + --first-view \ + --iterations=3 \ + --warmup=1 \ + --server-fixture-cache=rebuild + run_hot_write_suite \ + --benches=payload-edit,insert-sibling \ + --counts=100000 \ + --writes-per-sample=10 \ + --warmup-writes=2 + ;; + *) + echo "Unknown BENCH_SUITE=$BENCH_SUITE" >&2 + exit 1 + ;; + esac + + pnpm run benchmark:aggregate - name: Upload benchmark artifacts if: steps.pr.outputs.same_repo == 'true' uses: actions/upload-artifact@v4 with: - name: benchmarks-${{ steps.pr.outputs.number }} + name: benchmarks-${{ steps.pr.outputs.number }}-${{ steps.request.outputs.suite }} if-no-files-found: warn path: | bench-base/benchmarks/** @@ -290,14 +591,16 @@ jobs: const headSha = '${{ steps.pr.outputs.head_sha }}'.slice(0, 7); const baseRef = '${{ steps.pr.outputs.base_ref }}'; const headRef = '${{ steps.pr.outputs.head_ref }}'; - const triggerLabel = - context.eventName === 'workflow_dispatch' ? '`workflow_dispatch`' : '`/bench`'; + const triggerLabel = '`' + '${{ steps.request.outputs.trigger_label }}' + '`'; + const suiteLabel = '${{ steps.request.outputs.suite_label }}'; + const suite = '${{ steps.request.outputs.suite }}'; const parts = [ marker, '## Benchmarks', '', `Triggered by ${triggerLabel} - Run: ${runUrl}`, + `Suite: ${suiteLabel} (\`${suite}\`)`, `Base: ${baseRef} (${baseSha}) - Head: ${headRef} (${headSha})`, `Compared entries: ${keys.length} - Improved: ${improved} - Regressed: ${regressed}`, '', diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index efa6a855..87433a61 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -15,9 +15,12 @@ Useful top-level entrypoints: ```sh pnpm benchmark +pnpm benchmark:hot-write pnpm benchmark:sqlite-node +pnpm benchmark:sqlite-node:hot-write pnpm benchmark:sqlite-node:ops pnpm benchmark:sqlite-node:note-paths +pnpm benchmark:postgres:hot-write pnpm benchmark:sync pnpm benchmark:sync:direct pnpm benchmark:sync:local @@ -40,12 +43,57 @@ pnpm benchmark:postgres - One-time bootstrap/discovery tax before opening the regional websocket: `benchmark:sync:bootstrap` - Local render cost after the data is already present: `benchmark:sqlite-node:note-paths -- --benches=read-children-payloads` - Local mutation cost inside a large existing tree: `benchmark:sqlite-node:note-paths -- --benches=insert-into-large-tree` +- Hot write cost inside a large existing tree, standardized across local backends: `benchmark:hot-write` - Protocol/storage baselines and worst-case stress: `sync-one-missing`, `sync-all`, `sync-children*`, `sync-root-children-fanout10` That split is intentional: - The sync benches answer "how long until the needed subtree data is in the local store?" - The note-path benches answer "once the data is local, how quickly can the app render and mutate it?" +- The hot-write benches answer "how expensive is one new live edit after the large doc already exists?" + +## Hot Write Benchmarks + +Use the hot-write suite when you want an apples-to-apples answer for one edit against an existing large doc. + +The current standardized cases are: + +- `payload-edit` +- `insert-sibling` +- `move-leaf` +- `move-subtree` + +`move-leaf` is the small-write move case. +`move-subtree` intentionally moves a top-level branch so its cost includes moving a larger subtree, not just a single node. + +By default each case reseeds a balanced tree, performs exactly one live local mutation, and writes JSON under `benchmarks/hot-write/`. + +You can also switch the suite into a warmed session mode so one already-open doc handles several writes before it closes. That is useful when you want to isolate ongoing live-edit cost from sample setup. + +Top-level entrypoints: + +```sh +pnpm benchmark:hot-write +pnpm benchmark:sqlite-node:hot-write -- --counts=10000,100000 +TREECRDT_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres \ +pnpm benchmark:postgres:hot-write -- --counts=10000,100000 +``` + +Useful flags: + +- `--benches=payload-edit,insert-sibling,move-leaf,move-subtree` +- `--counts=10000,100000` +- `--fanout=10` +- `--payload-bytes=512` +- `--writes-per-sample=10` +- `--warmup-writes=1` + +Useful env vars: + +- `TREECRDT_POSTGRES_URL=...` for the Postgres runner +- `HOT_WRITE_SKIP_SAMPLE_CLEANUP=1` if you want faster local iteration on very large Postgres samples and do not mind keeping temporary sample docs around + +This suite is intentionally separate from the default `pnpm benchmark` run because reseeding large docs for each measured sample is expensive. ## Recommended Product-Facing Runs @@ -140,6 +188,8 @@ pnpm benchmark:sync:remote -- \ For remote targets, `prime` now records the exact fixture doc ID locally under `tmp/sqlite-node-sync-bench/server-fixtures/`. That means a fresh endpoint can be primed once with `--server-fixture-cache=rebuild`, and later `--server-fixture-cache=reuse` runs on the same machine can reopen that exact remote fixture doc instead of relying on historical deterministic fixture residue. +If you also have direct Postgres access to that same deployment, add `--postgres-url=postgres://...` on the remote target. Then remote fixture priming can use the same direct balanced-fixture seed path as the local Postgres target instead of uploading the entire large tree over websocket. This is the practical path for `1m` remote/prod-like runs on dedicated environments. + By default, the local sync target runs the Postgres sync server in a spawned child process so local and remote measurements are closer to each other. When you add `--profile-backend`, the local target intentionally switches to the in-process server so per-backend timings are visible inside the benchmark process. Local server benchmarks now seed the Postgres backend directly before the timer starts. That keeps the measured path honest, because the actual sync to the client still goes through the real websocket server, while avoiding huge protocol-seed setup costs that are not part of the benchmark question. diff --git a/examples/playground/src/App.tsx b/examples/playground/src/App.tsx index 6e989600..6e08e08d 100644 --- a/examples/playground/src/App.tsx +++ b/examples/playground/src/App.tsx @@ -1,25 +1,29 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { type Operation, type OperationKind } from "@treecrdt/interface"; -import { bytesToHex } from "@treecrdt/interface/ids"; -import { createTreecrdtClient, type TreecrdtClient } from "@treecrdt/wa-sqlite/client"; -import { detectOpfsSupport } from "@treecrdt/wa-sqlite/opfs"; -import { base64urlDecode } from "@treecrdt/auth"; -import { encryptTreecrdtPayloadV1, maybeDecryptTreecrdtPayloadV1 } from "@treecrdt/crypto"; - -import { loadOrCreateDocPayloadKeyB64 } from "./auth"; -import { hexToBytes16 } from "./sync-v0"; -import { useVirtualizer } from "./virtualizer"; - -import { MAX_COMPOSER_NODE_COUNT, ROOT_ID } from "./playground/constants"; -import { ComposerPanel } from "./playground/components/ComposerPanel"; -import { OpsPanel } from "./playground/components/OpsPanel"; -import { PlaygroundHeader } from "./playground/components/PlaygroundHeader"; -import { ShareSubtreeDialog } from "./playground/components/ShareSubtreeDialog"; -import { PlaygroundToast } from "./playground/components/PlaygroundToast"; -import { TreePanel } from "./playground/components/TreePanel"; -import { usePlaygroundAuth } from "./playground/hooks/usePlaygroundAuth"; -import { usePlaygroundSync } from "./playground/hooks/usePlaygroundSync"; -import { compareOps, mergeSortedOps, opKey } from "./playground/ops"; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { type Operation, type OperationKind } from '@treecrdt/interface'; +import { bytesToHex } from '@treecrdt/interface/ids'; +import { createTreecrdtClient, type TreecrdtClient } from '@treecrdt/wa-sqlite/client'; +import { detectOpfsSupport } from '@treecrdt/wa-sqlite/opfs'; +import { base64urlDecode } from '@treecrdt/auth'; +import { encryptTreecrdtPayloadV1, maybeDecryptTreecrdtPayloadV1 } from '@treecrdt/crypto'; + +import { loadOrCreateDocPayloadKeyB64 } from './auth'; +import { hexToBytes16 } from './sync-v0'; +import { useVirtualizer } from './virtualizer'; + +import { MAX_COMPOSER_NODE_COUNT, ROOT_ID } from './playground/constants'; +import { ComposerPanel } from './playground/components/ComposerPanel'; +import { OpsPanel } from './playground/components/OpsPanel'; +import { PlaygroundHeader } from './playground/components/PlaygroundHeader'; +import { ShareSubtreeDialog } from './playground/components/ShareSubtreeDialog'; +import { PlaygroundToast } from './playground/components/PlaygroundToast'; +import { TreePanel } from './playground/components/TreePanel'; +import { usePlaygroundAuth } from './playground/hooks/usePlaygroundAuth'; +import { usePlaygroundSync } from './playground/hooks/usePlaygroundSync'; +import { compareOps, mergeSortedOps, opKey } from './playground/ops'; +import { + applyLocalPayloadPreview as applyLocalPayloadPreviewToMap, + hydratePayloadsForOps as hydratePayloadsForOpsInMap, +} from './playground/payloads'; import { ensureOpfsKey, initialDocId, @@ -31,14 +35,16 @@ import { persistSyncSettings, persistOpfsKey, persistStorage, -} from "./playground/persist"; -import { getPlaygroundProfileId, prefixPlaygroundStorageKey } from "./playground/storage"; +} from './playground/persist'; +import { getPlaygroundProfileId, prefixPlaygroundStorageKey } from './playground/storage'; import { + applyAppendedChildren, applyChildrenLoaded, flattenForSelectState, nodesAffectedByPayloadOps, parentsAffectedByOps, -} from "./playground/treeState"; +} from './playground/treeState'; +import { recordBenchNodeTiming, registerBenchBindings } from './playground/bench'; import type { CollapseState, DisplayNode, @@ -47,45 +53,45 @@ import type { StorageMode, SyncTransportMode, TreeState, -} from "./playground/types"; +} from './playground/types'; -const PLAYGROUND_SYNC_SERVER_URL_KEY = "treecrdt-playground-sync-server-url"; -const PLAYGROUND_SYNC_TRANSPORT_MODE_KEY = "treecrdt-playground-sync-transport-mode"; +const PLAYGROUND_SYNC_SERVER_URL_KEY = 'treecrdt-playground-sync-server-url'; +const PLAYGROUND_SYNC_TRANSPORT_MODE_KEY = 'treecrdt-playground-sync-transport-mode'; function isSyncTransportMode(value: string | null): value is SyncTransportMode { - return value === "local" || value === "remote" || value === "hybrid"; + return value === 'local' || value === 'remote' || value === 'hybrid'; } function initialSyncServerUrl(): string { - if (typeof window === "undefined") return ""; - const fromQuery = new URLSearchParams(window.location.search).get("sync")?.trim(); + if (typeof window === 'undefined') return ''; + const fromQuery = new URLSearchParams(window.location.search).get('sync')?.trim(); if (fromQuery && fromQuery.length > 0) return fromQuery; - return window.localStorage.getItem(PLAYGROUND_SYNC_SERVER_URL_KEY) ?? ""; + return window.localStorage.getItem(PLAYGROUND_SYNC_SERVER_URL_KEY) ?? ''; } function initialSyncTransportMode(): SyncTransportMode { - if (typeof window === "undefined") return "local"; + if (typeof window === 'undefined') return 'local'; const params = new URLSearchParams(window.location.search); - const fromQuery = params.get("transport")?.trim() ?? null; + const fromQuery = params.get('transport')?.trim() ?? null; if (isSyncTransportMode(fromQuery)) return fromQuery; const fromStorage = window.localStorage.getItem(PLAYGROUND_SYNC_TRANSPORT_MODE_KEY); if (isSyncTransportMode(fromStorage)) return fromStorage; - const fromQuerySync = params.get("sync")?.trim(); - if (fromQuerySync && fromQuerySync.length > 0) return "hybrid"; + const fromQuerySync = params.get('sync')?.trim(); + if (fromQuerySync && fromQuerySync.length > 0) return 'hybrid'; const storedSyncUrl = window.localStorage.getItem(PLAYGROUND_SYNC_SERVER_URL_KEY)?.trim(); - if (storedSyncUrl) return "hybrid"; + if (storedSyncUrl) return 'hybrid'; - return "local"; + return 'local'; } type BulkAddProgress = { total: number; completed: number; - phase: "creating" | "applying"; + phase: 'creating' | 'applying'; startedAtMs: number; }; @@ -97,14 +103,14 @@ export default function App() { index: { [ROOT_ID]: { parentId: null, order: 0, childCount: 0, deleted: false } }, childrenByParent: { [ROOT_ID]: [] }, })); - const [status, setStatus] = useState("booting"); + const [status, setStatus] = useState('booting'); const [error, setError] = useState(null); const [headLamport, setHeadLamport] = useState(0); const [totalNodes, setTotalNodes] = useState(null); const [docId, setDocId] = useState(() => initialDocId()); const [storage, setStorage] = useState(() => initialStorage()); const [sessionKey, setSessionKey] = useState(() => - initialStorage() === "opfs" ? ensureOpfsKey() : makeSessionKey() + initialStorage() === 'opfs' ? ensureOpfsKey() : makeSessionKey(), ); const [parentChoice, setParentChoice] = useState(ROOT_ID); const [collapse, setCollapse] = useState(() => ({ @@ -115,36 +121,40 @@ export default function App() { const [bulkAddProgress, setBulkAddProgress] = useState(null); const [nodeCount, setNodeCount] = useState(1); const [fanout, setFanout] = useState(10); - const [newNodeValue, setNewNodeValue] = useState(""); + const [newNodeValue, setNewNodeValue] = useState(''); const [showOpsPanel, setShowOpsPanel] = useState(false); const [showPeersPanel, setShowPeersPanel] = useState(false); const [syncServerUrl, setSyncServerUrl] = useState(() => initialSyncServerUrl()); - const [syncTransportMode, setSyncTransportMode] = useState(() => initialSyncTransportMode()); + const [syncTransportMode, setSyncTransportMode] = useState(() => + initialSyncTransportMode(), + ); const [composerOpen, setComposerOpen] = useState(() => { - if (typeof window === "undefined") return true; - const key = prefixPlaygroundStorageKey("treecrdt-playground-ui-composer-open"); + if (typeof window === 'undefined') return true; + const key = prefixPlaygroundStorageKey('treecrdt-playground-ui-composer-open'); const stored = window.localStorage.getItem(key); - if (stored === "0") return false; - if (stored === "1") return true; + if (stored === '0') return false; + if (stored === '1') return true; return false; }); const [online, setOnline] = useState(true); const [payloadVersion, setPayloadVersion] = useState(0); const joinMode = - typeof window !== "undefined" && new URLSearchParams(window.location.search).get("join") === "1"; + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('join') === '1'; const autoSyncJoin = - typeof window !== "undefined" && new URLSearchParams(window.location.search).get("autosync") === "1"; + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('autosync') === '1'; const profileId = useMemo(() => getPlaygroundProfileId(), []); useEffect(() => { - if (typeof window === "undefined") return; - const key = prefixPlaygroundStorageKey("treecrdt-playground-ui-composer-open"); - window.localStorage.setItem(key, composerOpen ? "1" : "0"); + if (typeof window === 'undefined') return; + const key = prefixPlaygroundStorageKey('treecrdt-playground-ui-composer-open'); + window.localStorage.setItem(key, composerOpen ? '1' : '0'); }, [composerOpen]); useEffect(() => { - if (typeof window === "undefined") return; + if (typeof window === 'undefined') return; const next = syncServerUrl.trim(); if (next.length === 0) { window.localStorage.removeItem(PLAYGROUND_SYNC_SERVER_URL_KEY); @@ -154,7 +164,7 @@ export default function App() { }, [syncServerUrl]); useEffect(() => { - if (typeof window === "undefined") return; + if (typeof window === 'undefined') return; window.localStorage.setItem(PLAYGROUND_SYNC_TRANSPORT_MODE_KEY, syncTransportMode); }, [syncTransportMode]); @@ -166,6 +176,7 @@ export default function App() { const lamportRef = useRef(0); const initEpochRef = useRef(0); const disposedRef = useRef(false); + const localInsertInFlightRef = useRef(false); const opfsSupport = useMemo(detectOpfsSupport, []); const docPayloadKeyRef = useRef(null); const refreshDocPayloadKey = React.useCallback(async () => { @@ -173,10 +184,16 @@ export default function App() { docPayloadKeyRef.current = base64urlDecode(keyB64); return docPayloadKeyRef.current; }, [docId]); - const identityByReplicaRef = useRef>(new Map()); + const identityByReplicaRef = useRef< + Map + >(new Map()); const [, bumpIdentityVersion] = useState(0); const onPeerIdentityChain = React.useCallback( - (chain: { identityPublicKey: Uint8Array; devicePublicKey: Uint8Array; replicaPublicKey: Uint8Array }) => { + (chain: { + identityPublicKey: Uint8Array; + devicePublicKey: Uint8Array; + replicaPublicKey: Uint8Array; + }) => { const replicaHex = bytesToHex(chain.replicaPublicKey); const existing = identityByReplicaRef.current.get(replicaHex); if ( @@ -186,10 +203,13 @@ export default function App() { ) { return; } - identityByReplicaRef.current.set(replicaHex, { identityPk: chain.identityPublicKey, devicePk: chain.devicePublicKey }); + identityByReplicaRef.current.set(replicaHex, { + identityPk: chain.identityPublicKey, + devicePk: chain.devicePublicKey, + }); bumpIdentityVersion((v) => v + 1); }, - [] + [], ); const { @@ -312,7 +332,7 @@ export default function App() { const requireDocPayloadKey = React.useCallback(async (): Promise => { if (docPayloadKeyRef.current) return docPayloadKeyRef.current; const next = await refreshDocPayloadKey(); - if (!next) throw new Error("doc payload key is missing"); + if (!next) throw new Error('doc payload key is missing'); return next; }, [refreshDocPayloadKey]); @@ -344,7 +364,34 @@ export default function App() { } if (changed) setPayloadVersion((v) => v + 1); }, - [docId, requireDocPayloadKey] + [docId, requireDocPayloadKey], + ); + + const applyLocalPayloadPreview = React.useCallback( + (entries: Iterable<{ nodeId: string; payload: Uint8Array | null }>) => { + if (applyLocalPayloadPreviewToMap(payloadByNodeRef.current, entries)) { + setPayloadVersion((v) => v + 1); + } + }, + [], + ); + + const hydratePayloadsForOps = React.useCallback( + async (active: TreecrdtClient, ops: Iterable) => { + if ( + await hydratePayloadsForOpsInMap({ + payloads: payloadByNodeRef.current, + active, + ops, + docId, + requireDocPayloadKey, + refreshPayloadsForNodes, + }) + ) { + setPayloadVersion((v) => v + 1); + } + }, + [docId, refreshPayloadsForNodes, requireDocPayloadKey], ); const encryptPayloadBytes = React.useCallback( @@ -353,7 +400,7 @@ export default function App() { const key = await requireDocPayloadKey(); return await encryptTreecrdtPayloadV1({ docId, payloadKey: key, plaintext: payload }); }, - [docId, requireDocPayloadKey] + [docId, requireDocPayloadKey], ); const knownOpsRef = useRef>(new Set()); @@ -386,7 +433,7 @@ export default function App() { if (!opts.assumeSorted) fresh.sort(compareOps); setOps((prev) => mergeSortedOps(prev, fresh)); }, - [] + [], ); const childrenLoadInFlightRef = useRef>(new Set()); @@ -411,16 +458,16 @@ export default function App() { await refreshPayloadsForNodes(active, nodeIds); } } catch (err) { - console.error("Failed to load child payloads", err); + console.error('Failed to load child payloads', err); } } catch (err) { - console.error("Failed to load children", err); - setError("Failed to load tree children (see console)"); + console.error('Failed to load children', err); + setError('Failed to load tree children (see console)'); } finally { childrenLoadInFlightRef.current.delete(parentId); } }, - [client, refreshPayloadsForNodes] + [client, refreshPayloadsForNodes], ); const refreshParents = React.useCallback( @@ -440,15 +487,17 @@ export default function App() { try { const idsNeedingParent = ids.filter((id) => id !== ROOT_ID && !index[id]?.parentId); const [childrenResults, parentResults] = await Promise.all([ - Promise.all(ids.map((id) => active.tree.children(id).then((children) => [id, children] as const))), + Promise.all( + ids.map((id) => active.tree.children(id).then((children) => [id, children] as const)), + ), idsNeedingParent.length > 0 ? Promise.all( - idsNeedingParent.map((id) => active.tree.parent(id).then((p) => [id, p] as const)) + idsNeedingParent.map((id) => active.tree.parent(id).then((p) => [id, p] as const)), ) : Promise.resolve([]), ]); const parentOverrides = Object.fromEntries( - parentResults.filter(([, p]) => p !== null) as [string, string][] + parentResults.filter(([, p]) => p !== null) as [string, string][], ); setTreeState((prev) => { let next = prev; @@ -457,11 +506,15 @@ export default function App() { } return next; }); + const treeRefreshAppliedAtMs = Date.now(); + for (const [, children] of childrenResults) { + recordBenchNodeTiming(children, { treeRefreshAppliedAtMs }); + } } catch (err) { - console.error("Failed to refresh tree parents", err); + console.error('Failed to refresh tree parents', err); } }, - [client] + [client], ); const refreshNodeCount = React.useCallback( @@ -472,10 +525,10 @@ export default function App() { const count = await active.tree.nodeCount(); setTotalNodes(Number.isFinite(count) ? count : null); } catch (err) { - console.error("Failed to refresh node count", err); + console.error('Failed to refresh node count', err); } }, - [client] + [client], ); const refreshMeta = React.useCallback( @@ -491,10 +544,10 @@ export default function App() { setHeadLamport(lamportRef.current); counterRef.current = Math.max(counterRef.current, counter); } catch (err) { - console.error("Failed to refresh meta", err); + console.error('Failed to refresh meta', err); } }, - [client, replica] + [client, replica], ); const refreshParentsScheduledRef = useRef(false); @@ -516,7 +569,24 @@ export default function App() { void refreshParents(ids); }); }, - [refreshParents] + [refreshParents], + ); + + const scheduleRefreshParentsAfterPaint = React.useCallback( + (parentIds: Iterable) => { + const ids = Array.from(parentIds); + if (ids.length === 0) return; + if (typeof window === 'undefined') { + setTimeout(() => { + void refreshParents(ids); + }, 0); + return; + } + window.requestAnimationFrame(() => { + void refreshParents(ids); + }); + }, + [refreshParents], ); const refreshNodeCountQueuedRef = useRef(false); @@ -529,43 +599,87 @@ export default function App() { }); }, [refreshNodeCount]); + const scheduleRefreshNodeCountAfterPaint = React.useCallback(() => { + if (typeof window === 'undefined') { + setTimeout(() => { + void refreshNodeCount(); + }, 0); + return; + } + window.requestAnimationFrame(() => { + void refreshNodeCount(); + }); + }, [refreshNodeCount]); + const getMaxLamport = React.useCallback(() => BigInt(lamportRef.current), []); const onRemoteOpsApplied = React.useCallback( async (appliedOps: Operation[]) => { + const affectedNodes = nodesAffectedByPayloadOps(appliedOps); + const treeStateBefore = treeStateRef.current; + const affectedParents = parentsAffectedByOps(treeStateBefore, appliedOps); + const canApplyAppendedChildren = + appliedOps.length > 0 && + appliedOps.every( + (op) => + op.kind.type === 'insert' && + Object.prototype.hasOwnProperty.call(treeStateBefore.childrenByParent, op.kind.parent), + ); + const remoteOpsAppliedStartedAtMs = Date.now(); + recordBenchNodeTiming(affectedNodes, { remoteOpsAppliedStartedAtMs }); const active = clientRef.current ?? client; if (active && appliedOps.length > 0) { - await refreshPayloadsForNodes(active, nodesAffectedByPayloadOps(appliedOps)); + await hydratePayloadsForOps(active, appliedOps); + recordBenchNodeTiming(affectedNodes, { payloadsRefreshedAtMs: Date.now() }); } ingestOps(appliedOps); + recordBenchNodeTiming(affectedNodes, { remoteOpsAppliedFinishedAtMs: Date.now() }); if (appliedOps.length > 0) { let max = 0; for (const op of appliedOps) max = Math.max(max, op.meta.lamport); lamportRef.current = Math.max(lamportRef.current, max); setHeadLamport(lamportRef.current); } - scheduleRefreshParents(Object.keys(treeStateRef.current.childrenByParent)); + if (canApplyAppendedChildren) { + const groupedChildren = new Map(); + for (const op of appliedOps) { + if (op.kind.type !== 'insert') continue; + const children = groupedChildren.get(op.kind.parent); + if (children) children.push(op.kind.node); + else groupedChildren.set(op.kind.parent, [op.kind.node]); + } + setTreeState((prev) => { + let next = prev; + for (const [parentId, childIds] of groupedChildren) { + next = applyAppendedChildren(next, parentId, childIds); + } + return next; + }); + recordBenchNodeTiming(affectedNodes, { treeRefreshAppliedAtMs: Date.now() }); + } else { + scheduleRefreshParents(affectedParents); + } scheduleRefreshNodeCount(); }, - [client, ingestOps, refreshPayloadsForNodes, scheduleRefreshNodeCount, scheduleRefreshParents] + [client, hydratePayloadsForOps, ingestOps, scheduleRefreshNodeCount, scheduleRefreshParents], ); const openNewPeerTab = () => { - if (typeof window === "undefined") return; + if (typeof window === 'undefined') return; const url = new URL(window.location.href); - url.searchParams.set("doc", docId); - url.searchParams.set("transport", syncTransportMode); + url.searchParams.set('doc', docId); + url.searchParams.set('transport', syncTransportMode); const remoteSync = syncServerUrl.trim(); if (remoteSync.length > 0) { - url.searchParams.set("sync", remoteSync); + url.searchParams.set('sync', remoteSync); } else { - url.searchParams.delete("sync"); + url.searchParams.delete('sync'); } - url.searchParams.set("fresh", "1"); - url.searchParams.delete("replica"); - url.searchParams.delete("auth"); - url.hash = ""; - window.open(url.toString(), "_blank", "noopener,noreferrer"); + url.searchParams.set('fresh', '1'); + url.searchParams.delete('replica'); + url.searchParams.set('auth', authEnabled ? '1' : '0'); + url.hash = ''; + window.open(url.toString(), '_blank', 'noopener,noreferrer'); }; const { index, childrenByParent } = treeState; @@ -625,7 +739,7 @@ export default function App() { }) => { return await grantSubtreeToReplicaPubkeyRaw(postBroadcastMessage, opts); }, - [grantSubtreeToReplicaPubkeyRaw, postBroadcastMessage] + [grantSubtreeToReplicaPubkeyRaw, postBroadcastMessage], ); useEffect(() => { @@ -643,19 +757,19 @@ export default function App() { const nodeLabelForId = React.useCallback( (id: string) => { - if (id === ROOT_ID) return "Root"; + if (id === ROOT_ID) return 'Root'; const record = payloadByNodeRef.current.get(id); const payload = record?.payload ?? null; - if (payload === null) return record?.encrypted ? "(encrypted)" : id; + if (payload === null) return record?.encrypted ? '(encrypted)' : id; const decoded = textDecoder.decode(payload); - return decoded.length === 0 ? "(empty)" : decoded; + return decoded.length === 0 ? '(empty)' : decoded; }, - [payloadVersion, textDecoder] + [payloadVersion, textDecoder], ); const nodeList = useMemo( () => flattenForSelectState(childrenByParent, nodeLabelForId, { rootId: viewRootId }), - [childrenByParent, nodeLabelForId, viewRootId] + [childrenByParent, nodeLabelForId, viewRootId], ); const privateRootEntries = useMemo(() => { const roots = Array.from(privateRoots).filter((id) => id !== ROOT_ID); @@ -678,16 +792,16 @@ export default function App() { if (!entry) break; const record = payloadByNodeRef.current.get(entry.id); const payload = record?.payload ?? null; - const value = payload === null ? "" : textDecoder.decode(payload); + const value = payload === null ? '' : textDecoder.decode(payload); const label = entry.id === ROOT_ID - ? "Root" + ? 'Root' : payload === null ? record?.encrypted - ? "(encrypted)" + ? '(encrypted)' : entry.id : value.length === 0 - ? "(empty)" + ? '(empty)' : value; acc.push({ node: { id: entry.id, label, value, children: [] }, depth: entry.depth }); if (isCollapsed(entry.id)) continue; @@ -706,14 +820,14 @@ export default function App() { const getOpsScrollElement = React.useCallback(() => opsParentRef.current, []); const treeItemKey = React.useCallback( (index: number) => visibleNodes[index]?.node.id ?? index, - [visibleNodes] + [visibleNodes], ); const opsItemKey = React.useCallback( (index: number) => { const op = ops[index]; return op ? `${op.meta.id.counter}-${op.meta.lamport}-${index}` : index; }, - [ops] + [ops], ); const treeVirtualizer = useVirtualizer({ count: visibleNodes.length, @@ -760,21 +874,26 @@ export default function App() { }; }, [closeClientSafely]); - const initClient = async (storageMode: StorageMode, keyOverride?: string, docIdOverride?: string) => { + const initClient = async ( + storageMode: StorageMode, + keyOverride?: string, + docIdOverride?: string, + ) => { const initEpoch = ++initEpochRef.current; - setStatus("booting"); + setStatus('booting'); setError(null); try { const resolvedBase = - typeof window !== "undefined" - ? new URL(import.meta.env.BASE_URL ?? "./", window.location.href).href - : import.meta.env.BASE_URL ?? "./"; - const baseUrl = resolvedBase.endsWith("/") ? resolvedBase : `${resolvedBase}/`; - const filename = storageMode === "opfs" ? `/treecrdt-playground-${keyOverride ?? sessionKey}.db` : undefined; + typeof window !== 'undefined' + ? new URL(import.meta.env.BASE_URL ?? './', window.location.href).href + : (import.meta.env.BASE_URL ?? './'); + const baseUrl = resolvedBase.endsWith('/') ? resolvedBase : `${resolvedBase}/`; + const filename = + storageMode === 'opfs' ? `/treecrdt-playground-${keyOverride ?? sessionKey}.db` : undefined; const c = await createTreecrdtClient({ storage: storageMode, baseUrl, - preferWorker: storageMode === "opfs", + preferWorker: storageMode === 'opfs', filename, docId: docIdOverride ?? docId, }); @@ -788,18 +907,21 @@ export default function App() { await refreshMeta(c); await ensureChildrenLoaded(ROOT_ID, { nextClient: c, force: true }); await refreshNodeCount(c); - setStatus("ready"); + setStatus('ready'); } catch (err) { - console.error("Failed to init wa-sqlite", err); - setError("Failed to initialize wa-sqlite (see console for details)"); - setStatus("error"); + console.error('Failed to init wa-sqlite', err); + setError('Failed to initialize wa-sqlite (see console for details)'); + setStatus('error'); } }; - const resetAndInit = async (target: StorageMode, opts: { resetKey?: boolean; docId?: string } = {}) => { - setStatus("booting"); + const resetAndInit = async ( + target: StorageMode, + opts: { resetKey?: boolean; docId?: string } = {}, + ) => { + setStatus('booting'); const nextKey = - target === "opfs" + target === 'opfs' ? opts.resetKey ? persistOpfsKey(makeSessionKey()) : ensureOpfsKey() @@ -819,7 +941,7 @@ export default function App() { setHeadLamport(0); setTotalNodes(null); setParentChoice(ROOT_ID); - setNewNodeValue(""); + setNewNodeValue(''); setBulkAddProgress(null); setError(null); const closingClient = clientRef.current; @@ -829,7 +951,10 @@ export default function App() { await initClient(target, nextKey, opts.docId); }; - const refreshOps = async (nextClient?: TreecrdtClient, opts: { preserveParent?: boolean } = {}) => { + const refreshOps = async ( + nextClient?: TreecrdtClient, + opts: { preserveParent?: boolean } = {}, + ) => { const active = nextClient ?? client; if (!active) return; try { @@ -837,17 +962,17 @@ export default function App() { fetched.sort(compareOps); setOps(fetched); knownOpsRef.current = new Set(fetched.map(opKey)); - await refreshPayloadsForNodes(active, nodesAffectedByPayloadOps(fetched)); + await hydratePayloadsForOps(active, fetched); setParentChoice((prev) => (opts.preserveParent ? prev : ROOT_ID)); } catch (err) { - console.error("Failed to refresh ops", err); - setError("Failed to refresh operations (see console)"); + console.error('Failed to refresh ops', err); + setError('Failed to refresh operations (see console)'); } }; useEffect(() => { if (!showOpsPanel) return; - if (!client || status !== "ready") return; + if (!client || status !== 'ready') return; void refreshOps(undefined, { preserveParent: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [showOpsPanel, client, status]); @@ -857,30 +982,40 @@ export default function App() { setBusy(true); try { const stateBefore = treeStateRef.current; + const sourceLocalWriteStartedAtMs = Date.now(); let op: Operation; - if (kind.type === "payload") { + if (kind.type === 'payload') { const encryptedPayload = await encryptPayloadBytes(kind.payload); op = await client.local.payload(replica, kind.node, encryptedPayload); - } else if (kind.type === "delete") { + } else if (kind.type === 'delete') { op = await client.local.delete(replica, kind.node); } else { throw new Error(`unsupported operation kind: ${kind.type}`); } await verifyLocalOps([op]); + recordBenchNodeTiming([op.kind.node], { + sourceLocalWriteStartedAtMs, + sourceLocalPersistedAtMs: Date.now(), + }); lamportRef.current = Math.max(lamportRef.current, op.meta.lamport); counterRef.current = Math.max(counterRef.current, op.meta.id.counter); setHeadLamport(lamportRef.current); - notifyLocalUpdate([op]); - await refreshPayloadsForNodes(client, nodesAffectedByPayloadOps([op])); + if (kind.type === 'payload') { + applyLocalPayloadPreview([{ nodeId: kind.node, payload: kind.payload }]); + } else { + await hydratePayloadsForOps(client, [op]); + } ingestOps([op], { assumeSorted: true }); scheduleRefreshParents(parentsAffectedByOps(stateBefore, [op])); scheduleRefreshNodeCount(); + recordBenchNodeTiming([op.kind.node], { sourceLocalPreviewAppliedAtMs: Date.now() }); + notifyLocalUpdate([op]); } catch (err) { - console.error("Failed to append op", err); - setError("Failed to append operation (see console)"); + console.error('Failed to append op', err); + setError('Failed to append operation (see console)'); } finally { setBusy(false); } @@ -892,7 +1027,7 @@ export default function App() { setBusy(true); try { const stateBefore = treeStateRef.current; - const placement = after ? { type: "after" as const, after } : { type: "first" as const }; + const placement = after ? { type: 'after' as const, after } : { type: 'first' as const }; const op = await client.local.move(replica, nodeId, newParent, placement); notifyLocalUpdate([op]); ingestOps([op], { assumeSorted: true }); @@ -902,27 +1037,34 @@ export default function App() { counterRef.current = Math.max(counterRef.current, op.meta.id.counter); setHeadLamport(lamportRef.current); } catch (err) { - console.error("Failed to append move op", err); - setError("Failed to move node (see console)"); + console.error('Failed to append move op', err); + setError('Failed to move node (see console)'); } finally { setBusy(false); } }; - const handleAddNodes = async (parentId: string, count: number, opts: { fanout?: number } = {}) => { + const handleAddNodes = async ( + parentId: string, + count: number, + opts: { fanout?: number } = {}, + ) => { if (!client || !replica) return; if (authEnabled && !canWriteStructure) return; const normalizedCount = Math.max(0, Math.min(MAX_COMPOSER_NODE_COUNT, Math.floor(count))); if (normalizedCount <= 0) return; setBusy(true); const startedAtMs = Date.now(); - const progressStep = normalizedCount >= 1_000 ? 50 : normalizedCount >= 200 ? 20 : normalizedCount >= 50 ? 5 : 1; - setBulkAddProgress({ total: normalizedCount, completed: 0, phase: "creating", startedAtMs }); + const progressStep = + normalizedCount >= 1_000 ? 50 : normalizedCount >= 200 ? 20 : normalizedCount >= 50 ? 5 : 1; + setBulkAddProgress({ total: normalizedCount, completed: 0, phase: 'creating', startedAtMs }); try { const stateBefore = treeStateRef.current; + const deferLocalReconciliation = syncTransportMode !== 'local'; const ops: Operation[] = []; + const payloadPreviewEntries: Array<{ nodeId: string; payload: Uint8Array | null }> = []; const fanoutLimit = Math.max(0, Math.floor(opts.fanout ?? fanout)); - const valueBase = canWritePayload ? newNodeValue.trim() : ""; + const valueBase = canWritePayload ? newNodeValue.trim() : ''; const shouldSetValue = canWritePayload && valueBase.length > 0; if (fanoutLimit <= 0) { @@ -931,12 +1073,19 @@ export default function App() { const value = normalizedCount > 1 ? `${valueBase} ${i + 1}` : valueBase; const payload = shouldSetValue ? textEncoder.encode(value) : null; const encryptedPayload = await encryptPayloadBytes(payload); - ops.push(await client.local.insert(replica, parentId, nodeId, { type: "last" }, encryptedPayload)); + ops.push( + await client.local.insert( + replica, + parentId, + nodeId, + { type: 'last' }, + encryptedPayload, + ), + ); + payloadPreviewEntries.push({ nodeId, payload }); const completed = i + 1; if (completed === normalizedCount || completed % progressStep === 0) { - setBulkAddProgress((prev) => - prev ? { ...prev, completed } : prev - ); + setBulkAddProgress((prev) => (prev ? { ...prev, completed } : prev)); } } } else { @@ -946,7 +1095,7 @@ export default function App() { const getChildCount = (id: string) => { const existing = childCountByParent.get(id); - if (typeof existing === "number") return existing; + if (typeof existing === 'number') return existing; return (childrenByParent[id] ?? []).length; }; @@ -978,21 +1127,28 @@ export default function App() { const value = normalizedCount > 1 ? `${valueBase} ${i + 1}` : valueBase; const payload = shouldSetValue ? textEncoder.encode(value) : null; const encryptedPayload = await encryptPayloadBytes(payload); - ops.push(await client.local.insert(replica, targetParent, nodeId, { type: "last" }, encryptedPayload)); + ops.push( + await client.local.insert( + replica, + targetParent, + nodeId, + { type: 'last' }, + encryptedPayload, + ), + ); + payloadPreviewEntries.push({ nodeId, payload }); setChildCount(targetParent, childCount + 1); queue.push(nodeId); const completed = i + 1; if (completed === normalizedCount || completed % progressStep === 0) { - setBulkAddProgress((prev) => - prev ? { ...prev, completed } : prev - ); + setBulkAddProgress((prev) => (prev ? { ...prev, completed } : prev)); } } } setBulkAddProgress((prev) => - prev ? { ...prev, completed: normalizedCount, phase: "applying" } : prev + prev ? { ...prev, completed: normalizedCount, phase: 'applying' } : prev, ); for (const op of ops) { @@ -1001,11 +1157,31 @@ export default function App() { } setHeadLamport(lamportRef.current); - notifyLocalUpdate(ops); - await refreshPayloadsForNodes(client, nodesAffectedByPayloadOps(ops)); + applyLocalPayloadPreview(payloadPreviewEntries); ingestOps(ops, { assumeSorted: true }); - scheduleRefreshParents(parentsAffectedByOps(stateBefore, ops)); - scheduleRefreshNodeCount(); + setTreeState((prev) => { + let next = prev; + const groupedChildren = new Map(); + for (const op of ops) { + if (op.kind.type !== 'insert') continue; + const children = groupedChildren.get(op.kind.parent); + if (children) children.push(op.kind.node); + else groupedChildren.set(op.kind.parent, [op.kind.node]); + } + for (const [insertParentId, childIds] of groupedChildren) { + next = applyAppendedChildren(next, insertParentId, childIds); + } + return next; + }); + const affectedParents = parentsAffectedByOps(stateBefore, ops); + if (deferLocalReconciliation) { + scheduleRefreshParentsAfterPaint(affectedParents); + scheduleRefreshNodeCountAfterPaint(); + } else { + scheduleRefreshParents(affectedParents); + scheduleRefreshNodeCount(); + } + notifyLocalUpdate(ops); setCollapse((prev) => { const overrides = new Set(prev.overrides); const setExpanded = (id: string) => { @@ -1022,30 +1198,66 @@ export default function App() { return { ...prev, overrides }; }); } catch (err) { - console.error("Failed to add nodes", err); - setError("Failed to add nodes (see console)"); + console.error('Failed to add nodes', err); + setError('Failed to add nodes (see console)'); } finally { setBulkAddProgress(null); setBusy(false); } }; + useEffect(() => { + return registerBenchBindings({ + seedBalancedTree: async ({ count, fanout }) => { + await handleAddNodes(ROOT_ID, count, { fanout }); + }, + getState: () => ({ + status, + totalNodes, + headLamport, + syncBusy, + liveBusy, + }), + }); + }, [handleAddNodes, headLamport, liveBusy, status, syncBusy, totalNodes]); + const handleInsert = async (parentId: string) => { if (!client || !replica) return; if (authEnabled && !canWriteStructure) return; - setBusy(true); + if (localInsertInFlightRef.current) return; + localInsertInFlightRef.current = true; try { const stateBefore = treeStateRef.current; - const valueBase = canWritePayload ? newNodeValue.trim() : ""; + const deferLocalReconciliation = syncTransportMode !== 'local'; + const sourceLocalWriteStartedAtMs = Date.now(); + const valueBase = canWritePayload ? newNodeValue.trim() : ''; const payload = valueBase.length > 0 ? textEncoder.encode(valueBase) : null; const encryptedPayload = await encryptPayloadBytes(payload); const nodeId = makeNodeId(); - const op = await client.local.insert(replica, parentId, nodeId, { type: "last" }, encryptedPayload); - notifyLocalUpdate([op]); - await refreshPayloadsForNodes(client, [op.kind.node]); + const op = await client.local.insert( + replica, + parentId, + nodeId, + { type: 'last' }, + encryptedPayload, + ); + recordBenchNodeTiming([nodeId], { + sourceLocalWriteStartedAtMs, + sourceLocalPersistedAtMs: Date.now(), + }); + applyLocalPayloadPreview([{ nodeId, payload }]); ingestOps([op], { assumeSorted: true }); - scheduleRefreshParents(parentsAffectedByOps(stateBefore, [op])); - scheduleRefreshNodeCount(); + setTreeState((prev) => applyAppendedChildren(prev, parentId, [op.kind.node])); + const affectedParents = parentsAffectedByOps(stateBefore, [op]); + if (deferLocalReconciliation) { + scheduleRefreshParentsAfterPaint(affectedParents); + scheduleRefreshNodeCountAfterPaint(); + } else { + scheduleRefreshParents(affectedParents); + scheduleRefreshNodeCount(); + } + recordBenchNodeTiming([nodeId], { sourceLocalPreviewAppliedAtMs: Date.now() }); + notifyLocalUpdate([op]); if (!Object.prototype.hasOwnProperty.call(treeStateRef.current.childrenByParent, parentId)) { await ensureChildrenLoaded(parentId, { force: true }); } @@ -1067,34 +1279,34 @@ export default function App() { return { ...prev, overrides }; }); } catch (err) { - console.error("Failed to insert node", err); - setError("Failed to insert node (see console)"); + console.error('Failed to insert node', err); + setError('Failed to insert node (see console)'); } finally { - setBusy(false); + localInsertInFlightRef.current = false; } }; const handleSetValue = async (nodeId: string, value: string) => { if (nodeId === ROOT_ID) return; const payload = value.trim().length === 0 ? null : textEncoder.encode(value); - await appendOperation({ type: "payload", node: nodeId, payload }); + await appendOperation({ type: 'payload', node: nodeId, payload }); }; const handleDelete = async (nodeId: string) => { if (nodeId === ROOT_ID) return; - await appendOperation({ type: "delete", node: nodeId }); + await appendOperation({ type: 'delete', node: nodeId }); }; - const handleMove = async (nodeId: string, direction: "up" | "down") => { + const handleMove = async (nodeId: string, direction: 'up' | 'down') => { const meta = index[nodeId]; if (!meta || meta.parentId === null) return; const siblings = childrenByParent[meta.parentId] ?? []; const currentIdx = siblings.indexOf(nodeId); if (currentIdx === -1) return; - const targetIdx = direction === "up" ? currentIdx - 1 : currentIdx + 1; + const targetIdx = direction === 'up' ? currentIdx - 1 : currentIdx + 1; if (targetIdx < 0 || targetIdx >= siblings.length) return; const without = siblings.filter((id) => id !== nodeId); - const after = targetIdx <= 0 ? null : without[targetIdx - 1] ?? null; + const after = targetIdx <= 0 ? null : (without[targetIdx - 1] ?? null); await appendMoveAfter(nodeId, meta.parentId, after); }; @@ -1114,14 +1326,14 @@ export default function App() { }; const handleNewDoc = async () => { - if (typeof window !== "undefined") { + if (typeof window !== 'undefined') { const url = new URL(window.location.href); - url.searchParams.set("doc", makeDefaultDocId()); - url.searchParams.delete("join"); - url.searchParams.delete("autosync"); - url.hash = ""; - window.history.replaceState({}, "", url); - const nextDocId = url.searchParams.get("doc")!; + url.searchParams.set('doc', makeDefaultDocId()); + url.searchParams.delete('join'); + url.searchParams.delete('autosync'); + url.hash = ''; + window.history.replaceState({}, '', url); + const nextDocId = url.searchParams.get('doc')!; setDocId(nextDocId); setLiveChildrenParents(new Set()); setLiveAllEnabled(false); @@ -1145,7 +1357,9 @@ export default function App() { }; const toggleCollapse = (id: string) => { - const currentlyCollapsed = collapse.defaultCollapsed ? !collapse.overrides.has(id) : collapse.overrides.has(id); + const currentlyCollapsed = collapse.defaultCollapsed + ? !collapse.overrides.has(id) + : collapse.overrides.has(id); if (currentlyCollapsed) { void ensureChildrenLoaded(id); // For scoped tokens, expanding a node should opportunistically sync its children. @@ -1163,7 +1377,8 @@ export default function App() { }; const expandAll = () => setCollapse({ defaultCollapsed: false, overrides: new Set() }); - const collapseAll = () => setCollapse({ defaultCollapsed: true, overrides: new Set([viewRootId]) }); + const collapseAll = () => + setCollapse({ defaultCollapsed: true, overrides: new Set([viewRootId]) }); const selfPeerIdShort = selfPeerId ? selfPeerId.length > 20 @@ -1171,28 +1386,28 @@ export default function App() { : selfPeerId : null; const authScopeSummary = (() => { - if (!authTokenScope) return "-"; + if (!authTokenScope) return '-'; const rootId = (authTokenScope.rootNodeId ?? ROOT_ID).toLowerCase(); - if (rootId === ROOT_ID) return "doc-wide"; + if (rootId === ROOT_ID) return 'doc-wide'; const label = nodeLabelForId(rootId); if (label && label !== rootId) return `subtree ${label}`; return `subtree ${rootId.slice(0, 8)}…`; })(); const authScopeTitle = (() => { - if (!authTokenScope) return ""; + if (!authTokenScope) return ''; const rootId = (authTokenScope.rootNodeId ?? ROOT_ID).toLowerCase(); const parts = [`root=${rootId}`]; if (authTokenScope.maxDepth !== undefined) parts.push(`maxDepth=${authTokenScope.maxDepth}`); const excludeCount = authTokenScope.excludeNodeIds?.length ?? 0; if (excludeCount > 0) parts.push(`exclude=${excludeCount}`); - return parts.join(" "); + return parts.join(' '); })(); const authSummaryBadges = (() => { if (!Array.isArray(authTokenActions)) return []; const set = new Set(authTokenActions.map(String)); const out: string[] = []; - if (set.has("write_structure") || set.has("write_payload")) out.push("write"); - if (set.has("delete")) out.push("delete"); + if (set.has('write_structure') || set.has('write_payload')) out.push('write'); + if (set.has('delete')) out.push('delete'); return out; })(); const canManageCapabilities = authEnabled && (authCanIssue || authCanDelegate); @@ -1233,7 +1448,7 @@ export default function App() { selfPeerIdShort={selfPeerIdShort} onCopyPubkey={() => void (selfPeerId ? copyToClipboard(selfPeerId) : Promise.resolve()).catch((err) => - setSyncError(err instanceof Error ? err.message : String(err)) + setSyncError(err instanceof Error ? err.message : String(err)), ) } onSelectStorage={handleStorageToggle} @@ -1245,7 +1460,7 @@ export default function App() { />
-
+
{ void (authCanSyncAll ? handleSync({ all: {} }) : handleScopedSync()); }} - canSync={status === "ready" && !busy && !syncBusy && peers.length > 0 && online} + canSync={status === 'ready' && !busy && !syncBusy && peers.length > 0 && online} onDetails={() => setShowAuthPanel(true)} />
diff --git a/examples/playground/src/playground/bench.ts b/examples/playground/src/playground/bench.ts new file mode 100644 index 00000000..882db36d --- /dev/null +++ b/examples/playground/src/playground/bench.ts @@ -0,0 +1,95 @@ +import { ROOT_ID } from './constants'; +import type { Status } from './types'; + +export type PlaygroundBenchNodeTiming = { + sourceLocalWriteStartedAtMs?: number; + sourceLocalPersistedAtMs?: number; + sourceLocalPreviewAppliedAtMs?: number; + sourceRemoteQueuedAtMs?: number; + sourceRemotePushStartedAtMs?: number; + sourceRemotePushFinishedAtMs?: number; + remoteOpsAppliedStartedAtMs?: number; + payloadsRefreshedAtMs?: number; + remoteOpsAppliedFinishedAtMs?: number; + treeRefreshAppliedAtMs?: number; + rowCommittedAtMs?: number; + targetSocketMessageAtMs?: number; + targetBackendApplyStartedAtMs?: number; + targetBackendApplyFinishedAtMs?: number; +}; + +export type PlaygroundBenchState = { + status: Status; + totalNodes: number | null; + headLamport: number; + syncBusy: boolean; + liveBusy: boolean; +}; + +export type PlaygroundBenchWindow = { + nodes: Record; + lastRemoteSocketMessageAtMs?: number; + seedBalancedTree?: (opts: { count: number; fanout: number }) => Promise; + getState?: () => PlaygroundBenchState; +}; + +declare global { + interface Window { + __treecrdtPlaygroundBench?: PlaygroundBenchWindow; + } +} + +function getPlaygroundBench(create = false): PlaygroundBenchWindow | undefined { + if (typeof window === 'undefined') return undefined; + if (!window.__treecrdtPlaygroundBench && create) { + window.__treecrdtPlaygroundBench = { nodes: {} }; + } + return window.__treecrdtPlaygroundBench; +} + +// Keep benchmark-only globals behind one helper so shipped UI code does not open-code them. +export function recordBenchNodeTiming( + nodeIds: Iterable, + patch: Partial, +): void { + const bench = getPlaygroundBench(true); + if (!bench) return; + for (const nodeId of nodeIds) { + if (!nodeId || nodeId === ROOT_ID) continue; + bench.nodes[nodeId] = { ...bench.nodes[nodeId], ...patch }; + } +} + +export function registerBenchBindings(bindings: { + seedBalancedTree?: PlaygroundBenchWindow['seedBalancedTree']; + getState?: PlaygroundBenchWindow['getState']; +}): () => void { + const bench = getPlaygroundBench(true); + if (!bench) return () => {}; + if (bindings.seedBalancedTree) bench.seedBalancedTree = bindings.seedBalancedTree; + if (bindings.getState) bench.getState = bindings.getState; + return () => { + const current = getPlaygroundBench(false); + if (!current) return; + if (bindings.seedBalancedTree) delete current.seedBalancedTree; + if (bindings.getState) delete current.getState; + }; +} + +export function setBenchLastRemoteSocketMessageAtNow(now = Date.now()): void { + const bench = getPlaygroundBench(true); + if (!bench) return; + bench.lastRemoteSocketMessageAtMs = now; +} + +export function getBenchLastRemoteSocketMessageAtMs(): number | undefined { + return getPlaygroundBench(false)?.lastRemoteSocketMessageAtMs; +} + +export function markBenchRowCommitted(nodeId: string, now = Date.now()): void { + const bench = getPlaygroundBench(false); + if (!bench || !nodeId || nodeId === ROOT_ID) return; + const entry = bench.nodes[nodeId]; + if (!entry || typeof entry.rowCommittedAtMs === 'number') return; + entry.rowCommittedAtMs = now; +} diff --git a/examples/playground/src/playground/components/TreeRow.tsx b/examples/playground/src/playground/components/TreeRow.tsx index fd877115..3895cb44 100644 --- a/examples/playground/src/playground/components/TreeRow.tsx +++ b/examples/playground/src/playground/components/TreeRow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { MdAdd, @@ -25,6 +25,7 @@ import { toggleCapabilityAction, type CapabilityAction, } from "../capabilities"; +import { markBenchRowCommitted } from "../bench"; import { ROOT_ID } from "../constants"; import type { CollapseState, DisplayNode, NodeMeta, PeerInfo } from "../types"; @@ -332,6 +333,10 @@ export function TreeRow({ }; }, [showMembersMenu, updateMembersMenuLayout]); + useLayoutEffect(() => { + markBenchRowCommitted(node.id); + }, [node.id]); + return (
(); + for (const op of ops) { + switch (op.kind.type) { + case 'insert': + case 'payload': + case 'delete': + case 'move': + case 'tombstone': + nodeIds.add(op.kind.node); + break; + default: { + const _exhaustive: never = op.kind; + throw new Error(`unknown op kind: ${String((_exhaustive as any)?.type)}`); + } + } + } + return Array.from(nodeIds); +} + function withTimeout(promise: Promise, ms: number, message: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(message)), ms); @@ -474,8 +499,12 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn }; const notifyLocalUpdate = (ops?: Operation[]) => { + const affectedNodeIds = ops ? affectedNodeIdsFromOps(ops) : []; void syncPeerRef.current?.notifyLocalUpdate(ops); queueRemoteUploadHints(ops); + if (affectedNodeIds.length > 0) { + recordBenchNodeTiming(affectedNodeIds, { sourceRemoteQueuedAtMs: Date.now() }); + } if (remoteLivePushRunningRef.current) { remoteLivePushScheduledRef.current = true; return; @@ -515,6 +544,9 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn const conn = connections.get(peerId); if (!conn) continue; try { + if (affectedNodeIds.length > 0) { + recordBenchNodeTiming(affectedNodeIds, { sourceRemotePushStartedAtMs: Date.now() }); + } if (pendingOps.length > 0) { await withTimeout( peer.pushOps(conn.transport, pendingOps, { @@ -523,6 +555,11 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn syncTimeoutMsForPeer(peerId, { autoSync: true }), `live push with ${peerId.slice(0, 8)}… timed out`, ); + if (affectedNodeIds.length > 0) { + recordBenchNodeTiming(affectedNodeIds, { + sourceRemotePushFinishedAtMs: Date.now(), + }); + } continue; } @@ -532,6 +569,11 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn syncTimeoutMsForPeer(peerId, { autoSync: true }), `live sync with ${peerId.slice(0, 8)}… timed out`, ); + if (affectedNodeIds.length > 0) { + recordBenchNodeTiming(affectedNodeIds, { + sourceRemotePushFinishedAtMs: Date.now(), + }); + } continue; } @@ -546,6 +588,11 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn `live sync(children ${parentId.slice(0, 8)}…) with ${peerId.slice(0, 8)}… timed out`, ); } + if (affectedNodeIds.length > 0) { + recordBenchNodeTiming(affectedNodeIds, { + sourceRemotePushFinishedAtMs: Date.now(), + }); + } } catch (err) { console.error('Remote live sync push failed', err); setSyncError(formatSyncError(err)); @@ -1019,7 +1066,19 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn if (debugSync && ops.length > 0) { console.debug(`[sync:${selfPeerId}] applyOps(${ops.length})`); } + const affectedNodeIds = affectedNodeIdsFromOps(ops); + if (affectedNodeIds.length > 0) { + const targetBackendApplyStartedAtMs = Date.now(); + const targetSocketMessageAtMs = getBenchLastRemoteSocketMessageAtMs(); + recordBenchNodeTiming(affectedNodeIds, { + targetBackendApplyStartedAtMs, + ...(typeof targetSocketMessageAtMs === 'number' ? { targetSocketMessageAtMs } : {}), + }); + } await baseBackend.applyOps(ops); + if (affectedNodeIds.length > 0) { + recordBenchNodeTiming(affectedNodeIds, { targetBackendApplyFinishedAtMs: Date.now() }); + } await onRemoteOpsApplied(ops); }, }; @@ -1222,6 +1281,7 @@ export function usePlaygroundSync(opts: UsePlaygroundSyncOptions): PlaygroundSyn remoteSocket.addEventListener('message', () => { if (disposed || syncConnRef.current !== connections) return; if (!remotePeerId) return; + setBenchLastRemoteSocketMessageAtNow(); remotePeerRef.current = { id: remotePeerId, lastSeen: Date.now() }; setRemoteSyncStatus((prev) => prev.state === 'connected' diff --git a/examples/playground/src/playground/payloads.ts b/examples/playground/src/playground/payloads.ts new file mode 100644 index 00000000..3d788ac2 --- /dev/null +++ b/examples/playground/src/playground/payloads.ts @@ -0,0 +1,91 @@ +import type { Operation } from '@treecrdt/interface'; +import { maybeDecryptTreecrdtPayloadV1 } from '@treecrdt/crypto'; +import type { TreecrdtClient } from '@treecrdt/wa-sqlite/client'; + +import { ROOT_ID } from './constants'; +import { nodesAffectedByPayloadOps } from './treeState'; +import type { PayloadRecord } from './types'; + +export function applyLocalPayloadPreview( + payloads: Map, + entries: Iterable<{ nodeId: string; payload: Uint8Array | null }>, +): boolean { + let changed = false; + for (const { nodeId, payload } of entries) { + if (!nodeId || nodeId === ROOT_ID) continue; + payloads.set(nodeId, { payload, encrypted: false }); + changed = true; + } + return changed; +} + +export async function hydratePayloadsForOps(opts: { + payloads: Map; + active: TreecrdtClient; + ops: Iterable; + docId: string; + requireDocPayloadKey: () => Promise; + refreshPayloadsForNodes: (active: TreecrdtClient, nodeIds: Iterable) => Promise; +}): Promise { + const { payloads, active, ops, docId, requireDocPayloadKey, refreshPayloadsForNodes } = opts; + const materialized = Array.isArray(ops) ? ops : Array.from(ops); + const handled = new Set(); + let changed = false; + let payloadKeyPromise: Promise | null = null; + const getPayloadKey = () => { + payloadKeyPromise ??= requireDocPayloadKey(); + return payloadKeyPromise; + }; + + for (const op of materialized) { + const kind = op.kind; + if (kind.type === 'insert') { + if (kind.payload === undefined) { + payloads.set(kind.node, { payload: null, encrypted: false }); + handled.add(kind.node); + changed = true; + continue; + } + try { + const res = await maybeDecryptTreecrdtPayloadV1({ + docId, + payloadKey: await getPayloadKey(), + bytes: kind.payload, + }); + payloads.set(kind.node, { payload: res.plaintext, encrypted: res.encrypted }); + } catch { + payloads.set(kind.node, { payload: null, encrypted: true }); + } + handled.add(kind.node); + changed = true; + continue; + } + + if (kind.type === 'payload') { + if (kind.payload === null) { + payloads.set(kind.node, { payload: null, encrypted: false }); + } else { + try { + const res = await maybeDecryptTreecrdtPayloadV1({ + docId, + payloadKey: await getPayloadKey(), + bytes: kind.payload, + }); + payloads.set(kind.node, { payload: res.plaintext, encrypted: res.encrypted }); + } catch { + payloads.set(kind.node, { payload: null, encrypted: true }); + } + } + handled.add(kind.node); + changed = true; + } + } + + const remaining = [...nodesAffectedByPayloadOps(materialized)].filter((nodeId) => !handled.has(nodeId)); + if (remaining.length > 0) { + await refreshPayloadsForNodes(active, remaining); + changed = true; + } + + return changed; +} diff --git a/examples/playground/src/playground/treeState.ts b/examples/playground/src/playground/treeState.ts index 69247785..6c301d3a 100644 --- a/examples/playground/src/playground/treeState.ts +++ b/examples/playground/src/playground/treeState.ts @@ -68,6 +68,27 @@ export function nodesAffectedByPayloadOps(ops: Operation[]): Set { return out; } +export function applyAppendedChildren( + state: TreeState, + parentId: string, + childIds: Iterable +): TreeState { + const existingChildren = state.childrenByParent[parentId]; + if (!existingChildren) return state; + + const nextChildren = [...existingChildren]; + const seen = new Set(existingChildren); + let changed = false; + for (const childId of childIds) { + if (seen.has(childId)) continue; + seen.add(childId); + nextChildren.push(childId); + changed = true; + } + if (!changed) return state; + return applyChildrenLoaded(state, parentId, nextChildren); +} + export function parentsAffectedByOps(state: TreeState, ops: Operation[]): Set { const out = new Set(); for (const op of ops) { diff --git a/examples/playground/src/playground/types.ts b/examples/playground/src/playground/types.ts index 336a8ad2..58a16c15 100644 --- a/examples/playground/src/playground/types.ts +++ b/examples/playground/src/playground/types.ts @@ -22,16 +22,16 @@ export type CollapseState = { overrides: Set; }; -export type Status = "booting" | "ready" | "error"; -export type StorageMode = "memory" | "opfs"; -export type SyncTransportMode = "local" | "remote" | "hybrid"; +export type Status = 'booting' | 'ready' | 'error'; +export type StorageMode = 'memory' | 'opfs'; +export type SyncTransportMode = 'local' | 'remote' | 'hybrid'; export type RemoteSyncStatus = - | { state: "disabled"; detail: string } - | { state: "missing_url"; detail: string } - | { state: "invalid"; detail: string } - | { state: "connecting"; detail: string } - | { state: "connected"; detail: string } - | { state: "error"; detail: string }; + | { state: 'disabled'; detail: string } + | { state: 'missing_url'; detail: string } + | { state: 'invalid'; detail: string } + | { state: 'connecting'; detail: string } + | { state: 'connected'; detail: string } + | { state: 'error'; detail: string }; export type PeerInfo = { id: string; lastSeen: number }; diff --git a/package.json b/package.json index b2a3a21a..d3bc8c31 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,13 @@ "test:browser": "pnpm -C packages/treecrdt-wa-sqlite/e2e test:e2e", "test:native-node": "pnpm -C packages/sync/material/sqlite run test && pnpm -C packages/treecrdt-sqlite-node run test", "benchmark": "rm -rf benchmarks && mkdir -p benchmarks && pnpm run benchmark:web && pnpm run benchmark:sqlite-node:ops && pnpm run benchmark:sqlite-node:note-paths && pnpm run benchmark:sync:direct && pnpm run benchmark:wasm && pnpm run benchmark:postgres && pnpm run benchmark:core && pnpm run benchmark:aggregate", - "benchmark:sqlite-node": "pnpm run benchmark:sqlite-node:ops && pnpm run benchmark:sqlite-node:note-paths && pnpm run benchmark:sqlite-node:sync", + "benchmark:sqlite-node": "pnpm run benchmark:sqlite-node:ops && pnpm run benchmark:sqlite-node:note-paths && pnpm run benchmark:sqlite-node:hot-write && pnpm run benchmark:sqlite-node:sync", "benchmark:core": "cargo bench -p treecrdt-core --bench core --features bench", "benchmark:aggregate": "node scripts/aggregate-bench.mjs", "benchmark:postgres": "(if [ -n \"$TREECRDT_POSTGRES_URL\" ]; then pnpm -C packages/treecrdt-postgres-napi run benchmark; else echo \"Skipping postgres-napi benchmark (TREECRDT_POSTGRES_URL not set)\"; fi)", + "benchmark:hot-write": "pnpm run benchmark:sqlite-node:hot-write && pnpm run benchmark:postgres:hot-write", + "benchmark:postgres:hot-write": "(if [ -n \"$TREECRDT_POSTGRES_URL\" ]; then pnpm -C packages/treecrdt-postgres-napi run benchmark:hot-write; else echo \"Skipping postgres hot-write benchmark (TREECRDT_POSTGRES_URL not set)\"; fi)", + "benchmark:sqlite-node:hot-write": "pnpm -C packages/treecrdt-sqlite-node run benchmark:hot-write", "benchmark:sqlite-node:note-paths": "pnpm -C packages/treecrdt-sqlite-node run benchmark:note-paths", "benchmark:sqlite-node:ops": "pnpm -C packages/treecrdt-sqlite-node run benchmark:ops", "benchmark:sqlite-node:sync": "pnpm run benchmark:sync:direct", @@ -38,8 +41,8 @@ "benchmark:sync:upload:local": "node scripts/run-sync-bench.mjs local prime", "benchmark:sync:upload:remote": "node scripts/run-sync-bench.mjs remote prime", "benchmark:sync:remote": "node scripts/run-sync-bench.mjs remote", - "benchmark:playground:live-write": "node scripts/bench-playground-live-write.mjs", - "benchmark:sync:bootstrap": "node scripts/bench-discovery-connect.mjs", + "benchmark:playground:live-write": "pnpm -C packages/treecrdt-benchmark run build && node scripts/bench-playground-live-write.mjs", + "benchmark:sync:bootstrap": "pnpm -C packages/treecrdt-benchmark run build && node scripts/bench-discovery-connect.mjs", "benchmark:wasm": "pnpm -C packages/treecrdt-wasm-js run benchmark", "benchmark:web": "pnpm -C packages/treecrdt-wa-sqlite/e2e run bench", "test": "pnpm run test:browser && pnpm run test:native-node", diff --git a/packages/sync/protocol/tests/smoke.test.ts b/packages/sync/protocol/tests/smoke.test.ts index 90c22e85..e502fab7 100644 --- a/packages/sync/protocol/tests/smoke.test.ts +++ b/packages/sync/protocol/tests/smoke.test.ts @@ -576,6 +576,14 @@ test('syncOnce waits for ribltStatus.more before sending another codeword batch' const a = new MemoryBackend(docId); const b = new MemoryBackend(docId); + await b.applyOps([ + makeOp(replicas.b, 1, 1, { + type: 'insert', + parent: root, + node: nodeIdFromInt(1000), + orderKey: orderKeyFromPosition(0), + }), + ]); const ops: Operation[] = []; for (let i = 1; i <= 12; i += 1) { diff --git a/packages/treecrdt-benchmark/package.json b/packages/treecrdt-benchmark/package.json index 45e81f8b..1f48e3b7 100644 --- a/packages/treecrdt-benchmark/package.json +++ b/packages/treecrdt-benchmark/package.json @@ -10,6 +10,14 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./helpers": { + "import": "./dist/helpers.js", + "types": "./dist/helpers.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + }, "./node": { "import": "./dist/node.js", "types": "./dist/node.d.ts" diff --git a/packages/treecrdt-benchmark/src/helpers.ts b/packages/treecrdt-benchmark/src/helpers.ts new file mode 100644 index 00000000..69a22771 --- /dev/null +++ b/packages/treecrdt-benchmark/src/helpers.ts @@ -0,0 +1,62 @@ +export function parseFlagValue(argv: string[], flag: string): string | undefined { + const prefix = `${flag}=`; + const raw = argv.find((arg) => arg.startsWith(prefix)); + return raw ? raw.slice(prefix.length).trim() : undefined; +} + +export function parsePositiveIntFlag( + argv: string[], + flag: string, + envName: string, + fallback: number, +): number { + const raw = parseFlagValue(argv, flag) ?? process.env[envName]; + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`invalid ${flag} value "${raw}", expected a positive integer`); + } + return value; +} + +export function parseNonNegativeIntFlag( + argv: string[], + flag: string, + envName: string, + fallback: number, +): number { + const raw = parseFlagValue(argv, flag) ?? process.env[envName]; + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`invalid ${flag} value "${raw}", expected a non-negative integer`); + } + return value; +} + +export function orderKeyFromPosition(position: number): Uint8Array { + if (!Number.isInteger(position) || position < 0) throw new Error(`invalid position: ${position}`); + const n = position + 1; + if (n > 0xffff) throw new Error(`position too large for u16 order key: ${position}`); + const bytes = new Uint8Array(2); + new DataView(bytes.buffer).setUint16(0, n, false); + return bytes; +} + +export function replicaFromLabel(label: string): Uint8Array { + const encoded = new TextEncoder().encode(label); + if (encoded.length === 0) throw new Error('label must not be empty'); + const out = new Uint8Array(32); + for (let i = 0; i < out.length; i += 1) out[i] = encoded[i % encoded.length]!; + return out; +} + +export function payloadBytesFromSeed(seed: number, size = 512): Uint8Array { + if (!Number.isInteger(seed) || seed < 0) throw new Error(`invalid payload seed: ${seed}`); + if (!Number.isInteger(size) || size <= 0) throw new Error(`invalid payload size: ${size}`); + const out = new Uint8Array(size); + for (let i = 0; i < out.length; i += 1) { + out[i] = (seed + i * 31) % 251; + } + return out; +} diff --git a/packages/treecrdt-benchmark/src/hot-write.ts b/packages/treecrdt-benchmark/src/hot-write.ts new file mode 100644 index 00000000..93d54f8a --- /dev/null +++ b/packages/treecrdt-benchmark/src/hot-write.ts @@ -0,0 +1,533 @@ +import path from 'node:path'; + +import type { Operation, ReplicaId } from '@treecrdt/interface'; +import type { TreecrdtEngine } from '@treecrdt/interface/engine'; + +import type { BenchmarkResult } from './index.js'; +import { + parseFlagValue, + parseNonNegativeIntFlag, + parsePositiveIntFlag, + payloadBytesFromSeed, + replicaFromLabel, +} from './helpers.js'; +import { buildFanoutInsertTreeOps, nodeIdFromInt } from './sync.js'; +import { writeResult, type BenchmarkOutput } from './node.js'; +import { quantile, summarizeSamples } from './stats.js'; + +export { + parseFlagValue, + parseNonNegativeIntFlag, + parsePositiveIntFlag, + payloadBytesFromSeed, + replicaFromLabel, +} from './helpers.js'; + +export type HotWriteBenchKind = 'payload-edit' | 'insert-sibling' | 'move-leaf' | 'move-subtree'; +export type HotWriteConfigEntry = [count: number, iterations: number]; + +export const ALL_HOT_WRITE_BENCHES = [ + 'payload-edit', + 'insert-sibling', + 'move-leaf', + 'move-subtree', +] as const satisfies readonly HotWriteBenchKind[]; + +export const DEFAULT_HOT_WRITE_CONFIG: ReadonlyArray = [ + [10_000, 3], + [100_000, 1], +] as const; + +export const DEFAULT_HOT_WRITE_FANOUT = 10; +export const DEFAULT_HOT_WRITE_PAYLOAD_BYTES = 512; +export const HOT_WRITE_ROOT = '0'.repeat(32); + +export type HotWriteSeed = { + ops: Operation[]; +} & HotWriteSeedTargets; + +export type HotWriteSeedTargets = { + targetParent: string; + payloadNode: string; + moveLeafNode: string; + moveLeafOriginalParent: string; + moveNode: string; + moveNodeOriginalParent: string; +}; + +export type HotWriteWorkload = { + name: string; + totalOps: number; + run: ( + engine: TreecrdtEngine, + ctx: { writeIndex: number; totalWrites: number }, + ) => Promise<{ extra?: Record } | void>; +}; + +export function parseHotWriteConfigFromArgv(argv: string[]): Array | null { + let customConfig: Array | null = null; + const defaultIterations = Math.max(1, Number(process.env.BENCH_ITERATIONS ?? '1') || 1); + for (const arg of argv) { + if (arg.startsWith('--count=')) { + const val = arg.slice('--count='.length).trim(); + const count = val ? Number(val) : 10_000; + customConfig = [[Number.isFinite(count) && count > 0 ? count : 10_000, defaultIterations]]; + break; + } + if (arg.startsWith('--counts=')) { + const vals = arg + .slice('--counts='.length) + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + const parsed = vals + .map((s) => { + const n = Number(s); + return Number.isFinite(n) && n > 0 ? n : null; + }) + .filter((n): n is number => n != null) + .map((count) => [count, defaultIterations] as HotWriteConfigEntry); + if (parsed.length > 0) customConfig = parsed; + break; + } + } + return customConfig; +} + +export function parseHotWriteKinds(argv: string[]): HotWriteBenchKind[] { + const raw = + parseFlagValue(argv, '--benches') ?? + parseFlagValue(argv, '--bench') ?? + process.env.HOT_WRITE_BENCHES ?? + process.env.HOT_WRITE_BENCH; + if (!raw) return Array.from(ALL_HOT_WRITE_BENCHES); + + const seen = new Set(); + for (const value of raw + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0)) { + if (!(ALL_HOT_WRITE_BENCHES as readonly string[]).includes(value)) { + throw new Error( + `invalid hot-write bench "${value}", expected one of: ${ALL_HOT_WRITE_BENCHES.join(', ')}`, + ); + } + seen.add(value as HotWriteBenchKind); + } + return seen.size > 0 ? Array.from(seen) : Array.from(ALL_HOT_WRITE_BENCHES); +} + +export function buildHotWriteSeedTargets(opts: { + size: number; + fanout: number; +}): HotWriteSeedTargets { + if (!Number.isInteger(opts.size) || opts.size <= opts.fanout) { + throw new Error(`hot-write seed requires size > fanout (${opts.fanout})`); + } + const targetParent = nodeIdFromInt(1); + const payloadNode = nodeIdFromInt(opts.fanout + 1); + const moveLeafNode = nodeIdFromInt(opts.size); + const moveLeafOriginalParent = nodeIdFromInt(Math.floor((opts.size - 2) / opts.fanout) + 1); + const moveNode = nodeIdFromInt(opts.fanout); + const moveNodeOriginalParent = HOT_WRITE_ROOT; + + return { + targetParent, + payloadNode, + moveLeafNode, + moveLeafOriginalParent, + moveNode, + moveNodeOriginalParent, + }; +} + +export function buildHotWriteSeed(opts: { + size: number; + fanout: number; + payloadBytes: number; + seed?: HotWriteSeedTargets; +}): HotWriteSeed { + const seed = opts.seed ?? buildHotWriteSeedTargets({ size: opts.size, fanout: opts.fanout }); + const replica = replicaFromLabel('bench'); + const insertOps = buildFanoutInsertTreeOps({ + replica, + size: opts.size, + fanout: opts.fanout, + root: HOT_WRITE_ROOT, + }); + + const payloadOp: Operation = { + meta: { id: { replica, counter: insertOps.length + 1 }, lamport: insertOps.length + 1 }, + kind: { + type: 'payload', + node: seed.payloadNode, + payload: payloadBytesFromSeed(10_000, opts.payloadBytes), + }, + }; + + return { + ...seed, + ops: [...insertOps, payloadOp], + }; +} + +export function createHotWriteWorkload(opts: { + bench: HotWriteBenchKind; + size: number; + fanout: number; + payloadBytes: number; + seed: HotWriteSeedTargets; + writesPerSample?: number; + warmupWrites?: number; +}): HotWriteWorkload { + const targetParent = opts.seed.targetParent; + const totalWrites = (opts.writesPerSample ?? 1) + (opts.warmupWrites ?? 0); + const nameSuffix = + totalWrites > 1 ? `-warm${opts.warmupWrites ?? 0}-repeat${opts.writesPerSample ?? 1}` : ''; + if (opts.bench === 'payload-edit') { + const replica = replicaFromLabel('payload-writer'); + const payloadNode = opts.seed.payloadNode; + return { + name: `hot-write-payload-edit-fanout${opts.fanout}-${opts.size}${nameSuffix}`, + totalOps: 1, + run: async (engine, ctx) => { + const expectedPayload = payloadBytesFromSeed(90_000 + ctx.writeIndex, opts.payloadBytes); + const mutationStart = performance.now(); + const op = await engine.local.payload(replica, payloadNode, expectedPayload); + const mutationMs = performance.now() - mutationStart; + if (op.kind.type !== 'payload' || op.kind.node !== payloadNode) { + throw new Error('payload edit did not return the target node'); + } + const verifyStart = performance.now(); + const stored = await engine.tree.getPayload(payloadNode); + const verifyMs = performance.now() - verifyStart; + if (!stored || !equalBytes(stored, expectedPayload)) { + throw new Error('payload edit did not persist the expected bytes'); + } + return { + extra: { + payloadNode, + payloadBytes: expectedPayload.length, + mutationMs, + verifyMs, + }, + }; + }, + }; + } + + if (opts.bench === 'insert-sibling') { + const replica = replicaFromLabel('insert-writer'); + return { + name: `hot-write-insert-sibling-fanout${opts.fanout}-${opts.size}${nameSuffix}`, + totalOps: 1, + run: async (engine, ctx) => { + const newNode = nodeIdFromInt(opts.size + 10_000 + ctx.writeIndex + 1); + const payload = payloadBytesFromSeed(91_000 + ctx.writeIndex, opts.payloadBytes); + const mutationStart = performance.now(); + const op = await engine.local.insert( + replica, + targetParent, + newNode, + { type: 'last' }, + payload, + ); + const mutationMs = performance.now() - mutationStart; + if (op.kind.type !== 'insert' || op.kind.node !== newNode) { + throw new Error('insert did not return the new node'); + } + const verifyStart = performance.now(); + const parent = await engine.tree.parent(newNode); + if (parent !== targetParent) { + throw new Error( + `inserted node parent mismatch: expected ${targetParent}, got ${String(parent)}`, + ); + } + const stored = await engine.tree.getPayload(newNode); + const verifyMs = performance.now() - verifyStart; + if (!stored || !equalBytes(stored, payload)) { + throw new Error('inserted node payload missing'); + } + return { + extra: { + targetParent, + insertedNode: newNode, + payloadBytes: payload.length, + mutationMs, + verifyMs, + }, + }; + }, + }; + } + + const replica = replicaFromLabel( + opts.bench === 'move-leaf' ? 'move-leaf-writer' : 'move-subtree-writer', + ); + const moveTargets = + totalWrites === 1 + ? [ + { + node: opts.bench === 'move-leaf' ? opts.seed.moveLeafNode : opts.seed.moveNode, + originalParent: + opts.bench === 'move-leaf' + ? opts.seed.moveLeafOriginalParent + : opts.seed.moveNodeOriginalParent, + }, + ] + : collectMoveTargets({ + bench: opts.bench, + size: opts.size, + fanout: opts.fanout, + totalWrites, + }); + return { + name: `hot-write-${opts.bench}-fanout${opts.fanout}-${opts.size}${nameSuffix}`, + totalOps: 1, + run: async (engine, ctx) => { + const target = moveTargets[ctx.writeIndex]; + if (!target) throw new Error(`missing ${opts.bench} move target for write ${ctx.writeIndex}`); + const moveNode = target.node; + const mutationStart = performance.now(); + const op = await engine.local.move(replica, moveNode, targetParent, { type: 'last' }); + const mutationMs = performance.now() - mutationStart; + if (op.kind.type !== 'move' || op.kind.node !== moveNode) { + throw new Error('move did not return the moved node'); + } + const verifyStart = performance.now(); + const parent = await engine.tree.parent(moveNode); + if (parent !== targetParent) { + throw new Error( + `moved node parent mismatch: expected ${targetParent}, got ${String(parent)}`, + ); + } + if (!(await engine.tree.exists(moveNode))) { + throw new Error('moved node disappeared after subtree move'); + } + const verifyMs = performance.now() - verifyStart; + return { + extra: { + movedNode: moveNode, + movedFromParent: target.originalParent, + movedToParent: targetParent, + mutationMs, + verifyMs, + }, + }; + }, + }; +} + +export async function runHotWriteBenchmarks(opts: { + repoRoot: string; + implementation: string; + storage: string; + config: ReadonlyArray; + benches: readonly HotWriteBenchKind[]; + fanout: number; + payloadBytes: number; + writesPerSample?: number; + warmupWrites?: number; + openSeededEngine: (args: { + bench: HotWriteBenchKind; + size: number; + seed: HotWriteSeedTargets; + getSeed: () => HotWriteSeed; + sampleIndex: number; + }) => Promise; + outDirName?: string; +}): Promise { + const outputs: BenchmarkOutput[] = []; + + for (const [size, iterations] of opts.config) { + const seed = buildHotWriteSeedTargets({ size, fanout: opts.fanout }); + let fullSeed: HotWriteSeed | null = null; + const getSeed = () => { + if (fullSeed == null) { + fullSeed = buildHotWriteSeed({ + size, + fanout: opts.fanout, + payloadBytes: opts.payloadBytes, + seed, + }); + } + return fullSeed; + }; + for (const bench of opts.benches) { + const workload = createHotWriteWorkload({ + bench, + size, + fanout: opts.fanout, + payloadBytes: opts.payloadBytes, + seed, + writesPerSample: opts.writesPerSample, + warmupWrites: opts.warmupWrites, + }); + const result = await runHotWriteBenchmark({ + sampleDocs: iterations, + writesPerSample: opts.writesPerSample ?? 1, + warmupWrites: opts.warmupWrites ?? 0, + openSeededEngine: (sampleIndex) => + opts.openSeededEngine({ bench, size, seed, getSeed, sampleIndex }), + workload, + }); + const outFile = path.join( + opts.repoRoot, + 'benchmarks', + opts.outDirName ?? 'hot-write', + `${opts.implementation}-${opts.storage}-${result.name}.json`, + ); + outputs.push( + await writeResult(result, { + implementation: opts.implementation, + storage: opts.storage, + workload: result.name, + outFile, + extra: { + count: size, + bench, + fanout: opts.fanout, + payloadBytes: opts.payloadBytes, + writesPerSample: opts.writesPerSample ?? 1, + warmupWrites: opts.warmupWrites ?? 0, + ...result.extra, + }, + }), + ); + } + } + + return outputs; +} + +async function runHotWriteBenchmark(opts: { + sampleDocs: number; + writesPerSample: number; + warmupWrites: number; + openSeededEngine: (sampleIndex: number) => Promise; + workload: HotWriteWorkload; +}): Promise { + const durations: number[] = []; + let lastExtra: Record | undefined; + const numericExtraSamples = new Map(); + + for (let sampleIndex = 0; sampleIndex < opts.sampleDocs; sampleIndex += 1) { + const engine = await opts.openSeededEngine(sampleIndex); + try { + const totalWrites = opts.warmupWrites + opts.writesPerSample; + for (let writeIndex = 0; writeIndex < totalWrites; writeIndex += 1) { + const start = performance.now(); + const runResult = await opts.workload.run(engine, { writeIndex, totalWrites }); + const end = performance.now(); + if (runResult?.extra) lastExtra = runResult.extra; + if (writeIndex >= opts.warmupWrites) { + durations.push(end - start); + if (runResult?.extra) { + for (const [key, value] of Object.entries(runResult.extra)) { + if (typeof value !== 'number' || !Number.isFinite(value)) continue; + const values = numericExtraSamples.get(key) ?? []; + values.push(value); + numericExtraSamples.set(key, values); + } + } + } + } + } finally { + await engine.close(); + } + } + + const durationMs = quantile(durations, 0.5); + const durationSummary = summarizeSamples(durations); + const summarizedNumericExtras = Object.fromEntries( + [...numericExtraSamples.entries()].map(([key, values]) => [ + `${key}Summary`, + summarizeSamples(values), + ]), + ); + const totalOps = opts.workload.totalOps; + return { + name: opts.workload.name, + totalOps, + durationMs, + opsPerSec: durationMs > 0 ? (totalOps / durationMs) * 1000 : Infinity, + extra: { + ...(lastExtra ?? {}), + iterations: durations.length, + sampleDocs: opts.sampleDocs, + writesPerSample: opts.writesPerSample, + warmupWrites: opts.warmupWrites, + warmupIterations: opts.warmupWrites, + samplesMs: durations, + minMs: durationSummary.min, + meanMs: durationSummary.mean, + p95Ms: durationSummary.p95, + p99Ms: durationSummary.p99, + maxMs: durationSummary.max, + ...summarizedNumericExtras, + }, + }; +} + +function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function collectMoveTargets(opts: { + bench: 'move-leaf' | 'move-subtree'; + size: number; + fanout: number; + totalWrites: number; +}): Array<{ node: string; originalParent: string }> { + const targets: Array<{ node: string; originalParent: string }> = []; + for ( + let nodeIndex = opts.size; + nodeIndex >= 1 && targets.length < opts.totalWrites; + nodeIndex -= 1 + ) { + if (opts.bench === 'move-leaf' && hasBalancedChildren(nodeIndex, opts.size, opts.fanout)) { + continue; + } + if (opts.bench === 'move-subtree') { + if (!hasBalancedChildren(nodeIndex, opts.size, opts.fanout)) continue; + if (nodeIndex <= opts.fanout) continue; + } + if (rootChildIndex(nodeIndex, opts.fanout) === 1) continue; + const parentIndex = balancedTreeParentIndex(nodeIndex, opts.fanout); + if (parentIndex == null) continue; + targets.push({ + node: nodeIdFromInt(nodeIndex), + originalParent: nodeIdFromInt(parentIndex), + }); + } + if (targets.length < opts.totalWrites) { + throw new Error( + `not enough ${opts.bench} targets for ${opts.totalWrites} writes in size ${opts.size}`, + ); + } + return targets; +} + +function hasBalancedChildren(nodeIndex: number, size: number, fanout: number): boolean { + return nodeIndex * fanout + 1 <= size; +} + +function balancedTreeParentIndex(nodeIndex: number, fanout: number): number | null { + if (nodeIndex <= 0) throw new Error(`invalid balanced-tree node index: ${nodeIndex}`); + if (nodeIndex <= fanout) return null; + return Math.floor((nodeIndex - (fanout + 1)) / fanout) + 1; +} + +function rootChildIndex(nodeIndex: number, fanout: number): number { + let cursor = nodeIndex; + while (cursor > fanout) { + const parent = balancedTreeParentIndex(cursor, fanout); + if (parent == null) break; + cursor = parent; + } + return cursor; +} diff --git a/packages/treecrdt-benchmark/src/index.ts b/packages/treecrdt-benchmark/src/index.ts index 0c54cc44..233fbf53 100644 --- a/packages/treecrdt-benchmark/src/index.ts +++ b/packages/treecrdt-benchmark/src/index.ts @@ -5,8 +5,10 @@ import type { Operation, } from '@treecrdt/interface'; import { nodeIdToBytes16, replicaIdToBytes } from '@treecrdt/interface/ids'; -import { envInt, quantile } from './stats.js'; +import { orderKeyFromPosition, replicaFromLabel } from './helpers.js'; +import { envInt, quantile, summarizeSamples } from './stats.js'; import type { WorkloadName } from './workloads.js'; +export type { BenchmarkFixtureFactory, BenchmarkFixtureHelpers } from './testing.js'; export type BenchmarkResult = { name: string; @@ -29,23 +31,6 @@ export type BenchmarkWorkload = { const defaultSerializeNodeId: SerializeNodeId = nodeIdToBytes16; const defaultSerializeReplica: SerializeReplica = replicaIdToBytes; -function orderKeyFromPosition(position: number): Uint8Array { - if (!Number.isInteger(position) || position < 0) throw new Error(`invalid position: ${position}`); - const n = position + 1; - if (n > 0xffff) throw new Error(`position too large for u16 order key: ${position}`); - const bytes = new Uint8Array(2); - new DataView(bytes.buffer).setUint16(0, n, false); - return bytes; -} - -function replicaFromLabel(label: string): Uint8Array { - const encoded = new TextEncoder().encode(label); - if (encoded.length === 0) throw new Error('label must not be empty'); - const out = new Uint8Array(32); - for (let i = 0; i < out.length; i += 1) out[i] = encoded[i % encoded.length]!; - return out; -} - export async function runBenchmark( adapterFactory: () => Promise | TreecrdtAdapter, workload: BenchmarkWorkload, @@ -82,6 +67,7 @@ export async function runBenchmark( } const durationMs = quantile(samplesMs, 0.5); + const sampleSummary = summarizeSamples(samplesMs); const opsPerSec = totalOps > 0 && durationMs > 0 ? (totalOps / durationMs) * 1000 @@ -100,9 +86,11 @@ export async function runBenchmark( iterations, warmupIterations, samplesMs, - p95Ms: quantile(samplesMs, 0.95), - minMs: Math.min(...samplesMs), - maxMs: Math.max(...samplesMs), + minMs: sampleSummary.min, + meanMs: sampleSummary.mean, + p95Ms: sampleSummary.p95, + p99Ms: sampleSummary.p99, + maxMs: sampleSummary.max, } : undefined, }; @@ -286,6 +274,7 @@ export async function runWorkloads( } export { DEFAULT_BENCH_SIZES, WORKLOAD_NAMES, type WorkloadName } from './workloads.js'; +export * from './helpers.js'; export { benchTiming } from './timing.js'; export * from './sync.js'; export * from './stats.js'; diff --git a/packages/treecrdt-benchmark/src/stats.ts b/packages/treecrdt-benchmark/src/stats.ts index 55d233e0..b1865655 100644 --- a/packages/treecrdt-benchmark/src/stats.ts +++ b/packages/treecrdt-benchmark/src/stats.ts @@ -42,3 +42,42 @@ export function quantile(values: number[], q: number): number { const w = idx - lo; return sorted[lo]! * (1 - w) + sorted[hi]! * w; } + +export function medianOrNull(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2; +} + +export function percentileNearestRankOrNull(values: number[], p: number): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); + return sorted[index]!; +} + +export type SampleSummary = { + count: number; + min: number; + median: number; + p95: number; + p99: number; + max: number; + mean: number; +}; + +export function summarizeSamples(values: number[]): SampleSummary { + if (values.length === 0) { + throw new Error('cannot summarize an empty sample set'); + } + return { + count: values.length, + min: Math.min(...values), + median: quantile(values, 0.5), + p95: quantile(values, 0.95), + p99: quantile(values, 0.99), + max: Math.max(...values), + mean: values.reduce((sum, value) => sum + value, 0) / values.length, + }; +} diff --git a/packages/treecrdt-benchmark/src/sync.ts b/packages/treecrdt-benchmark/src/sync.ts index 87164fee..71f25f44 100644 --- a/packages/treecrdt-benchmark/src/sync.ts +++ b/packages/treecrdt-benchmark/src/sync.ts @@ -1,5 +1,6 @@ import type { Operation, OperationKind, ReplicaId } from '@treecrdt/interface'; import { nodeIdToBytes16 } from '@treecrdt/interface/ids'; +import { orderKeyFromPosition, payloadBytesFromSeed, replicaFromLabel } from './helpers.js'; import { envIntList } from './stats.js'; import { benchTiming } from './timing.js'; @@ -74,6 +75,16 @@ export function syncBenchTiming(opts: { defaultIterations?: number } = {}): { } export type SyncFilter = { all: Record } | { children: { parent: Uint8Array } }; +export type SyncBenchPayloadDistribution = 'none' | 'everywhere' | 'measured-subtree'; +export type SyncBenchServerPrime = { + kind: 'balanced-fanout'; + size: number; + fanout: number; + treeReplicaLabel: string; + expectedServerOpCount: number; + expectedServerMaxLamport: number; + overlayOps: Operation[]; +}; export type SyncBenchCase = { name: string; @@ -84,6 +95,7 @@ export type SyncBenchCase = { extra: Record; expectedFinalOpsA: number; expectedFinalOpsB: number; + serverPrime?: SyncBenchServerPrime; firstView?: { parent: string; pageSize: number; @@ -98,23 +110,6 @@ export function nodeIdFromInt(i: number): string { return i.toString(16).padStart(32, '0'); } -function orderKeyFromPosition(position: number): Uint8Array { - if (!Number.isInteger(position) || position < 0) throw new Error(`invalid position: ${position}`); - const n = position + 1; - if (n > 0xffff) throw new Error(`position too large for u16 order key: ${position}`); - const bytes = new Uint8Array(2); - new DataView(bytes.buffer).setUint16(0, n, false); - return bytes; -} - -function replicaFromLabel(label: string): Uint8Array { - const encoded = new TextEncoder().encode(label); - if (encoded.length === 0) throw new Error('label must not be empty'); - const out = new Uint8Array(32); - for (let i = 0; i < out.length; i += 1) out[i] = encoded[i % encoded.length]!; - return out; -} - export function makeOp( replica: ReplicaId, counter: number, @@ -130,16 +125,6 @@ export function maxLamport(ops: Operation[]): number { type ParentCursor = { parent: string; nextChildPosition: number }; -function payloadBytesFromSeed(seed: number, size = 512): Uint8Array { - if (!Number.isInteger(seed) || seed < 0) throw new Error(`invalid payload seed: ${seed}`); - if (!Number.isInteger(size) || size <= 0) throw new Error(`invalid payload size: ${size}`); - const out = new Uint8Array(size); - for (let i = 0; i < out.length; i += 1) { - out[i] = (seed + i * 31) % 251; - } - return out; -} - export function buildFanoutInsertTreeOps(opts: { replica: ReplicaId; size: number; @@ -189,45 +174,73 @@ function buildBalancedChildrenColdStartCase(opts: { root: string; payloadBytes: number; withPayloads: boolean; + materializeServerOps: boolean; }): SyncBenchCase { const treeSize = opts.size; if (!Number.isInteger(treeSize) || treeSize <= opts.fanout) { throw new Error(`balanced children cold-start requires size > fanout (${opts.fanout})`); } - const sharedOps = buildFanoutInsertTreeOps({ - replica: opts.replicas.s, - size: treeSize, - fanout: opts.fanout, - root: opts.root, - }); - const scopeRootInsert = sharedOps[0]; - if (!scopeRootInsert || scopeRootInsert.kind.type !== 'insert') { - throw new Error('expected balanced tree seed to start with scope root insert'); - } - - const targetParent = scopeRootInsert.kind.node; + const targetParent = nodeIdFromInt(1); const targetChildren = targetChildrenForFirstChild(treeSize, opts.fanout); + const scopeRootInsert = makeOp(opts.replicas.s, 1, 1, { + type: 'insert', + parent: opts.root, + node: targetParent, + orderKey: orderKeyFromPosition(0), + }); const opsA: Operation[] = [scopeRootInsert]; - const opsB: Operation[] = [...sharedOps]; - if (opts.withPayloads) { - let counter = 0; - let lamport = maxLamport(sharedOps); - for (let i = 0; i < sharedOps.length; i += 1) { - const op = sharedOps[i]; - if (op?.kind.type !== 'insert') continue; - opsB.push( - makeOp(opts.replicas.p, ++counter, ++lamport, { + const subtreePayloadOps = opts.withPayloads + ? [targetParent, ...targetChildren].map((node, index) => { + const counter = index === 0 ? 1 : opts.fanout + index; + return makeOp(opts.replicas.p, counter, treeSize + counter, { type: 'payload', - node: op.kind.node, - payload: payloadBytesFromSeed(i + 1, opts.payloadBytes), - }), - ); + node, + payload: payloadBytesFromSeed(counter, opts.payloadBytes), + }); + }) + : []; + + let opsB: Operation[] = []; + if (opts.materializeServerOps) { + const sharedOps = buildFanoutInsertTreeOps({ + replica: opts.replicas.s, + size: treeSize, + fanout: opts.fanout, + root: opts.root, + }); + opsB = [...sharedOps]; + + if (opts.withPayloads) { + let counter = 0; + let lamport = maxLamport(sharedOps); + for (let i = 0; i < sharedOps.length; i += 1) { + const op = sharedOps[i]; + if (op?.kind.type !== 'insert') continue; + opsB.push( + makeOp(opts.replicas.p, ++counter, ++lamport, { + type: 'payload', + node: op.kind.node, + payload: payloadBytesFromSeed(i + 1, opts.payloadBytes), + }), + ); + } } } + const payloadDistribution: SyncBenchPayloadDistribution = !opts.withPayloads + ? 'none' + : opts.materializeServerOps + ? 'everywhere' + : 'measured-subtree'; const transferredOps = opts.withPayloads ? 1 + targetChildren.length * 2 : targetChildren.length; + const expectedServerOpCount = opts.materializeServerOps + ? opsB.length + : treeSize + subtreePayloadOps.length; + const expectedServerMaxLamport = opts.materializeServerOps + ? maxLamport(opsB) + : treeSize + (subtreePayloadOps.at(-1)?.meta.id.counter ?? 0); return { name: `sync-balanced-children${opts.withPayloads ? '-payloads' : ''}-cold-start-fanout${opts.fanout}-${treeSize}`, opsA, @@ -244,11 +257,20 @@ function buildBalancedChildrenColdStartCase(opts: { balancedTree: true, knownScopeRoot: true, payloadBytes: opts.withPayloads ? opts.payloadBytes : 0, - payloadsEverywhere: opts.withPayloads, + payloadDistribution, pageSize: Math.min(DEFAULT_SYNC_BENCH_PAGE_SIZE, targetChildren.length), }, expectedFinalOpsA: opsA.length + transferredOps, - expectedFinalOpsB: opsB.length, + expectedFinalOpsB: expectedServerOpCount, + serverPrime: { + kind: 'balanced-fanout', + size: treeSize, + fanout: opts.fanout, + treeReplicaLabel: 's', + expectedServerOpCount, + expectedServerMaxLamport, + overlayOps: subtreePayloadOps, + }, firstView: { parent: targetParent, pageSize: Math.min(DEFAULT_SYNC_BENCH_PAGE_SIZE, targetChildren.length), @@ -287,6 +309,9 @@ function buildBalancedChildrenResyncCase(opts: { const targetChildren = targetChildrenForFirstChild(treeSize, opts.fanout); const scopedNodes = new Set([targetParent, ...targetChildren]); const opsB: Operation[] = [...sharedOps]; + const payloadDistribution: SyncBenchPayloadDistribution = opts.withPayloads + ? 'everywhere' + : 'none'; if (opts.withPayloads) { let counter = 0; @@ -334,7 +359,7 @@ function buildBalancedChildrenResyncCase(opts: { knownScopeRoot: true, nonEmptyLocalResult: true, payloadBytes: opts.withPayloads ? opts.payloadBytes : 0, - payloadsEverywhere: opts.withPayloads, + payloadDistribution, pageSize: Math.min(DEFAULT_SYNC_BENCH_PAGE_SIZE, targetChildren.length), }, expectedFinalOpsA: opsA.length, @@ -347,6 +372,7 @@ export function buildSyncBenchCase(opts: { size: number; fanout?: number; payloadBytes?: number; + materializeServerOps?: boolean; }): SyncBenchCase { const { workload } = opts; const size = opts.size; @@ -362,6 +388,7 @@ export function buildSyncBenchCase(opts: { }; const fanout = opts.fanout ?? DEFAULT_SYNC_BENCH_FANOUT; const payloadBytes = opts.payloadBytes ?? DEFAULT_SYNC_BENCH_PAYLOAD_BYTES; + const materializeServerOps = opts.materializeServerOps ?? true; if (workload === 'sync-one-missing') { const treeSize = size; @@ -404,6 +431,7 @@ export function buildSyncBenchCase(opts: { root, payloadBytes, withPayloads: false, + materializeServerOps, }); } @@ -415,6 +443,7 @@ export function buildSyncBenchCase(opts: { root, payloadBytes, withPayloads: true, + materializeServerOps, }); } diff --git a/packages/treecrdt-benchmark/src/testing.ts b/packages/treecrdt-benchmark/src/testing.ts new file mode 100644 index 00000000..227e9018 --- /dev/null +++ b/packages/treecrdt-benchmark/src/testing.ts @@ -0,0 +1,22 @@ +import type { Operation } from '@treecrdt/interface'; + +// Benchmark backends can expose these optional helpers to speed up fixture setup +// without putting bench-specific APIs on their main runtime entrypoints. +export type BenchmarkFixtureHelpers = { + resetForTests: () => Promise; + resetDocForTests: (docId: string) => Promise; + cloneDocForTests: (sourceDocId: string, targetDocId: string) => Promise; + cloneMaterializedDocForTests: (sourceDocId: string, targetDocId: string) => Promise; + primeDocForTests: (docId: string, ops: Operation[]) => Promise; + primeBalancedFanoutDocForTests: ( + docId: string, + size: number, + fanout: number, + payloadBytes: number, + replicaLabel: string, + ) => Promise; +}; + +export type BenchmarkFixtureFactory = { + open: (docId: string) => Promise; +} & Partial; diff --git a/packages/treecrdt-core/src/tree.rs b/packages/treecrdt-core/src/tree.rs index e3b3c4a8..ed639ede 100644 --- a/packages/treecrdt-core/src/tree.rs +++ b/packages/treecrdt-core/src/tree.rs @@ -156,16 +156,17 @@ where N: NodeStore, P: PayloadStore, { - pub fn with_stores( + pub fn with_stores_seeded( replica_id: ReplicaId, storage: S, clock: C, nodes: N, payloads: P, + counter: u64, + latest_lamport: Lamport, ) -> Result { - let counter = storage.latest_counter(&replica_id)?; let mut clock = clock; - clock.observe(storage.latest_lamport()); + clock.observe(latest_lamport); Ok(Self { replica_id, storage, @@ -179,6 +180,26 @@ where }) } + pub fn with_stores( + replica_id: ReplicaId, + storage: S, + clock: C, + nodes: N, + payloads: P, + ) -> Result { + let counter = storage.latest_counter(&replica_id)?; + let latest_lamport = storage.latest_lamport(); + Self::with_stores_seeded( + replica_id, + storage, + clock, + nodes, + payloads, + counter, + latest_lamport, + ) + } + fn is_in_order(&self, op: &Operation) -> bool { let Some(head) = self.head.as_ref() else { return true; diff --git a/packages/treecrdt-postgres-napi/native-rs/src/lib.rs b/packages/treecrdt-postgres-napi/native-rs/src/lib.rs index 6cce4955..e5e70656 100644 --- a/packages/treecrdt-postgres-napi/native-rs/src/lib.rs +++ b/packages/treecrdt-postgres-napi/native-rs/src/lib.rs @@ -244,13 +244,37 @@ impl PgFactory { Ok(()) } + #[napi] + pub fn open(&self, doc_id: String) -> napi::Result { + let client = connect(&self.url)?; + Ok(PgBackend { + client: std::cell::RefCell::new(Some(std::rc::Rc::new(std::cell::RefCell::new( + client, + )))), + doc_id, + }) + } +} + +#[napi] +pub struct PgTestingFactory { + url: String, +} + +#[napi] +impl PgTestingFactory { + #[napi(constructor)] + pub fn new(url: String) -> Self { + Self { url } + } + #[napi] pub fn reset_for_tests(&self) -> napi::Result<()> { let mut client = connect(&self.url)?; // Test-only convenience: wipe all docs. client .batch_execute( - "TRUNCATE treecrdt_oprefs_children, treecrdt_payload, treecrdt_nodes, treecrdt_ops, treecrdt_meta", + "TRUNCATE treecrdt_oprefs_children, treecrdt_payload, treecrdt_nodes, treecrdt_ops, treecrdt_meta, treecrdt_replica_meta", ) .map_err(map_err)?; Ok(()) @@ -264,26 +288,95 @@ impl PgFactory { } #[napi] - pub fn open(&self, doc_id: String) -> PgBackend { - PgBackend { - url: self.url.clone(), - doc_id, + pub fn clone_doc_for_tests( + &self, + source_doc_id: String, + target_doc_id: String, + ) -> napi::Result<()> { + let mut client = connect(&self.url)?; + treecrdt_postgres::clone_doc_for_tests(&mut client, &source_doc_id, &target_doc_id) + .map_err(map_core_err)?; + Ok(()) + } + + #[napi] + pub fn clone_materialized_doc_for_tests( + &self, + source_doc_id: String, + target_doc_id: String, + ) -> napi::Result<()> { + let mut client = connect(&self.url)?; + treecrdt_postgres::clone_materialized_doc_for_tests( + &mut client, + &source_doc_id, + &target_doc_id, + ) + .map_err(map_core_err)?; + Ok(()) + } + + #[napi] + pub fn prime_doc_for_tests(&self, doc_id: String, ops: Vec) -> napi::Result<()> { + let client = std::rc::Rc::new(std::cell::RefCell::new(connect(&self.url)?)); + let mut core_ops = Vec::with_capacity(ops.len()); + for op in ops { + core_ops.push(native_to_core_op(op).map_err(map_core_err)?); } + treecrdt_postgres::prime_doc_for_tests(&client, &doc_id, &core_ops) + .map_err(map_core_err)?; + Ok(()) + } + + #[napi] + pub fn prime_balanced_fanout_doc_for_tests( + &self, + doc_id: String, + size: u32, + fanout: u32, + payload_bytes: u32, + replica_label: String, + ) -> napi::Result<()> { + let client = std::rc::Rc::new(std::cell::RefCell::new(connect(&self.url)?)); + treecrdt_postgres::prime_balanced_fanout_doc_for_tests( + &client, + &doc_id, + size as usize, + fanout as usize, + payload_bytes as usize, + &replica_label, + ) + .map_err(map_core_err)?; + Ok(()) } } #[napi] pub struct PgBackend { - url: String, + client: std::cell::RefCell>>>, doc_id: String, } +impl PgBackend { + fn shared_client(&self) -> napi::Result>> { + self.client + .borrow() + .as_ref() + .cloned() + .ok_or_else(|| map_err("postgres backend is closed")) + } +} + #[napi] impl PgBackend { + #[napi] + pub fn close(&self) -> napi::Result<()> { + self.client.borrow_mut().take(); + Ok(()) + } + #[napi] pub fn max_lamport(&self) -> napi::Result { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let lamport = treecrdt_postgres::max_lamport(&client, &self.doc_id).map_err(map_core_err)?; Ok(BigInt::from(lamport as u64)) @@ -291,8 +384,7 @@ impl PgBackend { #[napi] pub fn list_op_refs_all(&self) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let refs = treecrdt_postgres::list_op_refs_all(&client, &self.doc_id).map_err(map_core_err)?; Ok(refs.into_iter().map(|r| Buffer::from(r.to_vec())).collect()) @@ -300,8 +392,7 @@ impl PgBackend { #[napi] pub fn list_op_refs_children(&self, parent: Buffer) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let parent = bytes16_to_node(&parent).map_err(map_core_err)?; let refs = treecrdt_postgres::list_op_refs_children(&client, &self.doc_id, parent) .map_err(map_core_err)?; @@ -313,8 +404,7 @@ impl PgBackend { &self, parent: Buffer, ) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let parent = bytes16_to_node(&parent).map_err(map_core_err)?; let refs = treecrdt_postgres::list_op_refs_children_with_parent_payload( &client, @@ -327,8 +417,7 @@ impl PgBackend { #[napi] pub fn ops_since(&self, lamport: BigInt, root: Option) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let lamport_u64 = bigint_to_u64("lamport", lamport).map_err(map_core_err)?; let root_id = match root { None => None, @@ -347,8 +436,7 @@ impl PgBackend { #[napi] pub fn tree_children(&self, parent: Buffer) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let parent = bytes16_to_node(&parent).map_err(map_core_err)?; let nodes = treecrdt_postgres::tree_children(&client, &self.doc_id, parent) .map_err(map_core_err)?; @@ -363,8 +451,7 @@ impl PgBackend { cursor_node: Option, limit: u32, ) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let parent = bytes16_to_node(&parent).map_err(map_core_err)?; let cursor = match (cursor_order_key, cursor_node) { @@ -387,8 +474,7 @@ impl PgBackend { #[napi] pub fn tree_dump(&self) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let rows = treecrdt_postgres::tree_dump(&client, &self.doc_id).map_err(map_core_err)?; Ok(rows .into_iter() @@ -403,8 +489,7 @@ impl PgBackend { #[napi] pub fn tree_node_count(&self) -> napi::Result { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let cnt = treecrdt_postgres::tree_node_count(&client, &self.doc_id).map_err(map_core_err)?; Ok(BigInt::from(cnt)) @@ -412,8 +497,7 @@ impl PgBackend { #[napi] pub fn tree_parent(&self, node: Buffer) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let node = bytes16_to_node(&node).map_err(map_core_err)?; let parent = treecrdt_postgres::tree_parent(&client, &self.doc_id, node).map_err(map_core_err)?; @@ -422,16 +506,14 @@ impl PgBackend { #[napi] pub fn tree_exists(&self, node: Buffer) -> napi::Result { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let node = bytes16_to_node(&node).map_err(map_core_err)?; treecrdt_postgres::tree_exists(&client, &self.doc_id, node).map_err(map_core_err) } #[napi] pub fn tree_payload(&self, node: Buffer) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let node = bytes16_to_node(&node).map_err(map_core_err)?; let payload = treecrdt_postgres::tree_payload(&client, &self.doc_id, node).map_err(map_core_err)?; @@ -440,8 +522,7 @@ impl PgBackend { #[napi] pub fn replica_max_counter(&self, replica: Buffer) -> napi::Result { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let cnt = treecrdt_postgres::replica_max_counter(&client, &self.doc_id, &replica) .map_err(map_core_err)?; Ok(BigInt::from(cnt)) @@ -449,8 +530,7 @@ impl PgBackend { #[napi] pub fn get_ops_by_op_refs(&self, op_refs: Vec) -> napi::Result> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let refs: Vec<[u8; 16]> = op_refs .into_iter() @@ -475,8 +555,7 @@ impl PgBackend { #[napi] pub fn apply_ops(&self, ops: Vec) -> napi::Result<()> { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let mut core_ops = Vec::with_capacity(ops.len()); for op in ops { @@ -497,8 +576,7 @@ impl PgBackend { after: Option, payload: Option, ) -> napi::Result { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let replica = ReplicaId(replica.to_vec()); let parent = bytes16_to_node(&parent).map_err(map_core_err)?; @@ -531,8 +609,7 @@ impl PgBackend { placement: String, after: Option, ) -> napi::Result { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let replica = ReplicaId(replica.to_vec()); let node = bytes16_to_node(&node).map_err(map_core_err)?; @@ -557,8 +634,7 @@ impl PgBackend { #[napi] pub fn local_delete(&self, replica: Buffer, node: Buffer) -> napi::Result { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let replica = ReplicaId(replica.to_vec()); let node = bytes16_to_node(&node).map_err(map_core_err)?; @@ -574,8 +650,7 @@ impl PgBackend { node: Buffer, payload: Option, ) -> napi::Result { - let client = connect(&self.url)?; - let client = std::rc::Rc::new(std::cell::RefCell::new(client)); + let client = self.shared_client()?; let replica = ReplicaId(replica.to_vec()); let node = bytes16_to_node(&node).map_err(map_core_err)?; diff --git a/packages/treecrdt-postgres-napi/package.json b/packages/treecrdt-postgres-napi/package.json index 5adeabf5..00430a09 100644 --- a/packages/treecrdt-postgres-napi/package.json +++ b/packages/treecrdt-postgres-napi/package.json @@ -9,6 +9,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" } }, "files": [ @@ -23,14 +27,15 @@ "build:native": "cargo build -p treecrdt-postgres-napi --release && node ./scripts/copy-native.mjs", "build": "pnpm run build:native && tsc -p tsconfig.json", "test": "pnpm -C ../treecrdt-benchmark run build && pnpm -C ../treecrdt-engine-conformance run build && pnpm run build && vitest run", - "benchmark": "pnpm -C ../treecrdt-benchmark run build && pnpm run build && tsx ./scripts/bench.ts" + "benchmark": "pnpm -C ../treecrdt-benchmark run build && pnpm run build && tsx ./scripts/bench.ts", + "benchmark:hot-write": "pnpm -C ../treecrdt-benchmark run build && pnpm run build && tsx ./scripts/bench-hot-writes.ts" }, "dependencies": { + "@treecrdt/benchmark": "workspace:*", "@treecrdt/interface": "workspace:*", "@treecrdt/sync": "workspace:*" }, "devDependencies": { - "@treecrdt/benchmark": "workspace:*", "@treecrdt/engine-conformance": "workspace:*", "@types/node": "^20.19.25", "tsx": "^4.19.2", diff --git a/packages/treecrdt-postgres-napi/scripts/bench-hot-writes.ts b/packages/treecrdt-postgres-napi/scripts/bench-hot-writes.ts new file mode 100644 index 00000000..2a410d5c --- /dev/null +++ b/packages/treecrdt-postgres-napi/scripts/bench-hot-writes.ts @@ -0,0 +1,171 @@ +import { randomUUID } from 'node:crypto'; + +import { + DEFAULT_HOT_WRITE_CONFIG, + DEFAULT_HOT_WRITE_FANOUT, + DEFAULT_HOT_WRITE_PAYLOAD_BYTES, + parseHotWriteConfigFromArgv, + parseHotWriteKinds, + parseNonNegativeIntFlag, + parsePositiveIntFlag, + runHotWriteBenchmarks, + type HotWriteSeed, + type HotWriteSeedTargets, +} from '../../treecrdt-benchmark/dist/hot-write.js'; +import { repoRootFromImportMeta } from '@treecrdt/benchmark/node'; + +import { createPostgresNapiTestAdapterFactory } from '../src/testing.js'; +import { createTreecrdtPostgresClient } from '../src/client.js'; + +const POSTGRES_URL = process.env.TREECRDT_POSTGRES_URL; +const HOT_WRITE_FIXTURE_CACHE_VERSION = '2026-03-30-v1'; +const HOT_WRITE_SKIP_SAMPLE_CLEANUP = process.env.HOT_WRITE_SKIP_SAMPLE_CLEANUP === '1'; + +async function main() { + if (!POSTGRES_URL) { + console.warn('Skipping postgres hot-write benchmark because TREECRDT_POSTGRES_URL is not set'); + return; + } + + const repoRoot = repoRootFromImportMeta(import.meta.url, 3); + const argv = process.argv.slice(2); + const config = parseHotWriteConfigFromArgv(argv) ?? [...DEFAULT_HOT_WRITE_CONFIG]; + const benches = parseHotWriteKinds(argv); + const fanout = parsePositiveIntFlag( + argv, + '--fanout', + 'HOT_WRITE_BENCH_FANOUT', + DEFAULT_HOT_WRITE_FANOUT, + ); + const payloadBytes = parsePositiveIntFlag( + argv, + '--payload-bytes', + 'HOT_WRITE_BENCH_PAYLOAD_BYTES', + DEFAULT_HOT_WRITE_PAYLOAD_BYTES, + ); + const writesPerSample = parsePositiveIntFlag( + argv, + '--writes-per-sample', + 'HOT_WRITE_WRITES_PER_SAMPLE', + 1, + ); + const warmupWrites = parseNonNegativeIntFlag( + argv, + '--warmup-writes', + 'HOT_WRITE_WARMUP_WRITES', + 0, + ); + + const factory = createPostgresNapiTestAdapterFactory(POSTGRES_URL); + await factory.ensureSchema(); + const seededDocs = new Map(); + + const outputs = await runHotWriteBenchmarks({ + repoRoot, + implementation: 'postgres-napi', + storage: 'postgres', + config, + benches, + fanout, + payloadBytes, + writesPerSample, + warmupWrites, + openSeededEngine: async ({ bench, size, seed, getSeed, sampleIndex }) => + openSeededClient({ + url: POSTGRES_URL, + factory, + bench, + size, + seed, + getSeed, + sampleIndex, + ensureSeededDocId: async () => + ensureSeededDocId({ + url: POSTGRES_URL, + factory, + size, + seed, + getSeed, + fanout, + payloadBytes, + seededDocs, + }), + }), + }); + + for (const output of outputs) console.log(JSON.stringify(output, null, 2)); +} + +async function openSeededClient(opts: { + url: string; + factory: ReturnType; + bench: string; + size: number; + seed: HotWriteSeedTargets; + getSeed: () => HotWriteSeed; + sampleIndex: number; + ensureSeededDocId: () => Promise; +}) { + const seededDocId = await opts.ensureSeededDocId(); + const docId = `hot-write-${opts.bench}-${opts.size}-${opts.sampleIndex}-${randomUUID()}`; + await opts.factory.cloneMaterializedDocForTests(seededDocId, docId); + const client = await createTreecrdtPostgresClient(opts.url, { docId }); + return { + ...client, + close: async () => { + await client.close(); + if (!HOT_WRITE_SKIP_SAMPLE_CLEANUP) { + await opts.factory.resetDocForTests(docId); + } + }, + }; +} + +async function ensureSeededDocId(opts: { + url: string; + factory: ReturnType; + size: number; + seed: HotWriteSeedTargets; + getSeed: () => HotWriteSeed; + fanout: number; + payloadBytes: number; + seededDocs: Map; +}): Promise { + const key = `${opts.size}:${opts.fanout}:${opts.payloadBytes}`; + const cached = opts.seededDocs.get(key); + if (cached) return cached; + const expectedHeadLamport = opts.size + 1; + + const docId = [ + 'hot-write-seed', + HOT_WRITE_FIXTURE_CACHE_VERSION, + `fanout${opts.fanout}`, + `payload${opts.payloadBytes}`, + String(opts.size), + ].join('-'); + const client = await createTreecrdtPostgresClient(opts.url, { docId }); + try { + const [headLamport, nodeCount] = await Promise.all([ + client.meta.headLamport(), + client.tree.nodeCount(), + ]); + if (headLamport !== expectedHeadLamport || nodeCount !== opts.size) { + await opts.factory.primeBalancedFanoutDocForTests( + docId, + opts.size, + opts.fanout, + opts.payloadBytes, + 'bench', + ); + } + } finally { + await client.close(); + } + opts.seededDocs.set(key, docId); + return docId; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/treecrdt-postgres-napi/scripts/bench.ts b/packages/treecrdt-postgres-napi/scripts/bench.ts index a411fc86..e019c6d1 100644 --- a/packages/treecrdt-postgres-napi/scripts/bench.ts +++ b/packages/treecrdt-postgres-napi/scripts/bench.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto'; import { benchTiming, buildWorkloads, runWorkloads } from '@treecrdt/benchmark'; import { parseBenchCliArgs, repoRootFromImportMeta, writeResult } from '@treecrdt/benchmark/node'; -import { createPostgresNapiAdapterFactory } from '../src/index.js'; +import { createPostgresNapiTestAdapterFactory } from '../src/testing.js'; const POSTGRES_URL = process.env.TREECRDT_POSTGRES_URL; @@ -27,7 +27,7 @@ async function main() { w.warmupIterations = timing.warmupIterations; } - const factory = createPostgresNapiAdapterFactory(POSTGRES_URL); + const factory = createPostgresNapiTestAdapterFactory(POSTGRES_URL); await factory.ensureSchema(); await factory.resetForTests(); const benchDocId = `bench-${randomUUID()}`; diff --git a/packages/treecrdt-postgres-napi/src/adapter.ts b/packages/treecrdt-postgres-napi/src/adapter.ts index 767f9db5..d966f904 100644 --- a/packages/treecrdt-postgres-napi/src/adapter.ts +++ b/packages/treecrdt-postgres-napi/src/adapter.ts @@ -4,15 +4,13 @@ import type { SerializeReplica, TreecrdtAdapter, } from '@treecrdt/interface'; -import { nodeIdToBytes16 } from '@treecrdt/interface/ids'; +import { nodeIdToBytes16, replicaIdToBytes } from '@treecrdt/interface/ids'; import { nativeOpToSqliteRow, operationToNativeWithSerializers } from './codec.js'; import { loadNative } from './native.js'; export type PostgresNapiAdapterFactory = { ensureSchema: () => Promise; - resetForTests: () => Promise; - resetDocForTests: (docId: string) => Promise; open: (docId: string) => Promise; }; @@ -44,11 +42,6 @@ export function createPostgresNapiAdapterFactory(url: string): PostgresNapiAdapt return { ensureSchema: async () => factory.ensureSchema(), - resetForTests: async () => factory.resetForTests(), - resetDocForTests: async (docId: string) => { - ensureNonEmptyString('docId', docId); - factory.resetDocForTests(docId); - }, open: async (initialDocId: string) => { ensureNonEmptyString('docId', initialDocId); let docId = initialDocId; @@ -106,7 +99,7 @@ export function createPostgresNapiAdapterFactory(url: string): PostgresNapiAdapt return rows.map(nativeOpToSqliteRow); }, close: async () => { - // no-op: native layer opens per-call connections + backend.close(); }, }; diff --git a/packages/treecrdt-postgres-napi/src/client.ts b/packages/treecrdt-postgres-napi/src/client.ts index b71b62d2..61bf7aba 100644 --- a/packages/treecrdt-postgres-napi/src/client.ts +++ b/packages/treecrdt-postgres-napi/src/client.ts @@ -196,7 +196,7 @@ export async function createTreecrdtPostgresClient( payload: localPayloadImpl, }, close: async () => { - // no-op: native layer opens per-call connections + backend.close(); }, }; } diff --git a/packages/treecrdt-postgres-napi/src/index.ts b/packages/treecrdt-postgres-napi/src/index.ts index 3ecad178..8250c6a8 100644 --- a/packages/treecrdt-postgres-napi/src/index.ts +++ b/packages/treecrdt-postgres-napi/src/index.ts @@ -9,8 +9,6 @@ export { createTreecrdtPostgresClient } from './client.js'; export type PostgresNapiSyncBackendFactory = { ensureSchema: () => Promise; - resetForTests: () => Promise; - resetDocForTests: (docId: string) => Promise; open: (docId: string) => Promise>; }; @@ -27,11 +25,6 @@ export function createPostgresNapiSyncBackendFactory(url: string): PostgresNapiS return { ensureSchema: async () => factory.ensureSchema(), - resetForTests: async () => factory.resetForTests(), - resetDocForTests: async (docId: string) => { - ensureNonEmptyString('docId', docId); - factory.resetDocForTests(docId); - }, open: async (docId: string) => { ensureNonEmptyString('docId', docId); const nativeBackend = factory.open(docId); diff --git a/packages/treecrdt-postgres-napi/src/native.ts b/packages/treecrdt-postgres-napi/src/native.ts index fd2a9c14..eb64eb97 100644 --- a/packages/treecrdt-postgres-napi/src/native.ts +++ b/packages/treecrdt-postgres-napi/src/native.ts @@ -19,6 +19,7 @@ export type NativeOp = { }; export type NativeBackend = { + close(): void; maxLamport(): bigint; listOpRefsAll(): Uint8Array[]; listOpRefsChildren(parent: Uint8Array): Uint8Array[]; @@ -65,8 +66,6 @@ export type NativeBackend = { export type NativeFactory = { ensureSchema(): void; - resetForTests(): void; - resetDocForTests(docId: string): void; open(docId: string): NativeBackend; }; diff --git a/packages/treecrdt-postgres-napi/src/testing.ts b/packages/treecrdt-postgres-napi/src/testing.ts new file mode 100644 index 00000000..243f094d --- /dev/null +++ b/packages/treecrdt-postgres-napi/src/testing.ts @@ -0,0 +1,158 @@ +import type { BenchmarkFixtureHelpers } from '@treecrdt/benchmark/testing'; +import type { Operation } from '@treecrdt/interface'; +import { nodeIdToBytes16, replicaIdToBytes } from '@treecrdt/interface/ids'; + +import { createPostgresNapiAdapterFactory, type PostgresNapiAdapterFactory } from './adapter.js'; +import { createTreecrdtPostgresClient } from './client.js'; +import { operationToNativeWithSerializers } from './codec.js'; +import { + createPostgresNapiSyncBackendFactory, + type PostgresNapiSyncBackendFactory, +} from './index.js'; +import { loadNative, type NativeOp } from './native.js'; + +export { createTreecrdtPostgresClient } from './client.js'; + +type PostgresNapiFixtureHelpers = Pick< + BenchmarkFixtureHelpers, + | 'resetForTests' + | 'resetDocForTests' + | 'cloneDocForTests' + | 'cloneMaterializedDocForTests' + | 'primeDocForTests' + | 'primeBalancedFanoutDocForTests' +>; + +type PostgresNapiSyncFixtureHelpers = Pick< + BenchmarkFixtureHelpers, + 'resetForTests' | 'resetDocForTests' | 'cloneDocForTests' | 'primeBalancedFanoutDocForTests' +>; + +export type PostgresNapiTestAdapterFactory = PostgresNapiAdapterFactory & + PostgresNapiFixtureHelpers; + +export type PostgresNapiTestSyncBackendFactory = PostgresNapiSyncBackendFactory & + PostgresNapiSyncFixtureHelpers; + +type NativeTestingFactory = { + resetForTests(): void; + resetDocForTests(docId: string): void; + cloneDocForTests(sourceDocId: string, targetDocId: string): void; + cloneMaterializedDocForTests(sourceDocId: string, targetDocId: string): void; + primeDocForTests(docId: string, ops: NativeOp[]): void; + primeBalancedFanoutDocForTests( + docId: string, + size: number, + fanout: number, + payloadBytes: number, + replicaLabel: string, + ): void; +}; + +function ensureNonEmptyString(name: string, value: string): void { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} must be a non-empty string`); + } +} + +function ensurePositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, got ${String(value)}`); + } +} + +function ensureNonNegativeInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer, got ${String(value)}`); + } +} + +function opToNative(op: Operation) { + return operationToNativeWithSerializers(op, nodeIdToBytes16, replicaIdToBytes); +} + +function createTestingFactory(url: string): NativeTestingFactory { + const native = loadNative() as unknown as { + PgTestingFactory: new (url: string) => NativeTestingFactory; + }; + return new native.PgTestingFactory(url); +} + +export function createPostgresNapiTestAdapterFactory(url: string): PostgresNapiTestAdapterFactory { + ensureNonEmptyString('url', url); + const base = createPostgresNapiAdapterFactory(url); + const factory = createTestingFactory(url); + + return { + ...base, + resetForTests: async () => factory.resetForTests(), + resetDocForTests: async (docId: string) => { + ensureNonEmptyString('docId', docId); + factory.resetDocForTests(docId); + }, + cloneDocForTests: async (sourceDocId: string, targetDocId: string) => { + ensureNonEmptyString('sourceDocId', sourceDocId); + ensureNonEmptyString('targetDocId', targetDocId); + factory.cloneDocForTests(sourceDocId, targetDocId); + }, + cloneMaterializedDocForTests: async (sourceDocId: string, targetDocId: string) => { + ensureNonEmptyString('sourceDocId', sourceDocId); + ensureNonEmptyString('targetDocId', targetDocId); + factory.cloneMaterializedDocForTests(sourceDocId, targetDocId); + }, + primeDocForTests: async (docId: string, ops: Operation[]) => { + ensureNonEmptyString('docId', docId); + factory.primeDocForTests(docId, ops.map(opToNative)); + }, + primeBalancedFanoutDocForTests: async ( + docId: string, + size: number, + fanout: number, + payloadBytes: number, + replicaLabel: string, + ) => { + ensureNonEmptyString('docId', docId); + ensureNonEmptyString('replicaLabel', replicaLabel); + ensurePositiveInteger('size', size); + ensurePositiveInteger('fanout', fanout); + ensureNonNegativeInteger('payloadBytes', payloadBytes); + factory.primeBalancedFanoutDocForTests(docId, size, fanout, payloadBytes, replicaLabel); + }, + }; +} + +export function createPostgresNapiTestSyncBackendFactory( + url: string, +): PostgresNapiTestSyncBackendFactory { + ensureNonEmptyString('url', url); + const base = createPostgresNapiSyncBackendFactory(url); + const factory = createTestingFactory(url); + + return { + ...base, + resetForTests: async () => factory.resetForTests(), + resetDocForTests: async (docId: string) => { + ensureNonEmptyString('docId', docId); + factory.resetDocForTests(docId); + }, + cloneDocForTests: async (sourceDocId: string, targetDocId: string) => { + ensureNonEmptyString('sourceDocId', sourceDocId); + ensureNonEmptyString('targetDocId', targetDocId); + factory.cloneDocForTests(sourceDocId, targetDocId); + }, + primeBalancedFanoutDocForTests: async ( + docId: string, + size: number, + fanout: number, + payloadBytes: number, + replicaLabel: string, + ) => { + ensureNonEmptyString('docId', docId); + ensureNonEmptyString('replicaLabel', replicaLabel); + ensurePositiveInteger('size', size); + ensurePositiveInteger('fanout', fanout); + ensureNonNegativeInteger('payloadBytes', payloadBytes); + factory.primeBalancedFanoutDocForTests(docId, size, fanout, payloadBytes, replicaLabel); + }, + }; +} diff --git a/packages/treecrdt-postgres-rs/src/lib.rs b/packages/treecrdt-postgres-rs/src/lib.rs index aed5d11f..3a61d6c8 100644 --- a/packages/treecrdt-postgres-rs/src/lib.rs +++ b/packages/treecrdt-postgres-rs/src/lib.rs @@ -18,5 +18,9 @@ pub use reads::{ tree_children, tree_children_page, tree_dump, tree_exists, tree_node_count, tree_parent, tree_payload, TreeChildRow, TreeRow, }; -pub use schema::{ensure_schema, reset_doc_for_tests}; -pub use store::{append_ops, ensure_materialized}; +pub use schema::{ + clone_doc_for_tests, clone_materialized_doc_for_tests, ensure_schema, reset_doc_for_tests, +}; +pub use store::{ + append_ops, ensure_materialized, prime_balanced_fanout_doc_for_tests, prime_doc_for_tests, +}; diff --git a/packages/treecrdt-postgres-rs/src/local_ops.rs b/packages/treecrdt-postgres-rs/src/local_ops.rs index d18626d4..53a4a331 100644 --- a/packages/treecrdt-postgres-rs/src/local_ops.rs +++ b/packages/treecrdt-postgres-rs/src/local_ops.rs @@ -1,5 +1,7 @@ use std::cell::RefCell; use std::rc::Rc; +use std::sync::OnceLock; +use std::time::Instant; use postgres::Client; @@ -9,11 +11,58 @@ use treecrdt_core::{ }; use crate::store::{ - ensure_materialized_in_tx, load_tree_meta_for_update, set_tree_meta_dirty, - update_tree_meta_head, PgCtx, PgNodeStore, PgOpStorage, PgParentOpIndex, PgPayloadStore, - TreeMeta, + ensure_materialized_and_load_meta_for_update_in_tx, replica_max_counter_in_tx, + set_tree_meta_dirty, update_tree_meta_head, PgCtx, PgNodeStore, PgOpStorage, PgParentOpIndex, + PgPayloadStore, TreeMeta, }; +fn local_profile_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + matches!( + std::env::var("TREECRDT_PG_PROFILE_LOCAL").ok().as_deref(), + Some("1") | Some("true") | Some("TRUE") | Some("yes") | Some("YES") + ) + }) +} + +#[derive(Clone, Debug, Default)] +struct PgLocalOpProfile { + kind: &'static str, + ensure_materialized_ms: f64, + load_meta_ms: f64, + replica_counter_ms: f64, + ctx_init_ms: f64, + crdt_init_ms: f64, + plan_ms: f64, + flush_last_change_ms: f64, + index_flush_ms: f64, + update_head_ms: f64, + fallback_mark_dirty: bool, + total_ms: f64, +} + +impl PgLocalOpProfile { + fn log(&self, doc_id: &str) { + eprintln!( + "treecrdt_pg_local_profile kind={} doc_id={} ensure_materialized_ms={:.3} load_meta_ms={:.3} replica_counter_ms={:.3} ctx_init_ms={:.3} crdt_init_ms={:.3} plan_ms={:.3} flush_last_change_ms={:.3} index_flush_ms={:.3} update_head_ms={:.3} fallback_mark_dirty={} total_ms={:.3}", + self.kind, + doc_id, + self.ensure_materialized_ms, + self.load_meta_ms, + self.replica_counter_ms, + self.ctx_init_ms, + self.crdt_init_ms, + self.plan_ms, + self.flush_last_change_ms, + self.index_flush_ms, + self.update_head_ms, + self.fallback_mark_dirty, + self.total_ms, + ); + } +} + type LocalCrdt = TreeCrdt; struct LocalOpSession { @@ -21,6 +70,7 @@ struct LocalOpSession { meta: TreeMeta, nodes: PgNodeStore, crdt: LocalCrdt, + profile: Option, } fn run_in_tx(client: &Rc>, f: impl FnOnce() -> Result) -> Result { @@ -49,29 +99,59 @@ fn begin_local_core_op( client: &Rc>, doc_id: &str, replica: &ReplicaId, + kind: &'static str, ) -> Result { // Local ops take the opposite route from append_ops_in_tx: start from a clean materialized // snapshot, then let TreeCrdt mint/store/apply the local op directly against Postgres stores. - ensure_materialized_in_tx(client, doc_id)?; - let meta = load_tree_meta_for_update(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + // `kind` is only a human-readable label for profiling/debug output ("insert", "move", etc.), + // so later timing logs can tell which local-op path they came from. + let mut profile = local_profile_enabled().then(|| PgLocalOpProfile { + kind, + ..PgLocalOpProfile::default() + }); + + let ensure_started_at = Instant::now(); + let meta = ensure_materialized_and_load_meta_for_update_in_tx(client, doc_id)?; + if let Some(profile) = &mut profile { + profile.ensure_materialized_ms = ensure_started_at.elapsed().as_secs_f64() * 1000.0; + profile.load_meta_ms = 0.0; + } + + let replica_started_at = Instant::now(); + let replica_counter = replica_max_counter_in_tx(client, doc_id, replica.as_bytes())?; + if let Some(profile) = &mut profile { + profile.replica_counter_ms = replica_started_at.elapsed().as_secs_f64() * 1000.0; + } + + let ctx_started_at = Instant::now(); + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; + if let Some(profile) = &mut profile { + profile.ctx_init_ms = ctx_started_at.elapsed().as_secs_f64() * 1000.0; + } let storage = PgOpStorage::new(ctx.clone()); let nodes = PgNodeStore::new(ctx.clone()); let payloads = PgPayloadStore::new(ctx.clone()); - let crdt = TreeCrdt::with_stores( + let crdt_started_at = Instant::now(); + let crdt = TreeCrdt::with_stores_seeded( replica.clone(), storage, LamportClock::default(), nodes.clone(), payloads, + replica_counter, + meta.head_lamport(), )?; + if let Some(profile) = &mut profile { + profile.crdt_init_ms = crdt_started_at.elapsed().as_secs_f64() * 1000.0; + } Ok(LocalOpSession { ctx, meta, nodes, crdt, + profile, }) } @@ -88,15 +168,29 @@ fn finish_local_core_op(session: &mut LocalOpSession, op: &Operation, plan: Loca { Ok(v) => { seq = v; - if session.nodes.flush_last_change().is_err() || op_index.flush().is_err() { + let flush_last_change_started_at = Instant::now(); + let flush_last_change_ok = session.nodes.flush_last_change().is_ok(); + if let Some(profile) = &mut session.profile { + profile.flush_last_change_ms = + flush_last_change_started_at.elapsed().as_secs_f64() * 1000.0; + } + + let index_flush_started_at = Instant::now(); + let index_flush_ok = op_index.flush().is_ok(); + if let Some(profile) = &mut session.profile { + profile.index_flush_ms = index_flush_started_at.elapsed().as_secs_f64() * 1000.0; + } + + if !flush_last_change_ok || !index_flush_ok { post_materialization_ok = false; } } Err(_) => post_materialization_ok = false, } - if post_materialization_ok - && update_tree_meta_head( + if post_materialization_ok { + let update_head_started_at = Instant::now(); + if update_tree_meta_head( &session.ctx.client, &session.ctx.doc_id, op.meta.lamport, @@ -105,12 +199,19 @@ fn finish_local_core_op(session: &mut LocalOpSession, op: &Operation, plan: Loca seq, ) .is_err() - { - post_materialization_ok = false; + { + post_materialization_ok = false; + } + if let Some(profile) = &mut session.profile { + profile.update_head_ms = update_head_started_at.elapsed().as_secs_f64() * 1000.0; + } } if !post_materialization_ok { let _ = set_tree_meta_dirty(&session.ctx.client, &session.ctx.doc_id, true); + if let Some(profile) = &mut session.profile { + profile.fallback_mark_dirty = true; + } } } @@ -126,10 +227,19 @@ pub fn local_insert( payload: Option>, ) -> Result { run_in_tx(client, || { - let mut session = begin_local_core_op(client, doc_id, replica)?; + let total_started_at = Instant::now(); + let mut session = begin_local_core_op(client, doc_id, replica, "insert")?; let placement = LocalPlacement::from_parts(placement, after)?; + let plan_started_at = Instant::now(); let (op, plan) = session.crdt.local_insert_with_plan(parent, node, placement, payload)?; + if let Some(profile) = &mut session.profile { + profile.plan_ms = plan_started_at.elapsed().as_secs_f64() * 1000.0; + } finish_local_core_op(&mut session, &op, plan); + if let Some(profile) = &mut session.profile { + profile.total_ms = total_started_at.elapsed().as_secs_f64() * 1000.0; + profile.log(doc_id); + } Ok(op) }) } @@ -144,10 +254,19 @@ pub fn local_move( after: Option, ) -> Result { run_in_tx(client, || { - let mut session = begin_local_core_op(client, doc_id, replica)?; + let total_started_at = Instant::now(); + let mut session = begin_local_core_op(client, doc_id, replica, "move")?; let placement = LocalPlacement::from_parts(placement, after)?; + let plan_started_at = Instant::now(); let (op, plan) = session.crdt.local_move_with_plan(node, new_parent, placement)?; + if let Some(profile) = &mut session.profile { + profile.plan_ms = plan_started_at.elapsed().as_secs_f64() * 1000.0; + } finish_local_core_op(&mut session, &op, plan); + if let Some(profile) = &mut session.profile { + profile.total_ms = total_started_at.elapsed().as_secs_f64() * 1000.0; + profile.log(doc_id); + } Ok(op) }) } @@ -159,9 +278,18 @@ pub fn local_delete( node: NodeId, ) -> Result { run_in_tx(client, || { - let mut session = begin_local_core_op(client, doc_id, replica)?; + let total_started_at = Instant::now(); + let mut session = begin_local_core_op(client, doc_id, replica, "delete")?; + let plan_started_at = Instant::now(); let (op, plan) = session.crdt.local_delete_with_plan(node)?; + if let Some(profile) = &mut session.profile { + profile.plan_ms = plan_started_at.elapsed().as_secs_f64() * 1000.0; + } finish_local_core_op(&mut session, &op, plan); + if let Some(profile) = &mut session.profile { + profile.total_ms = total_started_at.elapsed().as_secs_f64() * 1000.0; + profile.log(doc_id); + } Ok(op) }) } @@ -174,9 +302,18 @@ pub fn local_payload( payload: Option>, ) -> Result { run_in_tx(client, || { - let mut session = begin_local_core_op(client, doc_id, replica)?; + let total_started_at = Instant::now(); + let mut session = begin_local_core_op(client, doc_id, replica, "payload")?; + let plan_started_at = Instant::now(); let (op, plan) = session.crdt.local_payload_with_plan(node, payload)?; + if let Some(profile) = &mut session.profile { + profile.plan_ms = plan_started_at.elapsed().as_secs_f64() * 1000.0; + } finish_local_core_op(&mut session, &op, plan); + if let Some(profile) = &mut session.profile { + profile.total_ms = total_started_at.elapsed().as_secs_f64() * 1000.0; + profile.log(doc_id); + } Ok(op) }) } diff --git a/packages/treecrdt-postgres-rs/src/reads.rs b/packages/treecrdt-postgres-rs/src/reads.rs index 81e5e30c..306cdb4f 100644 --- a/packages/treecrdt-postgres-rs/src/reads.rs +++ b/packages/treecrdt-postgres-rs/src/reads.rs @@ -8,12 +8,12 @@ use treecrdt_core::{Error, Lamport, NodeId, Operation, Result}; use crate::opref::{derive_op_ref_v0, OPREF_V0_WIDTH}; use crate::store::{ bytes_to_node, ensure_doc_meta, ensure_materialized, node_to_bytes, op_ref_from_bytes, - row_to_op, row_to_op_at, storage_debug, PgCtx, + replica_max_counter_in_tx, row_to_op, row_to_op_at, storage_debug, PgCtx, }; pub fn max_lamport(client: &Rc>, doc_id: &str) -> Result { ensure_doc_meta(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let mut c = client.borrow_mut(); let stmt = ctx.stmt( &mut c, @@ -29,7 +29,7 @@ pub fn list_op_refs_all( doc_id: &str, ) -> Result> { ensure_doc_meta(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let mut c = client.borrow_mut(); let stmt = ctx.stmt( &mut c, @@ -50,7 +50,7 @@ pub fn list_op_refs_children( parent: NodeId, ) -> Result> { ensure_materialized(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let parent_bytes = node_to_bytes(parent); let mut c = client.borrow_mut(); let stmt = ctx.stmt( @@ -72,7 +72,7 @@ pub fn list_op_refs_children_with_parent_payload( parent: NodeId, ) -> Result> { ensure_materialized(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let parent_bytes = node_to_bytes(parent); let mut c = client.borrow_mut(); @@ -144,7 +144,7 @@ pub fn get_ops_by_op_refs( return Ok(Vec::new()); } - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let refs: Vec> = op_refs.iter().map(|r| r.to_vec()).collect(); let mut c = client.borrow_mut(); @@ -178,7 +178,7 @@ pub fn ops_since( root: Option, ) -> Result> { ensure_doc_meta(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let root_bytes: Option> = root.map(|n| node_to_bytes(n).to_vec()); let mut c = client.borrow_mut(); let stmt = ctx.stmt( @@ -218,7 +218,7 @@ pub fn tree_children( if parent == NodeId::TRASH { return Ok(Vec::new()); } - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let parent_bytes = node_to_bytes(parent); let mut c = client.borrow_mut(); let stmt = ctx.stmt( @@ -247,7 +247,7 @@ pub fn tree_children_page( if parent == NodeId::TRASH { return Ok(Vec::new()); } - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let parent_bytes = node_to_bytes(parent); let after_order_key: Option> = cursor.as_ref().map(|(k, _n)| k.clone()); let after_node: Option> = cursor.as_ref().map(|(_k, n)| n.clone()); @@ -289,7 +289,7 @@ pub fn tree_children_page( pub fn tree_dump(client: &Rc>, doc_id: &str) -> Result> { ensure_materialized(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let mut c = client.borrow_mut(); let stmt = ctx.stmt( &mut c, @@ -325,7 +325,7 @@ pub fn tree_payload( node: NodeId, ) -> Result>> { ensure_materialized(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let node_bytes = node_to_bytes(node); let mut c = client.borrow_mut(); let stmt = ctx.stmt( @@ -342,7 +342,7 @@ pub fn tree_payload( pub fn tree_node_count(client: &Rc>, doc_id: &str) -> Result { ensure_materialized(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let root_bytes = node_to_bytes(NodeId::ROOT); let mut c = client.borrow_mut(); let stmt = ctx.stmt( @@ -361,7 +361,7 @@ pub fn tree_parent( node: NodeId, ) -> Result> { ensure_materialized(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let node_bytes = node_to_bytes(node); let mut c = client.borrow_mut(); let stmt = ctx.stmt( @@ -379,7 +379,7 @@ pub fn tree_parent( pub fn tree_exists(client: &Rc>, doc_id: &str, node: NodeId) -> Result { ensure_materialized(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let node_bytes = node_to_bytes(node); let mut c = client.borrow_mut(); let stmt = ctx.stmt( @@ -396,13 +396,5 @@ pub fn replica_max_counter( replica: &[u8], ) -> Result { ensure_doc_meta(client, doc_id)?; - let ctx = PgCtx::new(client.clone(), doc_id)?; - let mut c = client.borrow_mut(); - let stmt = ctx.stmt( - &mut c, - "SELECT COALESCE(MAX(counter), 0) FROM treecrdt_ops WHERE doc_id = $1 AND replica = $2", - )?; - let rows = c.query(&stmt, &[&doc_id, &replica]).map_err(storage_debug)?; - let row = rows.first().ok_or_else(|| Error::Storage("missing MAX(counter) row".into()))?; - Ok(row.get::<_, i64>(0).max(0) as u64) + replica_max_counter_in_tx(client, doc_id, replica) } diff --git a/packages/treecrdt-postgres-rs/src/schema.rs b/packages/treecrdt-postgres-rs/src/schema.rs index ea3cb00d..338f8afd 100644 --- a/packages/treecrdt-postgres-rs/src/schema.rs +++ b/packages/treecrdt-postgres-rs/src/schema.rs @@ -1,4 +1,4 @@ -use postgres::Client; +use postgres::{Client, GenericClient}; use treecrdt_core::{Error, Result}; const SCHEMA_LOCK_KEY: i64 = 0x7472656563726474; // "treecrdt" @@ -33,6 +33,13 @@ CREATE TABLE IF NOT EXISTS treecrdt_meta ( head_seq BIGINT NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS treecrdt_replica_meta ( + doc_id TEXT NOT NULL, + replica BYTEA NOT NULL, + max_counter BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (doc_id, replica) +); + CREATE TABLE IF NOT EXISTS treecrdt_nodes ( doc_id TEXT NOT NULL, node BYTEA NOT NULL, @@ -69,22 +76,17 @@ CREATE INDEX IF NOT EXISTS idx_treecrdt_oprefs_children_doc_parent_seq ON treecrdt_oprefs_children (doc_id, parent, seq); "#; -pub fn ensure_schema(client: &mut Client) -> Result<()> { - // `CREATE TABLE IF NOT EXISTS` is not fully concurrency-safe in Postgres; concurrent calls can - // still fail with catalog uniqueness violations. Serialize schema creation across processes. +fn configure_fast_test_tx(client: &mut Client) -> Result<()> { client - .query_one("SELECT pg_advisory_lock($1)", &[&SCHEMA_LOCK_KEY]) + .batch_execute( + "SET LOCAL synchronous_commit = OFF; + SET LOCAL statement_timeout = 0;", + ) .map_err(|e| Error::Storage(format!("{e:?}")))?; - - let res = client.batch_execute(SCHEMA_SQL).map_err(|e| Error::Storage(format!("{e:?}"))); - - // Best-effort unlock. Locks are also released when the connection is dropped. - let _ = client.query_one("SELECT pg_advisory_unlock($1)", &[&SCHEMA_LOCK_KEY]); - - res + Ok(()) } -pub fn reset_doc_for_tests(client: &mut Client, doc_id: &str) -> Result<()> { +fn reset_doc_for_tests_in_tx(client: &mut impl GenericClient, doc_id: &str) -> Result<()> { client .execute( "DELETE FROM treecrdt_oprefs_children WHERE doc_id = $1", @@ -103,5 +105,237 @@ pub fn reset_doc_for_tests(client: &mut Client, doc_id: &str) -> Result<()> { client .execute("DELETE FROM treecrdt_meta WHERE doc_id = $1", &[&doc_id]) .map_err(|e| Error::Storage(format!("{e:?}")))?; + client + .execute( + "DELETE FROM treecrdt_replica_meta WHERE doc_id = $1", + &[&doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + Ok(()) +} + +pub fn ensure_schema(client: &mut Client) -> Result<()> { + // `CREATE TABLE IF NOT EXISTS` is not fully concurrency-safe in Postgres; concurrent calls can + // still fail with catalog uniqueness violations. Serialize schema creation across processes. + client + .query_one("SELECT pg_advisory_lock($1)", &[&SCHEMA_LOCK_KEY]) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + + let res = client.batch_execute(SCHEMA_SQL).map_err(|e| Error::Storage(format!("{e:?}"))); + + // Best-effort unlock. Locks are also released when the connection is dropped. + let _ = client.query_one("SELECT pg_advisory_unlock($1)", &[&SCHEMA_LOCK_KEY]); + + res +} + +pub fn reset_doc_for_tests(client: &mut Client, doc_id: &str) -> Result<()> { + client.batch_execute("BEGIN").map_err(|e| Error::Storage(format!("{e:?}")))?; + let res = (|| { + configure_fast_test_tx(client)?; + reset_doc_for_tests_in_tx(client, doc_id) + })(); + + match res { + Ok(()) => client.batch_execute("COMMIT").map_err(|e| Error::Storage(format!("{e:?}"))), + Err(err) => { + let _ = client.batch_execute("ROLLBACK"); + Err(err) + } + } +} + +pub fn reset_doc_for_tests_within_tx(client: &mut Client, doc_id: &str) -> Result<()> { + reset_doc_for_tests_in_tx(client, doc_id) +} + +pub fn clone_doc_for_tests( + client: &mut Client, + source_doc_id: &str, + target_doc_id: &str, +) -> Result<()> { + if source_doc_id == target_doc_id { + return Err(Error::Storage( + "source_doc_id and target_doc_id must differ".to_string(), + )); + } + + let mut tx = client.transaction().map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.batch_execute( + "SET LOCAL synchronous_commit = OFF; + SET LOCAL statement_timeout = 0;", + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + + tx.execute( + "DELETE FROM treecrdt_oprefs_children WHERE doc_id = $1", + &[&target_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "DELETE FROM treecrdt_payload WHERE doc_id = $1", + &[&target_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "DELETE FROM treecrdt_nodes WHERE doc_id = $1", + &[&target_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "DELETE FROM treecrdt_ops WHERE doc_id = $1", + &[&target_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "DELETE FROM treecrdt_meta WHERE doc_id = $1", + &[&target_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "DELETE FROM treecrdt_replica_meta WHERE doc_id = $1", + &[&target_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + + tx.execute( + "INSERT INTO treecrdt_meta (doc_id, dirty, head_lamport, head_replica, head_counter, head_seq) + SELECT $1, dirty, head_lamport, head_replica, head_counter, head_seq + FROM treecrdt_meta + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "INSERT INTO treecrdt_replica_meta (doc_id, replica, max_counter) + SELECT $1, replica, max_counter + FROM treecrdt_replica_meta + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "INSERT INTO treecrdt_ops + (doc_id, op_ref, lamport, replica, counter, kind, parent, node, new_parent, order_key, payload, known_state) + SELECT $1, op_ref, lamport, replica, counter, kind, parent, node, new_parent, order_key, payload, known_state + FROM treecrdt_ops + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "INSERT INTO treecrdt_nodes + (doc_id, node, parent, order_key, tombstone, last_change, deleted_at) + SELECT $1, node, parent, order_key, tombstone, last_change, deleted_at + FROM treecrdt_nodes + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "INSERT INTO treecrdt_payload + (doc_id, node, payload, last_lamport, last_replica, last_counter) + SELECT $1, node, payload, last_lamport, last_replica, last_counter + FROM treecrdt_payload + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "INSERT INTO treecrdt_oprefs_children + (doc_id, parent, op_ref, seq) + SELECT $1, parent, op_ref, seq + FROM treecrdt_oprefs_children + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + + let copied = tx + .query_one( + "SELECT EXISTS(SELECT 1 FROM treecrdt_meta WHERE doc_id = $1)", + &[&target_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))? + .get::<_, bool>(0); + if !copied { + return Err(Error::Storage(format!( + "source doc not found for clone: {source_doc_id}" + ))); + } + + tx.commit().map_err(|e| Error::Storage(format!("{e:?}")))?; + Ok(()) +} + +pub fn clone_materialized_doc_for_tests( + client: &mut Client, + source_doc_id: &str, + target_doc_id: &str, +) -> Result<()> { + if source_doc_id == target_doc_id { + return Err(Error::Storage( + "source_doc_id and target_doc_id must differ".to_string(), + )); + } + + let mut tx = client.transaction().map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.batch_execute( + "SET LOCAL synchronous_commit = OFF; + SET LOCAL statement_timeout = 0;", + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + + reset_doc_for_tests_in_tx(&mut tx, target_doc_id)?; + + tx.execute( + "INSERT INTO treecrdt_meta (doc_id, dirty, head_lamport, head_replica, head_counter, head_seq) + SELECT $1, dirty, head_lamport, head_replica, head_counter, head_seq + FROM treecrdt_meta + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "INSERT INTO treecrdt_replica_meta (doc_id, replica, max_counter) + SELECT $1, replica, max_counter + FROM treecrdt_replica_meta + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "INSERT INTO treecrdt_nodes + (doc_id, node, parent, order_key, tombstone, last_change, deleted_at) + SELECT $1, node, parent, order_key, tombstone, last_change, deleted_at + FROM treecrdt_nodes + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + tx.execute( + "INSERT INTO treecrdt_payload + (doc_id, node, payload, last_lamport, last_replica, last_counter) + SELECT $1, node, payload, last_lamport, last_replica, last_counter + FROM treecrdt_payload + WHERE doc_id = $2", + &[&target_doc_id, &source_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))?; + + let copied = tx + .query_one( + "SELECT EXISTS(SELECT 1 FROM treecrdt_meta WHERE doc_id = $1)", + &[&target_doc_id], + ) + .map_err(|e| Error::Storage(format!("{e:?}")))? + .get::<_, bool>(0); + if !copied { + return Err(Error::Storage(format!( + "source doc not found for materialized clone: {source_doc_id}" + ))); + } + + tx.commit().map_err(|e| Error::Storage(format!("{e:?}")))?; Ok(()) } diff --git a/packages/treecrdt-postgres-rs/src/store.rs b/packages/treecrdt-postgres-rs/src/store.rs index a6681344..b6f0e843 100644 --- a/packages/treecrdt-postgres-rs/src/store.rs +++ b/packages/treecrdt-postgres-rs/src/store.rs @@ -13,6 +13,7 @@ use treecrdt_core::{ use crate::opref::{derive_op_ref_v0, OPREF_V0_WIDTH}; use crate::profile::{append_profile_enabled, PgAppendProfile}; +use crate::schema::{reset_doc_for_tests, reset_doc_for_tests_within_tx}; pub(crate) fn storage_debug(e: E) -> Error { Error::Storage(format!("{e:?}")) @@ -31,6 +32,59 @@ pub(crate) fn bytes_to_node(bytes: &[u8]) -> Result { Ok(NodeId(u128::from_be_bytes(arr))) } +fn order_key_from_position(position: usize) -> Result> { + if position >= u16::MAX as usize { + return Err(Error::Storage(format!( + "position too large for u16 order key: {position}" + ))); + } + Ok(((position + 1) as u16).to_be_bytes().to_vec()) +} + +fn repeated_replica_from_label(label: &str) -> Result { + if label.is_empty() { + return Err(Error::Storage("replica label must not be empty".into())); + } + let encoded = label.as_bytes(); + let mut out = vec![0u8; 32]; + for (index, byte) in out.iter_mut().enumerate() { + *byte = encoded[index % encoded.len()]; + } + Ok(ReplicaId::new(out)) +} + +fn payload_bytes_from_seed(seed: usize, size: usize) -> Vec { + let mut out = vec![0u8; size]; + for (index, byte) in out.iter_mut().enumerate() { + *byte = ((seed + index * 31) % 251) as u8; + } + out +} + +fn single_counter_vv_bytes(replica: &ReplicaId, counter: u64) -> Result> { + let mut vv = VersionVector::new(); + vv.observe(replica, counter); + vv_to_bytes(&vv) +} + +fn balanced_parent_and_position(node_index: usize, fanout: usize) -> (NodeId, usize) { + if node_index <= fanout { + return (NodeId::ROOT, (node_index - 1) % fanout); + } + ( + NodeId((((node_index - (fanout + 1)) / fanout) + 1) as u128), + (node_index - 1) % fanout, + ) +} + +fn balanced_direct_child_max_counter(node_index: usize, size: usize, fanout: usize) -> Option { + let start = node_index.saturating_mul(fanout).saturating_add(1); + if start > size { + return None; + } + Some((node_index.saturating_mul(fanout).saturating_add(fanout).min(size)) as u64) +} + pub(crate) fn op_ref_from_bytes(bytes: &[u8]) -> Result<[u8; OPREF_V0_WIDTH]> { if bytes.len() != OPREF_V0_WIDTH { return Err(Error::Storage("expected 16-byte op_ref".into())); @@ -95,7 +149,7 @@ fn load_tree_meta_row( doc_id: &str, for_update: bool, ) -> Result { - let ctx = PgCtx::new(client.clone(), doc_id)?; + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; let mut c = client.borrow_mut(); let stmt = if for_update { ctx.stmt( @@ -124,6 +178,7 @@ fn load_tree_meta_row( } fn load_tree_meta(client: &Rc>, doc_id: &str) -> Result { + ensure_doc_meta(client, doc_id)?; load_tree_meta_row(client, doc_id, false) } @@ -131,6 +186,7 @@ pub(crate) fn load_tree_meta_for_update( client: &Rc>, doc_id: &str, ) -> Result { + ensure_doc_meta(client, doc_id)?; load_tree_meta_row(client, doc_id, true) } @@ -180,12 +236,24 @@ impl PgCtx { Self::new_with_profile(client, doc_id, None) } + pub(crate) fn new_assume_doc_meta(client: Rc>, doc_id: &str) -> Result { + Self::new_with_profile_assume_doc_meta(client, doc_id, None) + } + fn new_with_profile( client: Rc>, doc_id: &str, append_profile: Option>>, ) -> Result { ensure_doc_meta(&client, doc_id)?; + Self::new_with_profile_assume_doc_meta(client, doc_id, append_profile) + } + + fn new_with_profile_assume_doc_meta( + client: Rc>, + doc_id: &str, + append_profile: Option>>, + ) -> Result { Ok(Self { doc_id: doc_id.to_string(), client, @@ -902,28 +970,49 @@ impl treecrdt_core::PayloadStore for PgPayloadStore { writer: (Lamport, OperationId), ) -> Result<()> { let started_at = Instant::now(); + let row_exists = matches!(self.cache.borrow().get(&node), Some(Some(_))); let node_bytes = node_to_bytes(node); let (lamport, id) = writer; let OperationId { replica, counter } = id; let ReplicaId(replica_bytes) = replica; let mut c = self.ctx.client.borrow_mut(); - let stmt = self.ctx.stmt( - &mut c, - "INSERT INTO treecrdt_payload(doc_id, node, payload, last_lamport, last_replica, last_counter) VALUES ($1,$2,$3,$4,$5,$6) \ - ON CONFLICT (doc_id, node) DO UPDATE SET payload = EXCLUDED.payload, last_lamport = EXCLUDED.last_lamport, last_replica = EXCLUDED.last_replica, last_counter = EXCLUDED.last_counter", - )?; - c.execute( - &stmt, - &[ - &self.ctx.doc_id, - &node_bytes.as_slice(), - &payload, - &(lamport as i64), - &replica_bytes, - &(counter as i64), - ], - ) - .map_err(storage_debug)?; + if row_exists { + let stmt = self.ctx.stmt( + &mut c, + "UPDATE treecrdt_payload \ + SET payload = $3, last_lamport = $4, last_replica = $5, last_counter = $6 \ + WHERE doc_id = $1 AND node = $2", + )?; + c.execute( + &stmt, + &[ + &self.ctx.doc_id, + &node_bytes.as_slice(), + &payload, + &(lamport as i64), + &replica_bytes, + &(counter as i64), + ], + ) + .map_err(storage_debug)?; + } else { + let stmt = self.ctx.stmt( + &mut c, + "INSERT INTO treecrdt_payload(doc_id, node, payload, last_lamport, last_replica, last_counter) VALUES ($1,$2,$3,$4,$5,$6)", + )?; + c.execute( + &stmt, + &[ + &self.ctx.doc_id, + &node_bytes.as_slice(), + &payload, + &(lamport as i64), + &replica_bytes, + &(counter as i64), + ], + ) + .map_err(storage_debug)?; + } self.cache.borrow_mut().insert( node, @@ -1049,6 +1138,14 @@ impl Storage for PgOpStorage { fn apply(&mut self, op: Operation) -> Result { let mut c = self.ctx.client.borrow_mut(); let inserted = insert_op_in_tx(&self.ctx, &mut c, &op)?; + drop(c); + if inserted { + upsert_replica_counters_in_tx( + &self.ctx.client, + &self.ctx.doc_id, + std::slice::from_ref(&op), + )?; + } Ok(inserted) } @@ -1319,6 +1416,45 @@ fn op_kind_to_db(op: &Operation) -> Result { } } +fn upsert_replica_counters_in_tx( + client: &Rc>, + doc_id: &str, + ops: &[Operation], +) -> Result<()> { + if ops.is_empty() { + return Ok(()); + } + + let mut per_replica: HashMap, i64> = HashMap::new(); + for op in ops { + let replica = op.meta.id.replica.as_bytes().to_vec(); + let counter = (op.meta.id.counter.min(i64::MAX as u64)) as i64; + per_replica + .entry(replica) + .and_modify(|current| *current = (*current).max(counter)) + .or_insert(counter); + } + + let mut replicas = Vec::with_capacity(per_replica.len()); + let mut counters = Vec::with_capacity(per_replica.len()); + for (replica, counter) in per_replica { + replicas.push(replica); + counters.push(counter); + } + + let mut c = client.borrow_mut(); + c.execute( + "INSERT INTO treecrdt_replica_meta (doc_id, replica, max_counter) \ + SELECT $1, src.replica, src.max_counter \ + FROM unnest($2::bytea[], $3::bigint[]) AS src(replica, max_counter) \ + ON CONFLICT (doc_id, replica) DO UPDATE \ + SET max_counter = GREATEST(treecrdt_replica_meta.max_counter, EXCLUDED.max_counter)", + &[&doc_id, &replicas, &counters], + ) + .map_err(storage_debug)?; + Ok(()) +} + fn insert_op_in_tx(ctx: &PgCtx, c: &mut Client, op: &Operation) -> Result { let replica = op.meta.id.replica.as_bytes(); let counter = op.meta.id.counter; @@ -1439,6 +1575,77 @@ fn bulk_insert_ops_in_tx(ctx: &PgCtx, c: &mut Client, ops: &[Operation]) -> Resu Ok(inserted) } +fn bulk_insert_fixture_nodes_in_tx( + ctx: &PgCtx, + c: &mut Client, + nodes: &[Vec], + parents: &[Option>], + order_keys: &[Option>], + last_changes: &[Option>], +) -> Result<()> { + if nodes.is_empty() { + return Ok(()); + } + + let stmt = ctx.stmt( + c, + "INSERT INTO treecrdt_nodes(doc_id, node, parent, order_key, tombstone, last_change, deleted_at) \ + SELECT $1, src.node, src.parent, src.order_key, FALSE, src.last_change, NULL::bytea \ + FROM unnest($2::bytea[], $3::bytea[], $4::bytea[], $5::bytea[]) \ + AS src(node, parent, order_key, last_change)", + )?; + c.execute( + &stmt, + &[&ctx.doc_id, &nodes, &parents, &order_keys, &last_changes], + ) + .map_err(storage_debug)?; + Ok(()) +} + +fn bulk_insert_fixture_index_rows_in_tx( + ctx: &PgCtx, + c: &mut Client, + parents: &[Vec], + op_refs: &[Vec], + seqs: &[i64], +) -> Result<()> { + if parents.is_empty() { + return Ok(()); + } + + let stmt = ctx.stmt( + c, + "INSERT INTO treecrdt_oprefs_children(doc_id, parent, op_ref, seq) \ + SELECT $1, src.parent, src.op_ref, src.seq \ + FROM unnest($2::bytea[], $3::bytea[], $4::bigint[]) AS src(parent, op_ref, seq)", + )?; + c.execute(&stmt, &[&ctx.doc_id, &parents, &op_refs, &seqs]) + .map_err(storage_debug)?; + Ok(()) +} + +fn upsert_replica_counter_in_tx( + client: &Rc>, + doc_id: &str, + replica: &[u8], + max_counter: u64, +) -> Result<()> { + let mut c = client.borrow_mut(); + c.execute( + "INSERT INTO treecrdt_replica_meta (doc_id, replica, max_counter) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (doc_id, replica) DO UPDATE \ + SET max_counter = GREATEST(treecrdt_replica_meta.max_counter, EXCLUDED.max_counter)", + &[ + &doc_id, + &replica, + &((max_counter.min(i64::MAX as u64)) as i64), + ], + ) + .map_err(storage_debug)?; + Ok(()) +} + fn select_inserted_ops( ctx: &PgCtx, ops: &[Operation], @@ -1552,6 +1759,293 @@ pub fn append_ops(client: &Rc>, doc_id: &str, ops: &[Operation]) } } +fn run_in_tx(client: &Rc>, f: impl FnOnce() -> Result) -> Result { + { + let mut c = client.borrow_mut(); + c.batch_execute("BEGIN").map_err(|e| Error::Storage(e.to_string()))?; + } + + let res = f(); + + match res { + Ok(v) => { + let mut c = client.borrow_mut(); + c.batch_execute("COMMIT").map_err(|e| Error::Storage(e.to_string()))?; + Ok(v) + } + Err(e) => { + let mut c = client.borrow_mut(); + let _ = c.batch_execute("ROLLBACK"); + Err(e) + } + } +} + +pub fn prime_doc_for_tests( + client: &Rc>, + doc_id: &str, + ops: &[Operation], +) -> Result<()> { + run_in_tx(client, || { + { + let mut c = client.borrow_mut(); + reset_doc_for_tests_within_tx(&mut c, doc_id)?; + } + + if ops.is_empty() { + return Ok(()); + } + + ensure_doc_meta(client, doc_id)?; + set_tree_meta_dirty(client, doc_id, true)?; + + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; + { + let mut c = client.borrow_mut(); + bulk_insert_ops_in_tx(&ctx, &mut c, ops)?; + } + upsert_replica_counters_in_tx(client, doc_id, ops)?; + Ok(()) + })?; + + if ops.is_empty() { + return Ok(()); + } + + // Fixture import wants one bulk append followed by one rebuild, rather than eager + // incremental materialization during the append path. + ensure_materialized(client, doc_id) +} + +pub fn prime_balanced_fanout_doc_for_tests( + client: &Rc>, + doc_id: &str, + size: usize, + fanout: usize, + payload_bytes: usize, + replica_label: &str, +) -> Result<()> { + if size == 0 { + return Err(Error::Storage("fixture size must be positive".into())); + } + if fanout == 0 { + return Err(Error::Storage("fixture fanout must be positive".into())); + } + if payload_bytes > 0 && size <= fanout { + return Err(Error::Storage(format!( + "payload fixture requires size > fanout ({fanout})" + ))); + } + + const BATCH_SIZE: usize = 50_000; + + { + let mut c = client.borrow_mut(); + reset_doc_for_tests(&mut c, doc_id)?; + } + + ensure_doc_meta(client, doc_id)?; + + let replica = repeated_replica_from_label(replica_label)?; + let replica_bytes = replica.as_bytes().to_vec(); + let payload_counter = (size + 1) as u64; + let total_ops = if payload_bytes > 0 { + payload_counter + } else { + size as u64 + }; + + run_in_tx(client, || { + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; + set_tree_meta_dirty(client, doc_id, true)?; + + let root_last_change = if size == 0 { + None + } else { + Some(single_counter_vv_bytes(&replica, size.min(fanout) as u64)?) + }; + + { + let mut c = client.borrow_mut(); + bulk_insert_fixture_nodes_in_tx( + &ctx, + &mut c, + &[node_to_bytes(NodeId::ROOT).to_vec()], + &[None], + &[Some(Vec::new())], + &[root_last_change], + )?; + } + + let mut batch_ops = Vec::with_capacity(BATCH_SIZE); + let mut node_rows: Vec> = Vec::with_capacity(BATCH_SIZE); + let mut node_parents: Vec>> = Vec::with_capacity(BATCH_SIZE); + let mut node_order_keys: Vec>> = Vec::with_capacity(BATCH_SIZE); + let mut node_last_changes: Vec>> = Vec::with_capacity(BATCH_SIZE); + let mut index_parents: Vec> = Vec::with_capacity(BATCH_SIZE); + let mut index_op_refs: Vec> = Vec::with_capacity(BATCH_SIZE); + let mut index_seqs: Vec = Vec::with_capacity(BATCH_SIZE); + + let flush_batch = |batch_ops: &mut Vec, + node_rows: &mut Vec>, + node_parents: &mut Vec>>, + node_order_keys: &mut Vec>>, + node_last_changes: &mut Vec>>, + index_parents: &mut Vec>, + index_op_refs: &mut Vec>, + index_seqs: &mut Vec| + -> Result<()> { + if batch_ops.is_empty() { + return Ok(()); + } + { + let mut c = client.borrow_mut(); + bulk_insert_ops_in_tx(&ctx, &mut c, batch_ops)?; + bulk_insert_fixture_nodes_in_tx( + &ctx, + &mut c, + node_rows, + node_parents, + node_order_keys, + node_last_changes, + )?; + bulk_insert_fixture_index_rows_in_tx( + &ctx, + &mut c, + index_parents, + index_op_refs, + index_seqs, + )?; + } + batch_ops.clear(); + node_rows.clear(); + node_parents.clear(); + node_order_keys.clear(); + node_last_changes.clear(); + index_parents.clear(); + index_op_refs.clear(); + index_seqs.clear(); + Ok(()) + }; + + for node_index in 1..=size { + let (parent, position) = balanced_parent_and_position(node_index, fanout); + let node = NodeId(node_index as u128); + let order_key = order_key_from_position(position)?; + let op = Operation::insert( + &replica, + node_index as u64, + node_index as Lamport, + parent, + node, + order_key.clone(), + ); + let op_ref = derive_op_ref_v0(doc_id, replica.as_bytes(), node_index as u64).to_vec(); + let mut last_change_counter = node_index as u64; + if let Some(child_max) = balanced_direct_child_max_counter(node_index, size, fanout) { + last_change_counter = last_change_counter.max(child_max); + } + if payload_bytes > 0 && node_index == fanout + 1 { + last_change_counter = last_change_counter.max(payload_counter); + } + + batch_ops.push(op); + node_rows.push(node_to_bytes(node).to_vec()); + node_parents.push(Some(node_to_bytes(parent).to_vec())); + node_order_keys.push(Some(order_key)); + node_last_changes.push(Some(single_counter_vv_bytes( + &replica, + last_change_counter, + )?)); + index_parents.push(node_to_bytes(parent).to_vec()); + index_op_refs.push(op_ref); + index_seqs.push(node_index as i64); + + if batch_ops.len() >= BATCH_SIZE { + flush_batch( + &mut batch_ops, + &mut node_rows, + &mut node_parents, + &mut node_order_keys, + &mut node_last_changes, + &mut index_parents, + &mut index_op_refs, + &mut index_seqs, + )?; + } + } + + flush_batch( + &mut batch_ops, + &mut node_rows, + &mut node_parents, + &mut node_order_keys, + &mut node_last_changes, + &mut index_parents, + &mut index_op_refs, + &mut index_seqs, + )?; + + if payload_bytes > 0 { + let payload_node = NodeId((fanout + 1) as u128); + let payload_node_bytes = node_to_bytes(payload_node); + let payload_op = Operation::set_payload( + &replica, + payload_counter, + payload_counter as Lamport, + payload_node, + payload_bytes_from_seed(10_000, payload_bytes), + ); + let payload_parent = NodeId(1); + let payload_op_ref = + derive_op_ref_v0(doc_id, replica.as_bytes(), payload_counter).to_vec(); + let payload_bytes_value = payload_bytes_from_seed(10_000, payload_bytes); + + { + let mut c = client.borrow_mut(); + bulk_insert_ops_in_tx(&ctx, &mut c, &[payload_op])?; + let stmt = ctx.stmt( + &mut c, + "INSERT INTO treecrdt_payload(doc_id, node, payload, last_lamport, last_replica, last_counter) \ + VALUES ($1, $2, $3, $4, $5, $6)", + )?; + c.execute( + &stmt, + &[ + &ctx.doc_id, + &payload_node_bytes.as_slice(), + &payload_bytes_value, + &(payload_counter as i64), + &replica_bytes, + &(payload_counter as i64), + ], + ) + .map_err(storage_debug)?; + bulk_insert_fixture_index_rows_in_tx( + &ctx, + &mut c, + &[node_to_bytes(payload_parent).to_vec()], + &[payload_op_ref], + &[payload_counter as i64], + )?; + } + } + + upsert_replica_counter_in_tx(client, doc_id, replica.as_bytes(), total_ops)?; + update_tree_meta_head( + client, + doc_id, + total_ops as Lamport, + replica.as_bytes(), + total_ops, + total_ops, + )?; + Ok(()) + })?; + + Ok(()) +} + fn append_ops_in_tx(client: &Rc>, doc_id: &str, ops: &[Operation]) -> Result { // Serialize per-doc writers across all server instances (incremental materialization updates // derived tables + head_seq and is not safe to run concurrently for the same doc_id). @@ -1565,7 +2059,8 @@ fn append_ops_in_tx(client: &Rc>, doc_id: &str, ops: &[Operation meta.head_seq(), ))) }); - let ctx = PgCtx::new_with_profile(client.clone(), doc_id, append_profile.clone())?; + let ctx = + PgCtx::new_with_profile_assume_doc_meta(client.clone(), doc_id, append_profile.clone())?; // treecrdt_ops is the source of truth. This function first appends/dedupes the op log in SQL // and only then decides whether it can replay the inserted subset through core materialization. @@ -1583,13 +2078,26 @@ fn append_ops_in_tx(client: &Rc>, doc_id: &str, ops: &[Operation profile.bulk_insert_ms += bulk_insert_started_at.elapsed().as_secs_f64() * 1000.0; profile.bulk_inserted_ops += inserted.len(); } - if !inserted.is_empty() { + let inserted_count = inserted.len(); + if inserted_count > 0 { + let inserted_op_refs: HashSet> = inserted.into_iter().collect(); + let inserted_ops: Vec = ops + .iter() + .filter(|op| { + let replica = op.meta.id.replica.as_bytes(); + let counter = op.meta.id.counter; + inserted_op_refs + .contains(derive_op_ref_v0(&ctx.doc_id, replica, counter).as_slice()) + }) + .cloned() + .collect(); + upsert_replica_counters_in_tx(client, doc_id, &inserted_ops)?; set_tree_meta_dirty(client, doc_id, true)?; if let Some(profile) = &append_profile { profile.borrow_mut().fallback_mark_dirty = true; } } - let inserted_count = inserted.len().min(u64::MAX as usize) as u64; + let inserted_count = inserted_count.min(u64::MAX as usize) as u64; if let Some(profile) = &append_profile { profile.borrow().log(doc_id, inserted_count as usize); } @@ -1630,6 +2138,8 @@ fn append_ops_in_tx(client: &Rc>, doc_id: &str, ops: &[Operation return Ok(0); } + upsert_replica_counters_in_tx(client, doc_id, &inserted_ops)?; + let inserted = inserted_ops.len(); // If incremental materialization fails, keep the op-log append and mark the doc dirty so the // next rebuild replays the full log through the same core semantics. @@ -1687,7 +2197,7 @@ pub fn ensure_materialized(client: &Rc>, doc_id: &str) -> Result } } -pub(crate) fn ensure_materialized_in_tx(client: &Rc>, doc_id: &str) -> Result<()> { +fn ensure_materialized_in_tx(client: &Rc>, doc_id: &str) -> Result<()> { let meta = load_tree_meta(client, doc_id)?; if !meta.dirty { return Ok(()); @@ -1741,3 +2251,82 @@ pub(crate) fn ensure_materialized_in_tx(client: &Rc>, doc_id: &s Ok(()) } + +pub(crate) fn ensure_materialized_and_load_meta_for_update_in_tx( + client: &Rc>, + doc_id: &str, +) -> Result { + ensure_doc_meta(client, doc_id)?; + let meta = load_tree_meta_row(client, doc_id, true)?; + if !meta.dirty { + return Ok(meta); + } + + clear_materialized(client, doc_id)?; + + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; + let storage = PgOpStorage::new(ctx.clone()); + let mut nodes = PgNodeStore::new(ctx.clone()); + let node_flush = nodes.clone(); + let mut payloads = PgPayloadStore::new(ctx.clone()); + let mut index = PgParentOpIndex::new(ctx.clone()); + + nodes.reset()?; + payloads.reset()?; + index.reset()?; + + let mut crdt = TreeCrdt::with_stores( + ReplicaId::new(b"postgres"), + storage, + LamportClock::default(), + nodes, + payloads, + )?; + crdt.replay_from_storage_with_materialization(&mut index)?; + node_flush.flush_last_change()?; + index.flush()?; + + let seq = crdt.log_len().min(u64::MAX as usize) as u64; + if let Some(last) = crdt.head_op() { + update_tree_meta_head( + client, + doc_id, + last.meta.lamport, + last.meta.id.replica.as_bytes(), + last.meta.id.counter, + seq, + )?; + return Ok(TreeMeta { + dirty: false, + head_lamport: last.meta.lamport, + head_replica: last.meta.id.replica.as_bytes().to_vec(), + head_counter: last.meta.id.counter, + head_seq: seq, + }); + } + + update_tree_meta_head(client, doc_id, 0, &[], 0, 0)?; + Ok(TreeMeta { + dirty: false, + head_lamport: 0, + head_replica: Vec::new(), + head_counter: 0, + head_seq: 0, + }) +} + +pub(crate) fn replica_max_counter_in_tx( + client: &Rc>, + doc_id: &str, + replica: &[u8], +) -> Result { + let ctx = PgCtx::new_assume_doc_meta(client.clone(), doc_id)?; + let mut c = client.borrow_mut(); + let stmt = ctx.stmt( + &mut c, + "SELECT COALESCE(MAX(max_counter), 0) FROM treecrdt_replica_meta WHERE doc_id = $1 AND replica = $2", + )?; + let rows = c.query(&stmt, &[&doc_id, &replica]).map_err(storage_debug)?; + let row = rows.first().ok_or_else(|| Error::Storage("missing MAX(counter) row".into()))?; + Ok(row.get::<_, i64>(0).max(0) as u64) +} diff --git a/packages/treecrdt-postgres-rs/tests/postgres_test.rs b/packages/treecrdt-postgres-rs/tests/postgres_test.rs index 2d7d8c04..06c786c1 100644 --- a/packages/treecrdt-postgres-rs/tests/postgres_test.rs +++ b/packages/treecrdt-postgres-rs/tests/postgres_test.rs @@ -7,11 +7,40 @@ use uuid::Uuid; use treecrdt_core::{NodeId, Operation, ReplicaId, VersionVector}; use treecrdt_postgres::{ - append_ops, ensure_materialized, ensure_schema, get_ops_by_op_refs, list_op_refs_all, - list_op_refs_children, local_delete, local_insert, local_move, local_payload, max_lamport, - replica_max_counter, reset_doc_for_tests, tree_children, + append_ops, clone_doc_for_tests, clone_materialized_doc_for_tests, ensure_materialized, + ensure_schema, get_ops_by_op_refs, list_op_refs_all, list_op_refs_children, local_delete, + local_insert, local_move, local_payload, max_lamport, prime_balanced_fanout_doc_for_tests, + prime_doc_for_tests, replica_max_counter, reset_doc_for_tests, tree_children, tree_node_count, }; +type NodeRow = ( + Vec, + Option>, + Option>, + bool, + Option>, +); +type PayloadRow = (Vec, Vec, i64, Vec, i64); +type IndexRow = (Vec, Vec, i64); +type MetaRow = (bool, i64, Vec, i64, i64); +type ReplicaRow = (Vec, i64); + +#[derive(Debug, PartialEq, Eq)] +struct MaterializedDocSnapshot { + nodes: Vec, + payloads: Vec, + meta: MetaRow, + replicas: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +struct FullDocSnapshot { + materialized: MaterializedDocSnapshot, + op_refs: Vec>, + ops: Vec, + indexes: Vec, +} + fn order_key_from_position(position: u16) -> Vec { let n = position.wrapping_add(1); n.to_be_bytes().to_vec() @@ -21,6 +50,47 @@ fn node(n: u128) -> NodeId { NodeId(n) } +fn balanced_parent_and_position(node_index: usize, fanout: usize) -> (NodeId, u16) { + if node_index <= fanout { + return (NodeId::ROOT, ((node_index - 1) % fanout) as u16); + } + ( + node((((node_index - (fanout + 1)) / fanout) + 1) as u128), + ((node_index - 1) % fanout) as u16, + ) +} + +fn build_balanced_fixture_ops( + size: usize, + fanout: usize, + payload_bytes: usize, + replica_label: &[u8], +) -> Vec { + let replica = ReplicaId::new(replica_label); + let mut ops = Vec::with_capacity(size + usize::from(payload_bytes > 0)); + for node_index in 1..=size { + let (parent, position) = balanced_parent_and_position(node_index, fanout); + ops.push(Operation::insert( + &replica, + node_index as u64, + node_index as u64, + parent, + node(node_index as u128), + order_key_from_position(position), + )); + } + if payload_bytes > 0 { + ops.push(Operation::set_payload( + &replica, + (size + 1) as u64, + (size + 1) as u64, + node((fanout + 1) as u128), + vec![0xAB; payload_bytes], + )); + } + ops +} + fn connect() -> Option>> { let url = std::env::var("TREECRDT_POSTGRES_URL").ok()?; let client = Client::connect(&url, NoTls).ok()?; @@ -35,6 +105,92 @@ fn ensure_schema_once(client: &Rc>) { }); } +fn node_rows(client: &Rc>, doc_id: &str) -> Vec { + let mut c = client.borrow_mut(); + c.query( + "SELECT node, parent, order_key, tombstone, last_change \ + FROM treecrdt_nodes WHERE doc_id = $1 ORDER BY node", + &[&doc_id], + ) + .unwrap() + .into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3), row.get(4))) + .collect() +} + +fn payload_rows(client: &Rc>, doc_id: &str) -> Vec { + let mut c = client.borrow_mut(); + c.query( + "SELECT node, payload, last_lamport, last_replica, last_counter \ + FROM treecrdt_payload WHERE doc_id = $1 ORDER BY node", + &[&doc_id], + ) + .unwrap() + .into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3), row.get(4))) + .collect() +} + +fn index_rows(client: &Rc>, doc_id: &str) -> Vec { + let mut c = client.borrow_mut(); + c.query( + "SELECT parent, op_ref, seq \ + FROM treecrdt_oprefs_children WHERE doc_id = $1 ORDER BY parent, seq, op_ref", + &[&doc_id], + ) + .unwrap() + .into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2))) + .collect() +} + +fn meta_row(client: &Rc>, doc_id: &str) -> MetaRow { + let mut c = client.borrow_mut(); + let row = c + .query_one( + "SELECT dirty, head_lamport, head_replica, head_counter, head_seq \ + FROM treecrdt_meta WHERE doc_id = $1", + &[&doc_id], + ) + .unwrap(); + (row.get(0), row.get(1), row.get(2), row.get(3), row.get(4)) +} + +fn replica_rows(client: &Rc>, doc_id: &str) -> Vec { + let mut c = client.borrow_mut(); + c.query( + "SELECT replica, max_counter \ + FROM treecrdt_replica_meta WHERE doc_id = $1 ORDER BY replica", + &[&doc_id], + ) + .unwrap() + .into_iter() + .map(|row| (row.get(0), row.get(1))) + .collect() +} + +fn snapshot_materialized_doc( + client: &Rc>, + doc_id: &str, +) -> MaterializedDocSnapshot { + MaterializedDocSnapshot { + nodes: node_rows(client, doc_id), + payloads: payload_rows(client, doc_id), + meta: meta_row(client, doc_id), + replicas: replica_rows(client, doc_id), + } +} + +fn snapshot_full_doc(client: &Rc>, doc_id: &str) -> FullDocSnapshot { + let op_refs = list_op_refs_all(client, doc_id).unwrap(); + FullDocSnapshot { + materialized: snapshot_materialized_doc(client, doc_id), + ops: get_ops_by_op_refs(client, doc_id, &op_refs).unwrap(), + op_refs: op_refs.into_iter().map(|op_ref| op_ref.to_vec()).collect(), + indexes: index_rows(client, doc_id), + } +} + #[test] fn postgres_backend_apply_is_idempotent_and_max_lamport_monotonic() { let Some(client) = connect() else { @@ -149,6 +305,209 @@ fn postgres_backend_large_append_rebuilds_materialized_views_on_demand() { assert_eq!(refs_root.len(), op_count as usize); } +#[test] +fn postgres_backend_prime_doc_for_tests_builds_materialized_fixture() { + let Some(client) = connect() else { + return; + }; + ensure_schema_once(&client); + + let doc_id = format!("test-{}", Uuid::new_v4()); + let replica = ReplicaId::new(b"fixture"); + let op_count = 2_500u64; + let ops: Vec = (0..op_count) + .map(|index| { + Operation::insert( + &replica, + index + 1, + index + 1, + NodeId::ROOT, + node(20_000 + index as u128), + order_key_from_position(index as u16), + ) + }) + .collect(); + + prime_doc_for_tests(&client, &doc_id, &ops).unwrap(); + + assert_eq!( + replica_max_counter(&client, &doc_id, replica.as_bytes()).unwrap(), + op_count + ); + assert_eq!(max_lamport(&client, &doc_id).unwrap(), op_count); + assert_eq!( + list_op_refs_all(&client, &doc_id).unwrap().len(), + op_count as usize + ); + assert_eq!( + tree_children(&client, &doc_id, NodeId::ROOT).unwrap().len(), + op_count as usize + ); + assert_eq!( + list_op_refs_children(&client, &doc_id, NodeId::ROOT).unwrap().len(), + op_count as usize + ); +} + +#[test] +fn postgres_backend_prime_balanced_fanout_doc_for_tests_generates_expected_shape() { + let Some(client) = connect() else { + return; + }; + ensure_schema_once(&client); + + let doc_id = format!("test-{}", Uuid::new_v4()); + prime_balanced_fanout_doc_for_tests(&client, &doc_id, 25, 3, 8, "playground-seed").unwrap(); + + assert_eq!(tree_node_count(&client, &doc_id).unwrap(), 25); + assert_eq!(max_lamport(&client, &doc_id).unwrap(), 26); + assert_eq!( + tree_children(&client, &doc_id, NodeId::ROOT).unwrap().len(), + 3 + ); + assert_eq!( + list_op_refs_children(&client, &doc_id, NodeId::ROOT).unwrap().len(), + 3 + ); + assert_eq!( + treecrdt_postgres::tree_payload(&client, &doc_id, node(4)) + .unwrap() + .unwrap() + .len(), + 8 + ); +} + +#[test] +fn postgres_backend_prime_balanced_fanout_doc_for_tests_matches_replay_materialization() { + let Some(client) = connect() else { + return; + }; + ensure_schema_once(&client); + + let direct_doc_id = format!("test-direct-{}", Uuid::new_v4()); + let replay_doc_id = format!("test-replay-{}", Uuid::new_v4()); + let size = 25usize; + let fanout = 3usize; + let payload_bytes = 8usize; + let ops = build_balanced_fixture_ops(size, fanout, payload_bytes, b"playground-seed"); + + prime_doc_for_tests(&client, &replay_doc_id, &ops).unwrap(); + prime_balanced_fanout_doc_for_tests( + &client, + &direct_doc_id, + size, + fanout, + payload_bytes, + "playground-seed", + ) + .unwrap(); + + assert_eq!( + snapshot_full_doc(&client, &replay_doc_id), + snapshot_full_doc(&client, &direct_doc_id) + ); +} + +#[test] +fn postgres_backend_clone_doc_for_tests_matches_source_storage() { + let Some(client) = connect() else { + return; + }; + ensure_schema_once(&client); + + let source_doc_id = format!("test-source-{}", Uuid::new_v4()); + let target_doc_id = format!("test-target-{}", Uuid::new_v4()); + let ops = build_balanced_fixture_ops(25, 3, 8, b"clone-seed"); + + prime_doc_for_tests(&client, &source_doc_id, &ops).unwrap(); + { + let mut c = client.borrow_mut(); + clone_doc_for_tests(&mut c, &source_doc_id, &target_doc_id).unwrap(); + } + + assert_eq!( + snapshot_full_doc(&client, &source_doc_id), + snapshot_full_doc(&client, &target_doc_id) + ); +} + +#[test] +fn postgres_backend_clone_materialized_doc_for_tests_matches_source_materialized_state() { + let Some(client) = connect() else { + return; + }; + ensure_schema_once(&client); + + let source_doc_id = format!("test-source-{}", Uuid::new_v4()); + let target_doc_id = format!("test-target-{}", Uuid::new_v4()); + let ops = build_balanced_fixture_ops(25, 3, 8, b"clone-seed"); + + prime_doc_for_tests(&client, &source_doc_id, &ops).unwrap(); + { + let mut c = client.borrow_mut(); + clone_materialized_doc_for_tests(&mut c, &source_doc_id, &target_doc_id).unwrap(); + } + + assert_eq!( + snapshot_materialized_doc(&client, &source_doc_id), + snapshot_materialized_doc(&client, &target_doc_id) + ); + assert!(list_op_refs_all(&client, &target_doc_id).unwrap().is_empty()); +} + +#[test] +fn postgres_backend_materialized_clone_supports_local_writes() { + let Some(client) = connect() else { + return; + }; + ensure_schema_once(&client); + + let source_doc_id = format!("test-source-{}", Uuid::new_v4()); + let target_doc_id = format!("test-target-{}", Uuid::new_v4()); + prime_balanced_fanout_doc_for_tests(&client, &source_doc_id, 25, 3, 8, "playground-seed") + .unwrap(); + + { + let mut c = client.borrow_mut(); + clone_materialized_doc_for_tests(&mut c, &source_doc_id, &target_doc_id).unwrap(); + } + + let replica = ReplicaId::new(b"writer"); + let inserted = local_insert( + &client, + &target_doc_id, + &replica, + NodeId::ROOT, + node(999), + "last", + None, + Some(vec![1, 2, 3]), + ) + .unwrap(); + assert_eq!(inserted.kind.node(), node(999)); + assert_eq!( + treecrdt_postgres::tree_parent(&client, &target_doc_id, node(999)).unwrap(), + Some(NodeId::ROOT) + ); + assert_eq!( + treecrdt_postgres::tree_payload(&client, &target_doc_id, node(999)) + .unwrap() + .unwrap(), + vec![1, 2, 3] + ); + + let updated = + local_payload(&client, &target_doc_id, &replica, node(4), Some(vec![9, 9])).unwrap(); + assert_eq!(updated.kind.node(), node(4)); + assert_eq!( + treecrdt_postgres::tree_payload(&client, &target_doc_id, node(4)) + .unwrap() + .unwrap(), + vec![9, 9] + ); +} + #[test] fn postgres_backend_doc_isolation() { let Some(client) = connect() else { diff --git a/packages/treecrdt-sqlite-node/package.json b/packages/treecrdt-sqlite-node/package.json index 644c2db0..bcf94adb 100644 --- a/packages/treecrdt-sqlite-node/package.json +++ b/packages/treecrdt-sqlite-node/package.json @@ -16,6 +16,7 @@ "build": "pnpm run build:native-ext && pnpm run copy:ext && pnpm run build:ts", "test": "pnpm -C ../sync/protocol run build && pnpm -C ../sync/material/sqlite run build && pnpm run build && vitest run", "benchmark:ops": "pnpm -C ../sync/protocol run build && pnpm -C ../sync/material/sqlite run build && pnpm run build && tsx ./scripts/bench.ts", + "benchmark:hot-write": "pnpm -C ../treecrdt-benchmark run build && pnpm run build && tsx ./scripts/bench-hot-writes.ts", "benchmark:note-paths": "pnpm -C ../treecrdt-benchmark run build && pnpm run build && tsx ./scripts/bench-note-paths.ts", "benchmark:sync": "pnpm -C ../treecrdt-benchmark run build && pnpm -C ../sync/protocol run build && pnpm -C ../sync/material/sqlite run build && pnpm -C ../treecrdt-postgres-napi run build && pnpm -C ../sync/server/postgres-node run build && pnpm run build && tsx ./scripts/bench-sync.ts", "benchmark": "pnpm run benchmark:ops && pnpm run benchmark:note-paths && pnpm run benchmark:sync" diff --git a/packages/treecrdt-sqlite-node/scripts/bench-hot-writes.ts b/packages/treecrdt-sqlite-node/scripts/bench-hot-writes.ts new file mode 100644 index 00000000..dcaa26e4 --- /dev/null +++ b/packages/treecrdt-sqlite-node/scripts/bench-hot-writes.ts @@ -0,0 +1,128 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import Database from 'better-sqlite3'; + +import { + DEFAULT_HOT_WRITE_CONFIG, + DEFAULT_HOT_WRITE_FANOUT, + DEFAULT_HOT_WRITE_PAYLOAD_BYTES, + parseHotWriteConfigFromArgv, + parseHotWriteKinds, + parseNonNegativeIntFlag, + parsePositiveIntFlag, + runHotWriteBenchmarks, + type HotWriteBenchKind, + type HotWriteSeedTargets, +} from '../../treecrdt-benchmark/dist/hot-write.js'; +import { repoRootFromImportMeta } from '@treecrdt/benchmark/node'; +import { nodeIdToBytes16 } from '@treecrdt/interface/ids'; + +import { createTreecrdtClient, createSqliteNodeApi, loadTreecrdtExtension } from '../dist/index.js'; + +type StorageKind = 'memory' | 'file'; + +const STORAGES: readonly StorageKind[] = ['memory', 'file']; + +async function openSeededClient(opts: { + repoRoot: string; + storage: StorageKind; + bench: HotWriteBenchKind; + size: number; + seed: HotWriteSeedTargets; + getSeed: () => { ops: import('@treecrdt/interface').Operation[] }; + sampleIndex: number; +}) { + const dbPath = + opts.storage === 'memory' + ? ':memory:' + : path.join( + opts.repoRoot, + 'tmp', + 'sqlite-node-hot-write', + `${opts.bench}-${opts.size}-${opts.sampleIndex}-${randomUUID()}.db`, + ); + + if (opts.storage === 'file') { + await fs.mkdir(path.dirname(dbPath), { recursive: true }); + } + + const db = new Database(dbPath); + loadTreecrdtExtension(db); + const api = createSqliteNodeApi(db); + await api.setDocId('treecrdt-hot-write-bench'); + await api.appendOps!(opts.getSeed().ops, nodeIdToBytes16, (replica) => replica); + + const client = await createTreecrdtClient(db, { docId: 'treecrdt-hot-write-bench' }); + return { + ...client, + close: async () => { + await client.close(); + if (opts.storage === 'file') { + await fs.rm(dbPath).catch(() => {}); + } + }, + }; +} + +async function main() { + const repoRoot = repoRootFromImportMeta(import.meta.url, 3); + const argv = process.argv.slice(2); + const config = parseHotWriteConfigFromArgv(argv) ?? [...DEFAULT_HOT_WRITE_CONFIG]; + const benches = parseHotWriteKinds(argv); + const fanout = parsePositiveIntFlag( + argv, + '--fanout', + 'HOT_WRITE_BENCH_FANOUT', + DEFAULT_HOT_WRITE_FANOUT, + ); + const payloadBytes = parsePositiveIntFlag( + argv, + '--payload-bytes', + 'HOT_WRITE_BENCH_PAYLOAD_BYTES', + DEFAULT_HOT_WRITE_PAYLOAD_BYTES, + ); + const writesPerSample = parsePositiveIntFlag( + argv, + '--writes-per-sample', + 'HOT_WRITE_WRITES_PER_SAMPLE', + 1, + ); + const warmupWrites = parseNonNegativeIntFlag( + argv, + '--warmup-writes', + 'HOT_WRITE_WARMUP_WRITES', + 0, + ); + + for (const storage of STORAGES) { + const outputs = await runHotWriteBenchmarks({ + repoRoot, + implementation: 'sqlite-node', + storage, + config, + benches, + fanout, + payloadBytes, + writesPerSample, + warmupWrites, + openSeededEngine: ({ bench, size, seed, getSeed, sampleIndex }) => + openSeededClient({ + repoRoot, + storage, + bench, + size, + seed, + getSeed, + sampleIndex, + }), + }); + for (const output of outputs) console.log(JSON.stringify(output, null, 2)); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/treecrdt-sqlite-node/scripts/bench-note-paths.ts b/packages/treecrdt-sqlite-node/scripts/bench-note-paths.ts index 5c3862e0..323c3f1c 100644 --- a/packages/treecrdt-sqlite-node/scripts/bench-note-paths.ts +++ b/packages/treecrdt-sqlite-node/scripts/bench-note-paths.ts @@ -10,6 +10,12 @@ import { runBenchmark, type BenchmarkWorkload, } from '@treecrdt/benchmark'; +import { + parseFlagValue, + parsePositiveIntFlag, + payloadBytesFromSeed, + replicaFromLabel, +} from '@treecrdt/benchmark/helpers'; import { repoRootFromImportMeta, writeResult } from '@treecrdt/benchmark/node'; import type { Operation, ReplicaId } from '@treecrdt/interface'; import { nodeIdToBytes16 } from '@treecrdt/interface/ids'; @@ -72,27 +78,6 @@ function parseConfigFromArgv(argv: string[]): Array | null { return customConfig; } -function parseFlagValue(argv: string[], flag: string): string | undefined { - const prefix = `${flag}=`; - const raw = argv.find((arg) => arg.startsWith(prefix)); - return raw ? raw.slice(prefix.length).trim() : undefined; -} - -function parsePositiveIntFlag( - argv: string[], - flag: string, - envName: string, - fallback: number, -): number { - const raw = parseFlagValue(argv, flag) ?? process.env[envName]; - if (!raw) return fallback; - const value = Number(raw); - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`invalid ${flag} value "${raw}", expected a positive integer`); - } - return value; -} - function parseKinds(argv: string[]): NotePathBenchKind[] { const raw = parseFlagValue(argv, '--benches') ?? @@ -116,23 +101,6 @@ function parseKinds(argv: string[]): NotePathBenchKind[] { return seen.size > 0 ? Array.from(seen) : Array.from(ALL_NOTE_PATH_BENCHES); } -function payloadBytesFromSeed(seed: number, size = DEFAULT_PAYLOAD_BYTES): Uint8Array { - if (!Number.isInteger(seed) || seed < 0) throw new Error(`invalid payload seed: ${seed}`); - const out = new Uint8Array(size); - for (let i = 0; i < out.length; i += 1) { - out[i] = (seed + i * 31) % 251; - } - return out; -} - -function replicaFromLabel(label: string): Uint8Array { - const encoded = new TextEncoder().encode(label); - if (encoded.length === 0) throw new Error('label must not be empty'); - const out = new Uint8Array(32); - for (let i = 0; i < out.length; i += 1) out[i] = encoded[i % encoded.length]!; - return out; -} - function makePayloadOp( replica: ReplicaId, counter: number, diff --git a/packages/treecrdt-sqlite-node/scripts/bench-sync.ts b/packages/treecrdt-sqlite-node/scripts/bench-sync.ts index 37dbad78..e2785162 100644 --- a/packages/treecrdt-sqlite-node/scripts/bench-sync.ts +++ b/packages/treecrdt-sqlite-node/scripts/bench-sync.ts @@ -32,6 +32,8 @@ import { quantile, type SyncBenchWorkload, } from '@treecrdt/benchmark'; +import { parseFlagValue, parsePositiveIntFlag } from '@treecrdt/benchmark/helpers'; +import type { BenchmarkFixtureHelpers } from '@treecrdt/benchmark/testing'; import { repoRootFromImportMeta, writeResult } from '@treecrdt/benchmark/node'; import type { Operation } from '@treecrdt/interface'; import { nodeIdToBytes16 } from '@treecrdt/interface/ids'; @@ -74,6 +76,9 @@ type BenchCase = { fanout: number; }; +type BuiltSyncBench = ReturnType; +type ServerPrimeMode = NonNullable['kind']; + type SyncBenchResult = { name: string; totalOps: number; @@ -108,8 +113,8 @@ type SyncBenchSample = { type PreparedServerFixture = { docId: string; cacheKey?: string; - cacheStatus: 'disabled' | 'hit' | 'miss' | 'rebuild' | 'assumed'; cacheStatus: 'disabled' | 'hit' | 'miss' | 'rebuild' | 'assumed' | 'manifest'; + primeMode?: ServerPrimeMode; seedUploadMs?: number; seedAllReadyMs?: number; filterReadyMs?: number; @@ -121,6 +126,11 @@ type SyncBenchConnection = { close: () => Promise; }; +type SyncBenchFixtureHelpers = Pick< + BenchmarkFixtureHelpers, + 'resetDocForTests' | 'primeBalancedFanoutDocForTests' +>; + type SyncBenchTargetRuntime = { id: Exclude; serverProcess: 'child-process' | 'in-process' | 'remote'; @@ -128,7 +138,7 @@ type SyncBenchTargetRuntime = { connect: (docId: string) => Promise; seedOps?: (docId: string, ops: Operation[]) => Promise; inspectDoc?: (docId: string) => Promise<{ allCount: number; maxLamport: number }>; - resetDoc?: (docId: string) => Promise; + resetDoc?: SyncBenchFixtureHelpers['resetDocForTests']; waitForOpCount?: ( docId: string, filter: Filter, @@ -138,7 +148,7 @@ type SyncBenchTargetRuntime = { clearHelloTrace?: (docId: string) => void; takeHelloTrace?: (docId: string) => HelloTraceProfile | undefined; close: () => Promise; -}; +} & Partial; type TransportDirectionProfile = { messages: number; @@ -321,27 +331,6 @@ function parseConfigFromArgv( return customConfig; } -function parseFlagValue(argv: string[], flag: string): string | undefined { - const prefix = `${flag}=`; - const raw = argv.find((arg) => arg.startsWith(prefix)); - return raw ? raw.slice(prefix.length).trim() : undefined; -} - -function parsePositiveIntFlag( - argv: string[], - flag: string, - envName: string, - fallback: number, -): number { - const raw = parseFlagValue(argv, flag) ?? process.env[envName]; - if (!raw) return fallback; - const value = Number(raw); - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`invalid ${flag} value "${raw}", expected a positive integer`); - } - return value; -} - function parseOptionalPositiveIntFlag( argv: string[], flag: string, @@ -1155,6 +1144,21 @@ async function createLocalPostgresSyncServerTarget( const backend = await backendFactory.open(docId); await backend.applyOps(ops); }; + const primeBalancedFanoutDocForTests = async ( + docId: string, + size: number, + fanout: number, + payloadBytes: number, + replicaLabel: string, + ) => { + await backendFactory.primeBalancedFanoutDocForTests( + docId, + size, + fanout, + payloadBytes, + replicaLabel, + ); + }; if (profileBackend) { const server = await startSyncServer({ @@ -1174,6 +1178,7 @@ async function createLocalPostgresSyncServerTarget( connect: async (docId) => await connectToSyncServer(`ws://127.0.0.1:${server.port}`, docId), fixtureCacheScope: 'local-postgres-sync-server', seedOps, + primeBalancedFanoutDocForTests, inspectDoc, resetDoc, waitForOpCount, @@ -1233,6 +1238,7 @@ async function createLocalPostgresSyncServerTarget( connect: async (docId) => await connectToSyncServer(`ws://127.0.0.1:${port}`, docId), fixtureCacheScope: 'local-postgres-sync-server', seedOps, + primeBalancedFanoutDocForTests, inspectDoc, resetDoc, waitForOpCount, @@ -1295,21 +1301,40 @@ async function loadPostgresSyncBackendFactory( backendModule: string, postgresUrl: string, ): Promise<{ - resetDocForTests: (docId: string) => Promise; + ensureSchema: () => Promise; + resetDocForTests: NonNullable; + primeBalancedFanoutDocForTests: NonNullable< + SyncBenchFixtureHelpers['primeBalancedFanoutDocForTests'] + >; open: (docId: string) => Promise>; }> { const mod = (await import(pathToFileURL(backendModule).href)) as { createPostgresNapiSyncBackendFactory?: (url: string) => { - resetDocForTests: (docId: string) => Promise; + ensureSchema: () => Promise; + resetDocForTests: NonNullable; + primeBalancedFanoutDocForTests: NonNullable< + SyncBenchFixtureHelpers['primeBalancedFanoutDocForTests'] + >; + open: (docId: string) => Promise>; + }; + createPostgresNapiTestSyncBackendFactory?: (url: string) => { + ensureSchema: () => Promise; + resetDocForTests: NonNullable; + primeBalancedFanoutDocForTests: NonNullable< + SyncBenchFixtureHelpers['primeBalancedFanoutDocForTests'] + >; open: (docId: string) => Promise>; }; }; - if (typeof mod.createPostgresNapiSyncBackendFactory !== 'function') { - throw new Error( - `backend module "${backendModule}" does not export createPostgresNapiSyncBackendFactory(url)`, - ); + if (typeof mod.createPostgresNapiTestSyncBackendFactory === 'function') { + return mod.createPostgresNapiTestSyncBackendFactory(postgresUrl); } - return mod.createPostgresNapiSyncBackendFactory(postgresUrl); + if (typeof mod.createPostgresNapiSyncBackendFactory === 'function') { + return mod.createPostgresNapiSyncBackendFactory(postgresUrl); + } + throw new Error( + `backend module "${backendModule}" does not export createPostgresNapiSyncBackendFactory(url) or createPostgresNapiTestSyncBackendFactory(url)`, + ); } async function findFreePort(): Promise { @@ -1335,12 +1360,83 @@ async function findFreePort(): Promise { }); } -function createRemoteSyncServerTarget(baseUrl: string): SyncBenchTargetRuntime { +async function createDirectPostgresRemoteFixtureHelpers( + repoRoot: string, + postgresUrl: string, +): Promise< + Pick< + SyncBenchTargetRuntime, + 'inspectDoc' | 'resetDoc' | 'seedOps' | 'primeBalancedFanoutDocForTests' | 'waitForOpCount' + > +> { + const backendModule = path.join( + repoRoot, + 'packages', + 'treecrdt-postgres-napi', + 'dist', + 'testing.js', + ); + const waitForOpCount = await createDirectPostgresOpCountWaiter(backendModule, postgresUrl); + const backendFactory = await loadPostgresSyncBackendFactory(backendModule, postgresUrl); + await backendFactory.ensureSchema(); + const inspectDoc = async (docId: string) => { + const backend = await backendFactory.open(docId); + const [allRefs, currentMaxLamport] = await Promise.all([ + backend.listOpRefs({ all: {} }), + backend.maxLamport(), + ]); + return { + allCount: allRefs.length, + maxLamport: Number(currentMaxLamport), + }; + }; + const resetDoc = async (docId: string) => { + await backendFactory.resetDocForTests(docId); + }; + const seedOps = async (docId: string, ops: Operation[]) => { + if (ops.length === 0) return; + const backend = await backendFactory.open(docId); + await backend.applyOps(ops); + }; + const primeBalancedFanoutDocForTests = async ( + docId: string, + size: number, + fanout: number, + payloadBytes: number, + replicaLabel: string, + ) => { + await backendFactory.primeBalancedFanoutDocForTests( + docId, + size, + fanout, + payloadBytes, + replicaLabel, + ); + }; + return { + inspectDoc, + resetDoc, + seedOps, + primeBalancedFanoutDocForTests, + waitForOpCount, + }; +} + +async function createRemoteSyncServerTarget( + repoRoot: string, + baseUrl: string, + postgresUrl?: string, +): Promise { + const directFixtureHelpers = + postgresUrl != null && postgresUrl.length > 0 + ? await createDirectPostgresRemoteFixtureHelpers(repoRoot, postgresUrl) + : undefined; return { id: 'remote-sync-server', serverProcess: 'remote', connect: async (docId) => await connectToSyncServer(baseUrl, docId, { client: 'builtin' }), fixtureCacheScope: baseUrl, + ...directFixtureHelpers, close: async () => {}, }; } @@ -1427,12 +1523,16 @@ async function prepareTargetRuntimes( if (targets.includes('remote-sync-server')) { const remoteUrl = parseFlagValue(argv, '--sync-server-url') ?? process.env.TREECRDT_SYNC_SERVER_URL; + const postgresUrl = parseFlagValue(argv, '--postgres-url') ?? process.env.TREECRDT_POSTGRES_URL; if (!remoteUrl) { throw new Error( 'remote-sync-server target requires TREECRDT_SYNC_SERVER_URL or --sync-server-url=...', ); } - runtimes.set('remote-sync-server', createRemoteSyncServerTarget(remoteUrl)); + runtimes.set( + 'remote-sync-server', + await createRemoteSyncServerTarget(repoRoot, remoteUrl, postgresUrl), + ); } return runtimes; @@ -1494,21 +1594,48 @@ async function syncBackendThroughServer( } } -async function seedServerState( +async function seedPrimedBalancedServerState( runtime: SyncBenchTargetRuntime, + bench: BuiltSyncBench, docId: string, - ops: Operation[], - filter: Filter, - maxOpsPerBatch?: number, ): Promise { - if (ops.length === 0) { - return { - expectedFilterCount: 0, - uploadMs: 0, - allReadyMs: 0, - }; + const prime = bench.serverPrime; + if (!runtime.primeBalancedFanoutDocForTests || prime?.kind !== 'balanced-fanout') { + throw new Error(`runtime ${runtime.id} does not support optimized balanced fixture priming`); } + const uploadStartedAt = performance.now(); + await runtime.primeBalancedFanoutDocForTests( + docId, + prime.size, + prime.fanout, + 0, + prime.treeReplicaLabel, + ); + if (prime.overlayOps.length > 0) { + if (!runtime.seedOps) { + throw new Error( + `runtime ${runtime.id} does not support overlay ops for optimized balanced fixture priming`, + ); + } + await runtime.seedOps(docId, prime.overlayOps); + } + return { + expectedFilterCount: bench.totalOps, + uploadMs: performance.now() - uploadStartedAt, + allReadyMs: 0, + }; +} + +async function createSeedBackendForServerState( + docId: string, + ops: Operation[], + filter: Filter, +): Promise<{ + seedDb: Database.Database; + seedBackend: FlushableSyncBackend; + expectedFilterCount: number; +}> { const seedDb = await openDb({ storage: 'memory', docId }); try { await appendInitialOps(seedDb, ops); @@ -1518,80 +1645,143 @@ async function seedServerState( initialMaxLamport: maxLamport(ops), }); const expectedFilterCount = (await seedBackend.listOpRefs(filter)).length; - if (runtime.seedOps) { + return { seedDb, seedBackend, expectedFilterCount }; + } catch (error) { + seedDb.close(); + throw error; + } +} + +async function waitForSeededServerAllReady( + runtime: SyncBenchTargetRuntime, + docId: string, + opCount: number, +): Promise { + const allReadyStartedAt = performance.now(); + if (runtime.waitForOpCount) { + await runtime.waitForOpCount(docId, { all: {} }, opCount, { + timeoutMs: SERVER_SEED_READY_TIMEOUT_MS, + }); + } + return performance.now() - allReadyStartedAt; +} + +async function seedServerStateViaDirectOps( + runtime: SyncBenchTargetRuntime, + docId: string, + ops: Operation[], + expectedFilterCount: number, +): Promise { + if (!runtime.seedOps) { + throw new Error(`runtime ${runtime.id} does not support direct server seeding`); + } + + const uploadStartedAt = performance.now(); + await runtime.seedOps(docId, ops); + return { + expectedFilterCount, + uploadMs: performance.now() - uploadStartedAt, + allReadyMs: await waitForSeededServerAllReady(runtime, docId, ops.length), + }; +} + +async function seedServerStateViaSyncPeerUpload( + runtime: SyncBenchTargetRuntime, + docId: string, + seedBackend: FlushableSyncBackend, + opCount: number, + expectedFilterCount: number, + maxOpsPerBatch?: number, +): Promise { + const peer = new SyncPeer(seedBackend, { + maxCodewords: SYNC_BENCH_SEED_MAX_CODEWORDS, + directSendThreshold: 0, + ...(maxOpsPerBatch != null ? { maxOpsPerBatch } : {}), + }); + const deadline = Date.now() + SERVER_SEED_READY_TIMEOUT_MS; + let lastError: unknown; + while (true) { + const connection = await runtime.connect(docId); + const detach = peer.attach(connection.transport); + try { const uploadStartedAt = performance.now(); - await runtime.seedOps(docId, ops); - const uploadMs = performance.now() - uploadStartedAt; - const allReadyStartedAt = performance.now(); - if (runtime.waitForOpCount) { - await runtime.waitForOpCount(docId, { all: {} }, ops.length, { - timeoutMs: SERVER_SEED_READY_TIMEOUT_MS, - }); - } + await peer.syncOnce( + connection.transport, + { all: {} }, + { + maxCodewords: SYNC_BENCH_SEED_MAX_CODEWORDS, + codewordsPerMessage: SYNC_BENCH_DEFAULT_CODEWORDS_PER_MESSAGE, + ...(maxOpsPerBatch != null ? { maxOpsPerBatch } : {}), + }, + ); + await seedBackend.flush(); return { expectedFilterCount, - uploadMs, - allReadyMs: performance.now() - allReadyStartedAt, + uploadMs: performance.now() - uploadStartedAt, + allReadyMs: await waitForSeededServerAllReady(runtime, docId, opCount), }; - } - const peer = new SyncPeer(seedBackend, { - maxCodewords: SYNC_BENCH_SEED_MAX_CODEWORDS, - directSendThreshold: 0, - ...(maxOpsPerBatch != null ? { maxOpsPerBatch } : {}), - }); - const deadline = Date.now() + SERVER_SEED_READY_TIMEOUT_MS; - let lastError: unknown; - while (true) { - const connection = await runtime.connect(docId); - const detach = peer.attach(connection.transport); - try { - const uploadStartedAt = performance.now(); - await peer.syncOnce( - connection.transport, - { all: {} }, - { - maxCodewords: SYNC_BENCH_SEED_MAX_CODEWORDS, - codewordsPerMessage: SYNC_BENCH_DEFAULT_CODEWORDS_PER_MESSAGE, - ...(maxOpsPerBatch != null ? { maxOpsPerBatch } : {}), - }, + } catch (error) { + lastError = error; + if (Date.now() >= deadline) { + throw new Error( + `timed out seeding server doc ${docId} within ${SERVER_SEED_READY_TIMEOUT_MS}ms` + + (lastError ? `: ${String(lastError)}` : ''), ); - await seedBackend.flush(); - const uploadMs = performance.now() - uploadStartedAt; - const allReadyStartedAt = performance.now(); - if (runtime.waitForOpCount) { - await runtime.waitForOpCount(docId, { all: {} }, ops.length, { - timeoutMs: SERVER_SEED_READY_TIMEOUT_MS, - }); - } - return { - expectedFilterCount, - uploadMs, - allReadyMs: performance.now() - allReadyStartedAt, - }; - } catch (error) { - lastError = error; - if (Date.now() >= deadline) { - throw new Error( - `timed out seeding server doc ${docId} within ${SERVER_SEED_READY_TIMEOUT_MS}ms` + - (lastError ? `: ${String(lastError)}` : ''), - ); - } - await sleep(SERVER_READY_POLL_MS); - } finally { - detach(); - await connection.close(); } + await sleep(SERVER_READY_POLL_MS); + } finally { + detach(); + await connection.close(); + } + } +} + +async function seedServerState( + runtime: SyncBenchTargetRuntime, + bench: BuiltSyncBench, + docId: string, + ops: Operation[], + filter: Filter, + maxOpsPerBatch?: number, +): Promise { + if (runtime.primeBalancedFanoutDocForTests && bench.serverPrime?.kind === 'balanced-fanout') { + return await seedPrimedBalancedServerState(runtime, bench, docId); + } + + if (ops.length === 0) { + return { + expectedFilterCount: 0, + uploadMs: 0, + allReadyMs: 0, + }; + } + + const { seedDb, seedBackend, expectedFilterCount } = await createSeedBackendForServerState( + docId, + ops, + filter, + ); + try { + if (runtime.seedOps) { + return await seedServerStateViaDirectOps(runtime, docId, ops, expectedFilterCount); } - throw new Error(`failed to seed server doc ${docId}: unexpected retry loop exit`); + return await seedServerStateViaSyncPeerUpload( + runtime, + docId, + seedBackend, + ops.length, + expectedFilterCount, + maxOpsPerBatch, + ); } finally { seedDb.close(); } } -function canReuseServerFixture( - runtime: SyncBenchTargetRuntime, - bench: ReturnType, -): boolean { +function canReuseServerFixture(runtime: SyncBenchTargetRuntime, bench: BuiltSyncBench): boolean { + if (runtime.primeBalancedFanoutDocForTests && bench.serverPrime) { + return true; + } // Reuse is safe when every client-side starting op is already present on the // server fixture, so samples only read from the shared doc and never mutate it. const serverOpIds = new Set( @@ -1604,6 +1794,22 @@ function canReuseServerFixture( ); } +function expectedServerStateForBench(bench: BuiltSyncBench): { + opCount: number; + maxLamport: number; +} { + if (bench.serverPrime) { + return { + opCount: bench.serverPrime.expectedServerOpCount, + maxLamport: bench.serverPrime.expectedServerMaxLamport, + }; + } + return { + opCount: bench.opsB.length, + maxLamport: maxLamport(bench.opsB), + }; +} + function updateFixtureHashWithBytes( hash: ReturnType, value: Uint8Array | null | undefined, @@ -1627,7 +1833,36 @@ function updateFixtureHashWithNumber(hash: ReturnType, value: hash.update(`${value};`); } -function createServerFixtureCacheKey(bench: ReturnType): string { +function updateFixtureHashWithOperation(hash: ReturnType, op: Operation): void { + updateFixtureHashWithBytes(hash, op.meta.id.replica); + updateFixtureHashWithNumber(hash, op.meta.id.counter); + updateFixtureHashWithNumber(hash, op.meta.lamport); + updateFixtureHashWithBytes(hash, op.meta.knownState); + updateFixtureHashWithString(hash, op.kind.type); + switch (op.kind.type) { + case 'insert': + updateFixtureHashWithString(hash, op.kind.parent); + updateFixtureHashWithString(hash, op.kind.node); + updateFixtureHashWithBytes(hash, op.kind.orderKey); + updateFixtureHashWithBytes(hash, op.kind.payload); + break; + case 'move': + updateFixtureHashWithString(hash, op.kind.node); + updateFixtureHashWithString(hash, op.kind.newParent); + updateFixtureHashWithBytes(hash, op.kind.orderKey); + break; + case 'delete': + case 'tombstone': + updateFixtureHashWithString(hash, op.kind.node); + break; + case 'payload': + updateFixtureHashWithString(hash, op.kind.node); + updateFixtureHashWithBytes(hash, op.kind.payload); + break; + } +} + +function createServerFixtureCacheKey(bench: BuiltSyncBench): string { const hash = createHash('sha256'); updateFixtureHashWithString(hash, SYNC_BENCH_SERVER_FIXTURE_CACHE_VERSION); updateFixtureHashWithString(hash, bench.name); @@ -1637,37 +1872,107 @@ function createServerFixtureCacheKey(bench: ReturnType, + runtime?: SyncBenchTargetRuntime | null, +) { + return buildSyncBenchCase({ + workload: benchCase.workload, + size: benchCase.size, + fanout: benchCase.fanout, + materializeServerOps: shouldMaterializeServerOps(runtime, benchCase.target, benchCase.workload), + }); +} + +function serverLabelForTarget(target: SyncBenchTargetId): 'postgres-local' | 'remote' | 'none' { + if (target === 'local-postgres-sync-server') return 'postgres-local'; + if (target === 'remote-sync-server') return 'remote'; + return 'none'; +} + +function serverProcessLabelForTarget( + target: SyncBenchTargetId, + runtime?: SyncBenchTargetRuntime | null, +): SyncBenchTargetRuntime['serverProcess'] | 'none' | 'unknown' { + return runtime?.serverProcess ?? (target === 'direct' ? 'none' : 'unknown'); +} + +function buildBenchTargetExtra( + benchCase: Pick, + runtime?: SyncBenchTargetRuntime | null, +): Record { + return { + count: benchCase.size, + fanout: benchCase.fanout, + mode: benchCase.target, + target: benchCase.target, + server: serverLabelForTarget(benchCase.target), + serverProcess: serverProcessLabelForTarget(benchCase.target, runtime), + }; +} + +function buildPreparedFixtureExtra( + preparedFixture: PreparedServerFixture | undefined, + cacheMode: ServerFixtureCacheMode, +): Record { + return { + serverFixtureReuse: preparedFixture ? 'per-case' : undefined, + serverFixtureCacheMode: + preparedFixture && cacheMode !== DEFAULT_SERVER_FIXTURE_CACHE_MODE ? cacheMode : undefined, + serverFixtureCacheStatus: + preparedFixture && preparedFixture.cacheStatus !== 'disabled' + ? preparedFixture.cacheStatus + : undefined, + serverFixtureCacheKey: preparedFixture?.cacheKey, + serverFixturePrimeMode: preparedFixture?.primeMode, + }; +} + function fixtureCacheScopeForRuntime(runtime: SyncBenchTargetRuntime): string { return runtime.fixtureCacheScope ?? runtime.id; } @@ -1735,12 +2040,13 @@ async function writeServerFixtureManifest( async function prepareServerFixture( runtime: SyncBenchTargetRuntime, - bench: ReturnType, + bench: BuiltSyncBench, directSendThreshold: number, cacheMode: ServerFixtureCacheMode, maxOpsPerBatch?: number, ): Promise { const prepareStartedAt = performance.now(); + const expectedServerState = expectedServerStateForBench(bench); const cacheKey = cacheMode === 'off' ? undefined : createServerFixtureCacheKey(bench); const hasResettableFixture = runtime.resetDoc != null; const manifestDocId = @@ -1758,11 +2064,15 @@ async function prepareServerFixture( if (cacheMode === 'reuse' && runtime.inspectDoc) { try { const current = await runtime.inspectDoc(docId); - if (current.allCount === bench.opsB.length && current.maxLamport === maxLamport(bench.opsB)) { + if ( + current.allCount === expectedServerState.opCount && + current.maxLamport === expectedServerState.maxLamport + ) { return { docId, cacheKey, cacheStatus: 'hit', + primeMode: serverPrimeMode(bench), }; } } catch { @@ -1774,6 +2084,7 @@ async function prepareServerFixture( docId, cacheKey, cacheStatus: manifestDocId != null ? 'manifest' : 'assumed', + primeMode: serverPrimeMode(bench), }; } if (cacheMode !== 'off') { @@ -1781,6 +2092,7 @@ async function prepareServerFixture( } const seedState = await seedServerState( runtime, + bench, docId, bench.opsB, bench.filter as Filter, @@ -1810,6 +2122,7 @@ async function prepareServerFixture( docId, cacheKey, cacheStatus: cacheMode === 'rebuild' ? 'rebuild' : cacheMode === 'reuse' ? 'miss' : 'disabled', + primeMode: serverPrimeMode(bench), seedUploadMs: seedState.uploadMs, seedAllReadyMs: seedState.allReadyMs, filterReadyMs, @@ -2017,15 +2330,16 @@ async function runBenchOnceViaServer( try { await appendInitialOps(client.db, bench.opsA); if (!preparedFixture) { - const expectedFilterCount = await seedServerState( + const seedState = await seedServerState( runtime, + bench, docId, bench.opsB, bench.filter as Filter, maxOpsPerBatch, ); if (runtime.waitForOpCount) { - await runtime.waitForOpCount(docId, bench.filter as Filter, expectedFilterCount, { + await runtime.waitForOpCount(docId, bench.filter as Filter, seedState.expectedFilterCount, { timeoutMs: SERVER_READY_TIMEOUT_MS, }); } else { @@ -2033,7 +2347,7 @@ async function runBenchOnceViaServer( runtime, docId, bench.filter as Filter, - expectedFilterCount, + seedState.expectedFilterCount, directSendThreshold, maxOpsPerBatch, SERVER_SEED_READY_TIMEOUT_MS, @@ -2110,6 +2424,52 @@ async function runBenchOnceViaServer( } } +async function runBenchSample( + repoRoot: string, + benchCase: BenchCase, + bench: BuiltSyncBench, + opts: { + runtime?: SyncBenchTargetRuntime; + includeFirstView: boolean; + profileBackend: boolean; + profileTransport: boolean; + profileHello: boolean; + directSendThreshold: number; + maxOpsPerBatch?: number; + postSeedWaitMs: number; + preparedFixture?: PreparedServerFixture; + }, +): Promise { + if (opts.runtime) { + return await runBenchOnceViaServer( + repoRoot, + opts.runtime, + benchCase, + bench, + opts.includeFirstView, + opts.profileBackend, + opts.profileTransport, + opts.profileHello, + opts.directSendThreshold, + opts.maxOpsPerBatch, + opts.postSeedWaitMs, + opts.preparedFixture, + ); + } + + return await runBenchOnceDirect( + repoRoot, + benchCase, + bench, + opts.includeFirstView, + opts.profileBackend, + opts.profileTransport, + opts.profileHello, + opts.directSendThreshold, + opts.maxOpsPerBatch, + ); +} + async function runBenchCase( repoRoot: string, benchCase: BenchCase, @@ -2123,17 +2483,12 @@ async function runBenchCase( postSeedWaitMs: number, maxOpsPerBatch?: number, ): Promise { - const bench = buildSyncBenchCase({ - workload: benchCase.workload, - size: benchCase.size, - fanout: benchCase.fanout, - }); - const { iterations, warmupIterations } = benchCase; - const runtime = benchCase.target === 'direct' ? null : runtimes.get(benchCase.target); if (benchCase.target !== 'direct' && !runtime) { throw new Error(`missing runtime for sync bench target ${benchCase.target}`); } + const bench = buildBenchForCase(benchCase, runtime); + const { iterations, warmupIterations } = benchCase; if (includeFirstView && !bench.firstView) { throw new Error(`sync bench workload ${bench.name} does not support --first-view`); @@ -2148,68 +2503,25 @@ async function runBenchCase( maxOpsPerBatch, ) : undefined; + const sampleOpts = { + runtime: runtime ?? undefined, + includeFirstView, + profileBackend, + profileTransport, + profileHello, + directSendThreshold, + maxOpsPerBatch, + postSeedWaitMs, + preparedFixture, + }; for (let i = 0; i < warmupIterations; i += 1) { - if (runtime) { - await runBenchOnceViaServer( - repoRoot, - runtime, - benchCase, - bench, - includeFirstView, - profileBackend, - profileTransport, - profileHello, - directSendThreshold, - maxOpsPerBatch, - postSeedWaitMs, - preparedFixture, - ); - } else { - await runBenchOnceDirect( - repoRoot, - benchCase, - bench, - includeFirstView, - profileBackend, - profileTransport, - profileHello, - directSendThreshold, - maxOpsPerBatch, - ); - } + await runBenchSample(repoRoot, benchCase, bench, sampleOpts); } const samples: SyncBenchSample[] = []; for (let i = 0; i < iterations; i += 1) { - samples.push( - runtime - ? await runBenchOnceViaServer( - repoRoot, - runtime, - benchCase, - bench, - includeFirstView, - profileBackend, - profileTransport, - profileHello, - directSendThreshold, - maxOpsPerBatch, - postSeedWaitMs, - preparedFixture, - ) - : await runBenchOnceDirect( - repoRoot, - benchCase, - bench, - includeFirstView, - profileBackend, - profileTransport, - profileHello, - directSendThreshold, - maxOpsPerBatch, - ), - ); + samples.push(await runBenchSample(repoRoot, benchCase, bench, sampleOpts)); } const totalSamplesMs = samples.map((sample) => sample.totalMs); @@ -2234,18 +2546,8 @@ async function runBenchCase( opsPerSec, extra: { ...bench.extra, - count: benchCase.size, - fanout: benchCase.fanout, - mode: benchCase.target, - target: benchCase.target, + ...buildBenchTargetExtra(benchCase, runtime), transport: benchCase.target === 'direct' ? 'in-memory' : 'websocket', - server: - benchCase.target === 'local-postgres-sync-server' - ? 'postgres-local' - : benchCase.target === 'remote-sync-server' - ? 'remote' - : 'none', - serverProcess: runtime?.serverProcess ?? (benchCase.target === 'direct' ? 'none' : 'unknown'), measurement: includeFirstView ? 'time-to-first-view' : 'sync-only', backendProfile: profileBackend ? backendProfiles.at(-1) : undefined, backendProfileSamples: @@ -2264,16 +2566,7 @@ async function runBenchCase( directSendThreshold: directSendThreshold > 0 ? directSendThreshold : undefined, maxOpsPerBatch, postSeedWaitMs: postSeedWaitMs > 0 ? postSeedWaitMs : undefined, - serverFixtureReuse: preparedFixture ? 'per-case' : undefined, - serverFixtureCacheMode: - preparedFixture && serverFixtureCacheMode !== DEFAULT_SERVER_FIXTURE_CACHE_MODE - ? serverFixtureCacheMode - : undefined, - serverFixtureCacheStatus: - preparedFixture && preparedFixture.cacheStatus !== 'disabled' - ? preparedFixture.cacheStatus - : undefined, - serverFixtureCacheKey: preparedFixture?.cacheKey, + ...buildPreparedFixtureExtra(preparedFixture, serverFixtureCacheMode), iterations: iterations > 1 ? iterations : undefined, warmupIterations: warmupIterations > 0 ? warmupIterations : undefined, avgDurationMs: iterations > 1 ? durationMs : undefined, @@ -2296,11 +2589,6 @@ async function primeServerFixtureCase( serverFixtureCacheMode: ServerFixtureCacheMode, maxOpsPerBatch?: number, ): Promise { - const bench = buildSyncBenchCase({ - workload: benchCase.workload, - size: benchCase.size, - fanout: benchCase.fanout, - }); const runtime = benchCase.target === 'direct' ? null : runtimes.get(benchCase.target); if (benchCase.target !== 'direct' && !runtime) { throw new Error(`missing runtime for sync bench target ${benchCase.target}`); @@ -2308,6 +2596,7 @@ async function primeServerFixtureCase( if (!runtime) { throw new Error('server fixture priming requires a sync-server target'); } + const bench = buildBenchForCase(benchCase, runtime); if (!canReuseServerFixture(runtime, bench)) { throw new Error( `sync bench workload ${bench.name} does not support reusable sync-server fixtures`, @@ -2323,7 +2612,8 @@ async function primeServerFixtureCase( maxOpsPerBatch, ); const durationMs = performance.now() - startedAt; - const totalOps = bench.opsB.length; + const expectedServerState = expectedServerStateForBench(bench); + const totalOps = expectedServerState.opCount; const opsPerSec = durationMs > 0 ? (totalOps / durationMs) * 1000 : Infinity; return { name: `${bench.name}-server-fixture`, @@ -2332,31 +2622,18 @@ async function primeServerFixtureCase( opsPerSec, extra: { ...bench.extra, - count: benchCase.size, - fanout: benchCase.fanout, - mode: benchCase.target, - target: benchCase.target, - server: - benchCase.target === 'local-postgres-sync-server' - ? 'postgres-local' - : benchCase.target === 'remote-sync-server' - ? 'remote' - : 'none', - serverProcess: runtime.serverProcess, + ...buildBenchTargetExtra(benchCase, runtime), measurement: 'server-fixture-prime', directSendThreshold: directSendThreshold > 0 ? directSendThreshold : undefined, maxOpsPerBatch, - serverFixtureReuse: 'per-case', - serverFixtureCacheMode, - serverFixtureCacheStatus: preparedFixture.cacheStatus, - serverFixtureCacheKey: preparedFixture.cacheKey, + ...buildPreparedFixtureExtra(preparedFixture, serverFixtureCacheMode), docId: preparedFixture.docId, seedUploadMs: preparedFixture.seedUploadMs, seedAllReadyMs: preparedFixture.seedAllReadyMs, filterReadyMs: preparedFixture.filterReadyMs, totalPrepareMs: preparedFixture.totalPrepareMs, fixtureOpCount: totalOps, - fixtureMaxLamport: maxLamport(bench.opsB), + fixtureMaxLamport: expectedServerState.maxLamport, }, }; } diff --git a/packages/treecrdt-sqlite-node/scripts/instrumented-postgres-backend-module.mjs b/packages/treecrdt-sqlite-node/scripts/instrumented-postgres-backend-module.mjs index a353f004..7e4e358e 100644 --- a/packages/treecrdt-sqlite-node/scripts/instrumented-postgres-backend-module.mjs +++ b/packages/treecrdt-sqlite-node/scripts/instrumented-postgres-backend-module.mjs @@ -1,4 +1,4 @@ -import { createPostgresNapiSyncBackendFactory as createBaseFactory } from "@treecrdt/postgres-napi"; +import { createPostgresNapiTestSyncBackendFactory as createBaseFactory } from "@treecrdt/postgres-napi/testing"; import { wrapBackendWithProfiler } from "./backend-profiler.mjs"; diff --git a/scripts/bench-discovery-connect.mjs b/scripts/bench-discovery-connect.mjs index 912f76e0..f4cf49de 100644 --- a/scripts/bench-discovery-connect.mjs +++ b/scripts/bench-discovery-connect.mjs @@ -6,6 +6,10 @@ import process from 'node:process'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { performance } from 'node:perf_hooks'; +import { + medianOrNull, + percentileNearestRankOrNull, +} from '../packages/treecrdt-benchmark/dist/stats.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); @@ -55,20 +59,6 @@ function parseHostMap(raw) { return new Map(entries); } -function median(values) { - if (values.length === 0) return null; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; -} - -function percentile(values, p) { - if (values.length === 0) return null; - const sorted = [...values].sort((a, b) => a - b); - const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); - return sorted[index]; -} - function normalizeBootstrapUrl(raw) { if (!raw || raw.trim().length === 0) throw new Error('missing bootstrap url'); let input = raw.trim(); @@ -286,12 +276,12 @@ async function main() { connectSamplesMs, totalSamplesMs, cachedConnectSamplesMs, - resolveMedianMs: median(resolveSamplesMs), - connectMedianMs: median(connectSamplesMs), - totalMedianMs: median(totalSamplesMs), - cachedConnectMedianMs: median(cachedConnectSamplesMs), - totalP95Ms: percentile(totalSamplesMs, 95), - cachedConnectP95Ms: percentile(cachedConnectSamplesMs, 95), + resolveMedianMs: medianOrNull(resolveSamplesMs), + connectMedianMs: medianOrNull(connectSamplesMs), + totalMedianMs: medianOrNull(totalSamplesMs), + cachedConnectMedianMs: medianOrNull(cachedConnectSamplesMs), + totalP95Ms: percentileNearestRankOrNull(totalSamplesMs, 95), + cachedConnectP95Ms: percentileNearestRankOrNull(cachedConnectSamplesMs, 95), generatedAt: new Date().toISOString(), }; diff --git a/scripts/bench-playground-live-write.mjs b/scripts/bench-playground-live-write.mjs index c6a3cda0..8cd200aa 100644 --- a/scripts/bench-playground-live-write.mjs +++ b/scripts/bench-playground-live-write.mjs @@ -1,17 +1,25 @@ -import { spawn } from "node:child_process"; -import { createRequire } from "node:module"; -import { performance } from "node:perf_hooks"; -import path from "node:path"; -import process from "node:process"; -import { fileURLToPath } from "node:url"; -import fs from "node:fs/promises"; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { performance } from 'node:perf_hooks'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import fs from 'node:fs/promises'; +import { buildFanoutInsertTreeOps, replicaFromLabel } from '../packages/treecrdt-benchmark/dist/index.js'; +import { summarizeSamples } from '../packages/treecrdt-benchmark/dist/stats.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(__dirname, ".."); -const playgroundDir = path.join(repoRoot, "examples", "playground"); -const ROOT_ID = "00000000000000000000000000000000"; -const playgroundRequire = createRequire(path.join(playgroundDir, "package.json")); -const { chromium } = playgroundRequire("@playwright/test"); +const repoRoot = path.resolve(__dirname, '..'); +const playgroundDir = path.join(repoRoot, 'examples', 'playground'); +const ROOT_ID = '00000000000000000000000000000000'; +const PLAYGROUND_LIVE_WRITE_FIXTURE_VERSION = '2026-03-30-v1'; +const playgroundRequire = createRequire(path.join(playgroundDir, 'package.json')); +const { chromium } = playgroundRequire('@playwright/test'); +const BENCHMARK_BROWSER_ARGS = [ + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', +]; function usage() { console.log(`Usage: @@ -20,14 +28,18 @@ function usage() { Options: --base-url=http://127.0.0.1:5195 Playground base URL (default: env TREECRDT_PLAYGROUND_BASE_URL or http://127.0.0.1:5195) --transport=local|remote Sync transport to benchmark (default: remote) - --sync-server-url=... Sync server websocket URL (required for --transport=remote; default: env TREECRDT_SYNC_SERVER_URL) + --sync-server-url=... Sync server websocket URL or HTTPS bootstrap URL (required for --transport=remote; default: env TREECRDT_SYNC_SERVER_URL) --mode=all|children Live mode to benchmark (default: all) --iterations=N Measured samples (default: 5) --warmup=N Warmup samples (default: 1) --tabs=2|3 Number of tabs/devices (default: 3) --auth=0|1 Enable playground auth (default: 1) --headless=0|1 Launch browser headless (default: 1) + --host-map=host=ip[,host=ip...] Optional hostname override(s) for browser resolution --label-prefix=foo Label prefix for inserted nodes + --seed-count=N Optional number of existing nodes to preseed into a Postgres-backed remote doc before the browser opens + --seed-fanout=N Fanout for seeded balanced trees (default: 10) + --postgres-url=... Postgres URL used to reset/cleanup seeded remote docs (required when --seed-count is set) --out=path.json Optional output path; defaults under benchmarks/playground-live-write/ `); } @@ -50,18 +62,159 @@ function parseIntArg(name, defaultValue) { return parsed; } +function parsePositiveIntFlagFromArgv(name, defaultValue) { + const raw = getArg(name); + if (raw == null || raw.length === 0) return defaultValue; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`invalid --${name}: ${raw}`); + return parsed; +} + function parseBoolArg(name, defaultValue) { const raw = getArg(name); if (raw == null || raw.length === 0) return defaultValue; - if (raw === "1" || raw === "true") return true; - if (raw === "0" || raw === "false") return false; + if (raw === '1' || raw === 'true') return true; + if (raw === '0' || raw === 'false') return false; throw new Error(`invalid --${name}: ${raw}`); } +function parseHostMap(raw) { + if (!raw || raw.trim().length === 0) return new Map(); + const entries = raw + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [host, ip] = entry.split('=', 2).map((part) => part?.trim() ?? ''); + if (!host || !ip) throw new Error(`invalid host map entry: ${entry}`); + return [host.toLowerCase(), ip]; + }); + return new Map(entries); +} + +function buildHostResolverRules(hostMap) { + if (hostMap.size === 0) return []; + const rules = [...hostMap.entries()].map(([host, ip]) => `MAP ${host} ${ip}`); + return [`--host-resolver-rules=${rules.join(',')}`]; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function buildSeedOps({ size, fanout }) { + return buildFanoutInsertTreeOps({ + replica: replicaFromLabel('playground-seed'), + size, + fanout, + root: ROOT_ID, + }); +} + +let postgresSeedApiPromise = null; +let syncSeedApiPromise = null; +let syncPostgresSeedApiPromise = null; + +async function loadPostgresSeedApi() { + if (!postgresSeedApiPromise) { + const modulePath = path.join( + repoRoot, + 'packages', + 'treecrdt-postgres-napi', + 'dist', + 'testing.js', + ); + postgresSeedApiPromise = import(pathToFileURL(modulePath).href).catch((error) => { + throw new Error( + `failed to load Postgres seeding helpers from ${modulePath}; build @treecrdt/postgres-napi first: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + return await postgresSeedApiPromise; +} + +async function loadSyncSeedApi() { + if (!syncSeedApiPromise) { + const syncIndexPath = path.join(repoRoot, 'packages', 'sync', 'protocol', 'dist', 'index.js'); + const syncBrowserPath = path.join( + repoRoot, + 'packages', + 'sync', + 'protocol', + 'dist', + 'browser.js', + ); + const syncInMemoryPath = path.join( + repoRoot, + 'packages', + 'sync', + 'protocol', + 'dist', + 'in-memory.js', + ); + const syncTransportPath = path.join( + repoRoot, + 'packages', + 'sync', + 'protocol', + 'dist', + 'transport', + 'index.js', + ); + const syncProtobufPath = path.join( + repoRoot, + 'packages', + 'sync', + 'protocol', + 'dist', + 'protobuf.js', + ); + + syncSeedApiPromise = Promise.all([ + import(pathToFileURL(syncIndexPath).href), + import(pathToFileURL(syncBrowserPath).href), + import(pathToFileURL(syncInMemoryPath).href), + import(pathToFileURL(syncTransportPath).href), + import(pathToFileURL(syncProtobufPath).href), + ]).catch((error) => { + throw new Error( + `failed to load sync seeding helpers; build @treecrdt/sync first: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + + const [syncIndex, syncBrowser, syncInMemory, syncTransport, syncProtobuf] = + await syncSeedApiPromise; + return { + SyncPeer: syncIndex.SyncPeer, + deriveOpRefV0: syncIndex.deriveOpRefV0, + createBrowserWebSocketTransport: syncBrowser.createBrowserWebSocketTransport, + makeQueuedSyncBackend: syncInMemory.makeQueuedSyncBackend, + wrapDuplexTransportWithCodec: syncTransport.wrapDuplexTransportWithCodec, + treecrdtSyncV0ProtobufCodec: syncProtobuf.treecrdtSyncV0ProtobufCodec, + }; +} + +async function loadSyncPostgresSeedApi() { + if (!syncPostgresSeedApiPromise) { + const modulePath = path.join( + repoRoot, + 'packages', + 'sync', + 'material', + 'postgres', + 'dist', + 'index.js', + ); + syncPostgresSeedApiPromise = import(pathToFileURL(modulePath).href).catch((error) => { + throw new Error( + `failed to load Postgres sync proof-material helpers from ${modulePath}; build @treecrdt/sync-postgres first: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + return await syncPostgresSeedApiPromise; +} + async function canFetch(url) { try { const res = await fetch(url); @@ -82,14 +235,14 @@ async function waitForHttp(url, timeoutMs = 60_000) { function isLocalhostUrl(baseUrl) { const parsed = new URL(baseUrl); - return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost"; + return parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost'; } async function maybeStartPlaygroundServer(baseUrl) { const healthUrl = new URL(baseUrl); - healthUrl.pathname = "/"; - healthUrl.search = ""; - healthUrl.hash = ""; + healthUrl.pathname = '/'; + healthUrl.search = ''; + healthUrl.hash = ''; const readyUrl = healthUrl.toString(); if (await canFetch(readyUrl)) return { child: null, started: false }; @@ -98,38 +251,42 @@ async function maybeStartPlaygroundServer(baseUrl) { } const parsed = new URL(baseUrl); - const port = parsed.port || "5195"; - const child = spawn("pnpm", ["exec", "vite", "--host", parsed.hostname, "--port", port, "--strictPort"], { - cwd: playgroundDir, - stdio: ["ignore", "pipe", "pipe"], - env: process.env, - }); + const port = parsed.port || '5195'; + const child = spawn( + 'pnpm', + ['exec', 'vite', '--host', parsed.hostname, '--port', port, '--strictPort'], + { + cwd: playgroundDir, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + }, + ); - child.stdout?.on("data", (chunk) => process.stdout.write(`[playground] ${chunk}`)); - child.stderr?.on("data", (chunk) => process.stderr.write(`[playground] ${chunk}`)); + child.stdout?.on('data', (chunk) => process.stdout.write(`[playground] ${chunk}`)); + child.stderr?.on('data', (chunk) => process.stderr.write(`[playground] ${chunk}`)); try { await waitForHttp(readyUrl, 90_000); return { child, started: true }; } catch (err) { - child.kill("SIGINT"); + child.kill('SIGINT'); throw err; } } async function stopChild(child) { if (!child) return; - child.kill("SIGINT"); - await new Promise((resolve) => child.once("exit", () => resolve())); + child.kill('SIGINT'); + await new Promise((resolve) => child.once('exit', () => resolve())); } async function waitReady(page) { - await page.getByText("Ready (memory)").waitFor({ timeout: 60_000 }); - const show = page.getByRole("button", { name: "Show", exact: true }); + await page.getByText('Ready (memory)').waitFor({ timeout: 60_000 }); + const show = page.getByRole('button', { name: 'Show', exact: true }); if ((await show.count()) > 0) await show.click(); - const newDevice = page.getByRole("button", { name: /New device/ }); - const addChild = rootRow(page).getByRole("button", { name: "Add child" }); + const newDevice = page.getByRole('button', { name: /New device/ }); + const addChild = rootRow(page).getByRole('button', { name: 'Add child' }); const deadline = Date.now() + 60_000; while (Date.now() < deadline) { if ((await newDevice.isEnabled()) && (await addChild.isEnabled())) return; @@ -139,12 +296,12 @@ async function waitReady(page) { } async function waitRemoteConnection(page) { - const connectionsButton = page.getByRole("button", { name: /Connections/ }); + const connectionsButton = page.getByRole('button', { name: /Connections/ }); const deadline = Date.now() + 60_000; while (Date.now() < deadline) { - const text = (await connectionsButton.textContent()) ?? ""; + const text = (await connectionsButton.textContent()) ?? ''; const countMatch = text.match(/Connections\s*(\d+)/i); - if (countMatch && Number.parseInt(countMatch[1] ?? "0", 10) >= 1) return; + if (countMatch && Number.parseInt(countMatch[1] ?? '0', 10) >= 1) return; const syncError = await readSyncError(page); if (syncError) throw new Error(`sync error before connection: ${syncError}`); await sleep(250); @@ -158,18 +315,18 @@ function rootRow(page) { async function clickAndAssertPressed(button) { await button.click(); - await button.waitFor({ state: "visible", timeout: 30_000 }); + await button.waitFor({ state: 'visible', timeout: 30_000 }); await sleep(150); } async function enableLiveMode(page, mode) { - if (mode === "all") { - const button = page.getByRole("button", { name: "Live sync all" }); + if (mode === 'all') { + const button = page.getByRole('button', { name: 'Live sync all' }); await clickAndAssertPressed(button); return; } - if (mode === "children") { - const button = rootRow(page).getByRole("button", { name: "Live sync children" }); + if (mode === 'children') { + const button = rootRow(page).getByRole('button', { name: 'Live sync children' }); await clickAndAssertPressed(button); return; } @@ -177,17 +334,17 @@ async function enableLiveMode(page, mode) { } async function readSyncError(page) { - const syncError = page.getByTestId("sync-error"); + const syncError = page.getByTestId('sync-error'); if ((await syncError.count()) === 0) return null; if (!(await syncError.isVisible())) return null; - return ((await syncError.textContent()) ?? "").trim(); + return ((await syncError.textContent()) ?? '').trim(); } async function openNewDevice(page, opts = {}) { const { waitForRemote = false } = opts; const [popup] = await Promise.all([ - page.waitForEvent("popup", { timeout: 30_000 }), - page.getByRole("button", { name: /New device/ }).click(), + page.waitForEvent('popup', { timeout: 30_000 }), + page.getByRole('button', { name: /New device/ }).click(), ]); await waitReady(popup); if (waitForRemote) await waitRemoteConnection(popup); @@ -195,139 +352,554 @@ async function openNewDevice(page, opts = {}) { } async function addNode(page, label) { - const input = page.getByPlaceholder("Stored as payload bytes"); + const input = page.getByPlaceholder('Stored as payload bytes'); await input.fill(label); - await rootRow(page).getByRole("button", { name: "Add child" }).click(); - await page.getByTestId("tree-row").filter({ hasText: label }).first().waitFor({ timeout: 30_000 }); + await rootRow(page).getByRole('button', { name: 'Add child' }).click(); + const row = page.getByTestId('tree-row').filter({ hasText: label }).first(); + await row.waitFor({ state: 'visible', timeout: 30_000 }); + const nodeId = await row.getAttribute('data-node-id'); + if (!nodeId) throw new Error(`missing node id for inserted label "${label}"`); + return { nodeId }; +} + +async function resetBenchState(page) { + await page.evaluate(() => { + window.__treecrdtPlaygroundBench = { nodes: {} }; + }); +} + +async function readBenchNodeTiming(page, nodeId) { + return await page.evaluate((id) => window.__treecrdtPlaygroundBench?.nodes?.[id] ?? null, nodeId); +} + +async function waitForNode(page, nodeId, timeoutMs, startedAtMs) { + const handle = await page.waitForFunction( + ({ targetNodeId }) => { + const syncError = document.querySelector('[data-testid="sync-error"]'); + const syncErrorText = syncError?.textContent?.trim(); + if (syncErrorText) return { ok: false, error: syncErrorText }; + const row = document.querySelector( + `[data-testid="tree-row"][data-node-id="${targetNodeId}"]`, + ); + return row ? { ok: true } : null; + }, + { targetNodeId: nodeId }, + { timeout: timeoutMs }, + ); + const result = await handle.jsonValue(); + await handle.dispose(); + if (!result?.ok) { + throw new Error( + `sync error on ${page.url()}: ${result?.error ?? `timed out waiting for node ${nodeId}`}`, + ); + } + const durationMs = performance.now() - startedAtMs; + const benchTiming = await readBenchNodeTiming(page, nodeId); + return { durationMs, benchTiming }; +} + +async function waitForVisibleRowCount(page, minCount, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const syncError = await readSyncError(page); + if (syncError) throw new Error(`sync error before visible rows: ${syncError}`); + const count = await page.getByTestId('tree-row').count(); + if (count >= minCount) return; + await sleep(250); + } + throw new Error(`timed out waiting for at least ${minCount} visible rows on ${page.url()}`); } -async function waitForLabel(page, label, timeoutMs, startedAtMs) { - const row = page.getByTestId("tree-row").filter({ hasText: label }).first(); +async function seedBalancedTreeInBrowser(page, { count, fanout }) { + await page.evaluate( + async ({ seedCount, seedFanout }) => { + const bench = window.__treecrdtPlaygroundBench; + if (!bench?.seedBalancedTree) { + throw new Error('playground bench seedBalancedTree hook is not available'); + } + await bench.seedBalancedTree({ count: seedCount, fanout: seedFanout }); + }, + { seedCount: count, seedFanout: fanout }, + ); +} + +async function waitForBenchHeadLamport(page, minHeadLamport, timeoutMs = 120_000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const syncError = await readSyncError(page); - if (syncError) throw new Error(`sync error on ${page.url()}: ${syncError}`); - if ((await row.count()) > 0) return performance.now() - startedAtMs; - await sleep(50); + if (syncError) + throw new Error(`sync error before head lamport ${minHeadLamport}: ${syncError}`); + const state = await page.evaluate(() => window.__treecrdtPlaygroundBench?.getState?.() ?? null); + if (state && typeof state.headLamport === 'number' && state.headLamport >= minHeadLamport) { + return; + } + await sleep(250); + } + throw new Error(`timed out waiting for head lamport ${minHeadLamport} on ${page.url()}`); +} + +async function waitForBenchIdle(page, { settleMs = 1_000, timeoutMs = 120_000 } = {}) { + const deadline = Date.now() + timeoutMs; + let idleStartedAt = null; + while (Date.now() < deadline) { + const syncError = await readSyncError(page); + if (syncError) throw new Error(`sync error before idle state: ${syncError}`); + const state = await page.evaluate(() => window.__treecrdtPlaygroundBench?.getState?.() ?? null); + const isIdle = + state && state.status === 'ready' && state.syncBusy === false && state.liveBusy === false; + if (isIdle) { + if (idleStartedAt == null) idleStartedAt = Date.now(); + if (Date.now() - idleStartedAt >= settleMs) return; + } else { + idleStartedAt = null; + } + await sleep(250); + } + throw new Error(`timed out waiting for idle benchmark state on ${page.url()}`); +} + +async function waitForSeededRemoteDoc(postgresUrl, docId, expectedHeadLamport, timeoutMs = 60_000) { + const { createTreecrdtPostgresClient } = await loadPostgresSeedApi(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const client = await createTreecrdtPostgresClient(postgresUrl, { docId }); + try { + const headLamport = await client.meta.headLamport(); + if (headLamport >= expectedHeadLamport) return; + } finally { + await client.close(); + } + await sleep(250); + } + throw new Error( + `timed out waiting for remote doc ${docId} to reach head lamport ${expectedHeadLamport}`, + ); +} + +function buildSyncWebSocketUrl(baseUrl, docId) { + let input = baseUrl.trim(); + if (input.length === 0) throw new Error('sync server URL is empty'); + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(input)) input = `ws://${input}`; + const url = new URL(input); + if (url.protocol === 'http:') url.protocol = 'ws:'; + if (url.protocol === 'https:') url.protocol = 'wss:'; + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error('sync server URL must use ws://, wss://, http://, or https://'); + } + if (url.pathname === '/' || url.pathname.length === 0) { + url.pathname = '/sync'; + } + url.searchParams.set('docId', docId); + return url; +} + +async function openSeedWebSocket(url, timeoutMs = 10_000) { + const WebSocketCtor = globalThis.WebSocket; + if (typeof WebSocketCtor !== 'function') { + throw new Error('global WebSocket is not available in this Node runtime'); + } + return await new Promise((resolve, reject) => { + const ws = new WebSocketCtor(url.toString()); + ws.binaryType = 'arraybuffer'; + let settled = false; + + const finish = (fn, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + ws.removeEventListener('open', onOpen); + ws.removeEventListener('error', onError); + fn(value); + }; + + const onOpen = () => finish(resolve, ws); + const onError = () => finish(reject, new Error(`failed connecting to ${url.toString()}`)); + const timer = setTimeout(() => { + try { + ws.close(); + } catch { + // ignore + } + finish(reject, new Error(`timed out connecting to ${url.toString()}`)); + }, timeoutMs); + + ws.addEventListener('open', onOpen); + ws.addEventListener('error', onError); + }); +} + +async function closeSeedWebSocket(ws) { + if (ws.readyState === globalThis.WebSocket.CLOSED) return; + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + ws.removeEventListener('close', onClose); + ws.removeEventListener('error', onError); + resolve(); + }; + const onClose = () => finish(); + const onError = () => finish(); + ws.addEventListener('close', onClose); + ws.addEventListener('error', onError); + try { + if (ws.readyState !== globalThis.WebSocket.CLOSING) { + ws.close(); + } + } catch { + finish(); + } + setTimeout(finish, 1_000); + }); +} + +function buildSeedSyncBackend({ docId, ops, deriveOpRefV0, makeQueuedSyncBackend }) { + const opRefs = []; + const opRefHexToOp = new Map(); + for (const op of ops) { + const opRef = deriveOpRefV0(docId, op.meta.id); + const opRefHex = Buffer.from(opRef).toString('hex'); + opRefs.push(opRef); + opRefHexToOp.set(opRefHex, op); } - throw new Error(`timed out waiting for label "${label}" on ${page.url()}`); + + return makeQueuedSyncBackend({ + docId, + initialMaxLamport: ops.reduce((max, op) => Math.max(max, op.meta.lamport), 0), + maxLamportFromOps: (incomingOps) => + incomingOps.reduce((max, op) => Math.max(max, op.meta.lamport), 0), + listOpRefs: async (filter) => { + if (!('all' in filter)) { + throw new Error('seed sync backend only supports { all: {} }'); + } + return opRefs; + }, + getOpsByOpRefs: async (requestedOpRefs) => + requestedOpRefs + .map((opRef) => opRefHexToOp.get(Buffer.from(opRef).toString('hex')) ?? null) + .filter((op) => op != null), + applyOps: async () => {}, + }); } -function percentile(sorted, p) { - if (sorted.length === 0) return null; - const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); - return sorted[index]; +async function preseedPostgresPlaygroundDoc({ postgresUrl, docId, size, fanout }) { + const { createPostgresNapiTestAdapterFactory, createTreecrdtPostgresClient } = + await loadPostgresSeedApi(); + const factory = createPostgresNapiTestAdapterFactory(postgresUrl); + await factory.ensureSchema(); + const hotWriteFixtureDocId = [ + 'hot-write-seed', + PLAYGROUND_LIVE_WRITE_FIXTURE_VERSION, + `fanout${fanout}`, + 'payload32', + String(size), + ].join('-'); + try { + await factory.cloneDocForTests(hotWriteFixtureDocId, docId); + return { + fixtureDocId: hotWriteFixtureDocId, + cleanup: async () => { + if (process.env.PLAYGROUND_LIVE_WRITE_SKIP_DOC_CLEANUP === '1') return; + await factory.resetDocForTests(docId); + }, + }; + } catch { + // Fall back to building a dedicated zero-payload fixture for the playground bench. + } + + const fixtureDocId = [ + 'playground-live-write-seed', + PLAYGROUND_LIVE_WRITE_FIXTURE_VERSION, + `fanout${fanout}`, + String(size), + ].join('-'); + const expectedHeadLamport = size; + const fixtureClient = await createTreecrdtPostgresClient(postgresUrl, { docId: fixtureDocId }); + try { + const [headLamport, nodeCount] = await Promise.all([ + fixtureClient.meta.headLamport(), + fixtureClient.tree.nodeCount(), + ]); + if (headLamport !== expectedHeadLamport || nodeCount !== size) { + await factory.primeBalancedFanoutDocForTests( + fixtureDocId, + size, + fanout, + 0, + 'playground-seed', + ); + } + } finally { + await fixtureClient.close(); + } + await factory.cloneDocForTests(fixtureDocId, docId); + + return { + fixtureDocId, + cleanup: async () => { + if (process.env.PLAYGROUND_LIVE_WRITE_SKIP_DOC_CLEANUP === '1') return; + if (docId !== fixtureDocId) await factory.resetDocForTests(docId); + }, + }; } function summarize(values) { - const sorted = [...values].sort((a, b) => a - b); - const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + if (values.length === 0) return null; + const summary = summarizeSamples(values); + return { + count: summary.count, + minMs: summary.min, + medianMs: summary.median, + p95Ms: summary.p95, + maxMs: summary.max, + meanMs: summary.mean, + }; +} + +function phaseExtrema(entries, key, method) { + const values = entries + .map((entry) => entry?.[key]) + .filter((value) => typeof value === 'number' && Number.isFinite(value)); + if (values.length === 0) return null; + return method === 'min' ? Math.min(...values) : Math.max(...values); +} + +function derivePhaseOffsets(sample) { + const source = sample.sourceBenchOffsetsMs ?? {}; + const targets = Array.isArray(sample.targetBenchOffsetsMs) + ? sample.targetBenchOffsetsMs.filter((entry) => entry && typeof entry === 'object') + : []; + return { - count: sorted.length, - minMs: sorted[0], - medianMs: percentile(sorted, 50), - p95Ms: percentile(sorted, 95), - maxMs: sorted[sorted.length - 1], - meanMs: mean, + sourceLocalPersistedMs: source.sourceLocalPersistedAtMsAfterMs ?? null, + sourceLocalPreviewMs: source.sourceLocalPreviewAppliedAtMsAfterMs ?? null, + sourceRemoteQueuedMs: source.sourceRemoteQueuedAtMsAfterMs ?? null, + sourceRemotePushStartedMs: source.sourceRemotePushStartedAtMsAfterMs ?? null, + sourceRemotePushFinishedMs: source.sourceRemotePushFinishedAtMsAfterMs ?? null, + sourceRowCommittedMs: source.rowCommittedAtMsAfterMs ?? null, + sourceTreeRefreshMs: source.treeRefreshAppliedAtMsAfterMs ?? null, + targetSocketMessageMs: phaseExtrema(targets, 'targetSocketMessageAtMsAfterMs', 'min'), + targetBackendApplyStartedMs: phaseExtrema( + targets, + 'targetBackendApplyStartedAtMsAfterMs', + 'min', + ), + targetBackendApplyFinishedMs: phaseExtrema( + targets, + 'targetBackendApplyFinishedAtMsAfterMs', + 'max', + ), + targetRemoteStartedMs: phaseExtrema(targets, 'remoteOpsAppliedStartedAtMsAfterMs', 'min'), + targetPayloadsRefreshedMs: phaseExtrema(targets, 'payloadsRefreshedAtMsAfterMs', 'max'), + targetRemoteFinishedMs: phaseExtrema(targets, 'remoteOpsAppliedFinishedAtMsAfterMs', 'max'), + targetTreeRefreshMs: phaseExtrema(targets, 'treeRefreshAppliedAtMsAfterMs', 'max'), + targetRowCommittedMs: phaseExtrema(targets, 'rowCommittedAtMsAfterMs', 'max'), }; } -function defaultOutPath({ transport, syncServerUrl, mode, auth, tabs }) { +function summarizePhaseOffsets(samples) { + const phaseKeys = [ + 'sourceLocalPersistedMs', + 'sourceLocalPreviewMs', + 'sourceRemoteQueuedMs', + 'sourceRemotePushStartedMs', + 'sourceRemotePushFinishedMs', + 'sourceRowCommittedMs', + 'sourceTreeRefreshMs', + 'targetSocketMessageMs', + 'targetBackendApplyStartedMs', + 'targetBackendApplyFinishedMs', + 'targetRemoteStartedMs', + 'targetPayloadsRefreshedMs', + 'targetRemoteFinishedMs', + 'targetTreeRefreshMs', + 'targetRowCommittedMs', + ]; + const out = {}; + for (const key of phaseKeys) { + const values = samples + .map((sample) => sample.phaseOffsetsMs?.[key]) + .filter((value) => typeof value === 'number' && Number.isFinite(value)); + if (values.length > 0) out[key] = summarize(values); + } + return out; +} + +function normalizeBenchTiming(benchTiming, startedAtWallClockMs) { + if (!benchTiming) return null; + const out = {}; + for (const [key, value] of Object.entries(benchTiming)) { + if (typeof value !== 'number') continue; + out[`${key}AfterMs`] = value - startedAtWallClockMs; + } + return out; +} + +function defaultOutPath({ transport, syncServerUrl, mode, auth, tabs, seedCount }) { const safeHost = - transport === "local" - ? "local-mesh" - : new URL(syncServerUrl).host.replace(/[^a-z0-9.-]+/gi, "_"); + transport === 'local' + ? 'local-mesh' + : new URL(syncServerUrl).host.replace(/[^a-z0-9.-]+/gi, '_'); + const seedSuffix = seedCount > 0 ? `-seed${seedCount}` : ''; return path.join( repoRoot, - "benchmarks", - "playground-live-write", - `playground-live-write-${safeHost}-${transport}-${mode}-auth${auth ? "1" : "0"}-${tabs}tabs.json` + 'benchmarks', + 'playground-live-write', + `playground-live-write-${safeHost}-${transport}-${mode}${seedSuffix}-auth${auth ? '1' : '0'}-${tabs}tabs.json`, ); } async function main() { - if (hasFlag("help")) { + if (hasFlag('help')) { usage(); return; } - const baseUrl = getArg("base-url") ?? process.env.TREECRDT_PLAYGROUND_BASE_URL ?? "http://127.0.0.1:5195"; - const transport = getArg("transport") ?? "remote"; - const syncServerUrl = getArg("sync-server-url") ?? process.env.TREECRDT_SYNC_SERVER_URL ?? ""; - if (!["local", "remote"].includes(transport)) { + const baseUrl = + getArg('base-url') ?? process.env.TREECRDT_PLAYGROUND_BASE_URL ?? 'http://127.0.0.1:5195'; + const transport = getArg('transport') ?? 'remote'; + const syncServerUrl = getArg('sync-server-url') ?? process.env.TREECRDT_SYNC_SERVER_URL ?? ''; + if (!['local', 'remote'].includes(transport)) { throw new Error(`--transport must be local or remote, got ${transport}`); } - if (transport === "remote" && !syncServerUrl) { - throw new Error("missing --sync-server-url or TREECRDT_SYNC_SERVER_URL"); + if (transport === 'remote' && !syncServerUrl) { + throw new Error('missing --sync-server-url or TREECRDT_SYNC_SERVER_URL'); } - const mode = getArg("mode") ?? "all"; - const iterations = parseIntArg("iterations", 5); - const warmup = parseIntArg("warmup", 1); - const tabs = parseIntArg("tabs", 3); - const auth = parseBoolArg("auth", true); - const headless = parseBoolArg("headless", true); - const labelPrefix = getArg("label-prefix") ?? `pw-live-write-${mode}`; - const outPath = getArg("out") ?? defaultOutPath({ transport, syncServerUrl, mode, auth, tabs }); + const mode = getArg('mode') ?? 'all'; + const iterations = parseIntArg('iterations', 5); + const warmup = parseIntArg('warmup', 1); + const tabs = parseIntArg('tabs', 3); + const auth = parseBoolArg('auth', true); + const headless = parseBoolArg('headless', true); + const seedCount = parseIntArg('seed-count', 0); + const seedFanout = parsePositiveIntFlagFromArgv('seed-fanout', 10); + const postgresUrl = getArg('postgres-url') ?? process.env.TREECRDT_POSTGRES_URL ?? ''; + const hostMap = parseHostMap(getArg('host-map') ?? process.env.TREECRDT_BENCH_HOST_MAP ?? ''); + const labelPrefix = getArg('label-prefix') ?? `pw-live-write-${mode}`; + const outPath = + getArg('out') ?? defaultOutPath({ transport, syncServerUrl, mode, auth, tabs, seedCount }); if (tabs < 2 || tabs > 3) throw new Error(`--tabs must be 2 or 3, got ${tabs}`); - if (!["all", "children"].includes(mode)) throw new Error(`--mode must be all or children, got ${mode}`); + if (!['all', 'children'].includes(mode)) + throw new Error(`--mode must be all or children, got ${mode}`); if (iterations <= 0) throw new Error(`--iterations must be > 0, got ${iterations}`); + if (seedCount > 0 && transport !== 'remote') { + throw new Error('--seed-count currently requires --transport=remote'); + } + const useRemotePreseed = seedCount > 0 && postgresUrl.length > 0; const playground = await maybeStartPlaygroundServer(baseUrl); - const browser = await chromium.launch({ headless }); + const browser = await chromium.launch({ + headless, + args: [...BENCHMARK_BROWSER_ARGS, ...buildHostResolverRules(hostMap)], + }); const context = await browser.newContext(); + let seedFixture = null; try { const docId = `${labelPrefix}-doc-${Date.now()}`; + if (useRemotePreseed) { + seedFixture = await preseedPostgresPlaygroundDoc({ + postgresUrl, + docId, + size: seedCount, + fanout: seedFanout, + }); + } const rootUrl = new URL(baseUrl); - rootUrl.searchParams.set("doc", docId); - rootUrl.searchParams.set("profile", `${labelPrefix}-a`); - rootUrl.searchParams.set("transport", transport); - if (transport === "remote") rootUrl.searchParams.set("sync", syncServerUrl); - rootUrl.searchParams.set("auth", auth ? "1" : "0"); + rootUrl.searchParams.set('doc', docId); + rootUrl.searchParams.set('profile', `${labelPrefix}-a`); + rootUrl.searchParams.set('transport', transport); + if (transport === 'remote') rootUrl.searchParams.set('sync', syncServerUrl); + rootUrl.searchParams.set('auth', auth ? '1' : '0'); const pageA = await context.newPage(); await pageA.goto(rootUrl.toString()); await waitReady(pageA); - if (transport === "remote") await waitRemoteConnection(pageA); + if (transport === 'remote') await waitRemoteConnection(pageA); await enableLiveMode(pageA, mode); + const seedPage = pageA; + const measuredPages = seedCount > 0 && !useRemotePreseed ? [] : [seedPage]; + let openerPage = seedPage; + while (measuredPages.length < tabs) { + const nextPage = await openNewDevice(openerPage, { waitForRemote: transport === 'remote' }); + await enableLiveMode(nextPage, mode); + measuredPages.push(nextPage); + openerPage = nextPage; + } - const pageB = await openNewDevice(pageA, { waitForRemote: transport === "remote" }); - await enableLiveMode(pageB, mode); - - const pages = [pageA, pageB]; - if (tabs === 3) { - const pageC = await openNewDevice(pageB, { waitForRemote: transport === "remote" }); - await enableLiveMode(pageC, mode); - pages.push(pageC); + if (seedCount > 0 && useRemotePreseed) { + if (mode === 'children') { + const expectedVisibleRows = 1 + Math.min(seedFanout, seedCount); + await Promise.all( + measuredPages.map((page) => waitForVisibleRowCount(page, expectedVisibleRows, 180_000)), + ); + } else { + await Promise.all( + measuredPages.map((page) => waitForBenchHeadLamport(page, seedCount, 180_000)), + ); + } + await Promise.all( + measuredPages.map((page) => waitForBenchIdle(page, { timeoutMs: 180_000 })), + ); + } else if (seedCount > 0) { + await seedBalancedTreeInBrowser(seedPage, { count: seedCount, fanout: seedFanout }); + await waitForBenchHeadLamport(seedPage, seedCount); + if (mode === 'children') { + const expectedVisibleRows = 1 + Math.min(seedFanout, seedCount); + await Promise.all( + measuredPages.map((page) => waitForVisibleRowCount(page, expectedVisibleRows)), + ); + } else { + await Promise.all(measuredPages.map((page) => waitForBenchHeadLamport(page, seedCount))); + } + await Promise.all(measuredPages.map((page) => waitForBenchIdle(page))); + await seedPage.close(); } + await Promise.all(measuredPages.map((page) => resetBenchState(page))); - const sourcePage = pages[pages.length - 1]; - const targetPages = pages.slice(0, -1); + const sourcePage = measuredPages[measuredPages.length - 1]; + const targetPages = measuredPages.slice(0, -1); const samples = []; const total = warmup + iterations; for (let i = 0; i < total; i += 1) { const label = `${labelPrefix}-${i}-${Date.now()}`; const warmupSample = i < warmup; + if (targetPages.length > 0) { + await targetPages[0].bringToFront(); + } + const startedAtWallClockMs = Date.now(); const start = performance.now(); - await addNode(sourcePage, label); + const { nodeId } = await addNode(sourcePage, label); const sourceApplyMs = performance.now() - start; - const targetDurationsMs = await Promise.all( - targetPages.map((page) => waitForLabel(page, label, 60_000, start)) + const sourceBenchTiming = await readBenchNodeTiming(sourcePage, nodeId); + const targetResults = await Promise.all( + targetPages.map((page) => waitForNode(page, nodeId, 60_000, start)), ); + const targetDurationsMs = targetResults.map((entry) => entry.durationMs); const durationMs = Math.max(...targetDurationsMs); const sample = { index: i - warmup, warmup: warmupSample, label, + nodeId, + startedAtWallClockMs, sourceApplyMs, + sourceBenchTiming, + sourceBenchOffsetsMs: normalizeBenchTiming(sourceBenchTiming, startedAtWallClockMs), durationMs, targetDurationsMs, + targetBenchTimings: targetResults.map((entry) => entry.benchTiming), + targetBenchOffsetsMs: targetResults.map((entry) => + normalizeBenchTiming(entry.benchTiming, startedAtWallClockMs), + ), }; + sample.phaseOffsetsMs = derivePhaseOffsets(sample); console.log( - `[bench-playground-live-write] ${warmupSample ? "warmup" : "sample"} ${i + 1}/${total}: ${durationMs.toFixed(1)}ms` + `[bench-playground-live-write] ${warmupSample ? 'warmup' : 'sample'} ${i + 1}/${total}: ${durationMs.toFixed(1)}ms`, ); if (!warmupSample) { samples.push(sample); @@ -344,27 +916,37 @@ async function main() { tabs, warmup, iterations, - source: "browser-live-write", + seed: + seedCount > 0 + ? { + count: seedCount, + fanout: seedFanout, + fixtureDocId: seedFixture?.fixtureDocId ?? null, + } + : null, + source: 'browser-live-write', measuredAt: new Date().toISOString(), samples, summary, + phaseSummary: summarizePhaseOffsets(samples), }; await fs.mkdir(path.dirname(outPath), { recursive: true }); - await fs.writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + await fs.writeFile(outPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); console.log(`[bench-playground-live-write] wrote ${outPath}`); console.log( - `[bench-playground-live-write] summary: median=${summary.medianMs?.toFixed(1)}ms p95=${summary.p95Ms?.toFixed(1)}ms max=${summary.maxMs?.toFixed(1)}ms` + `[bench-playground-live-write] summary: median=${summary.medianMs?.toFixed(1)}ms p95=${summary.p95Ms?.toFixed(1)}ms max=${summary.maxMs?.toFixed(1)}ms`, ); } finally { await context.close(); await browser.close(); + await seedFixture?.cleanup?.(); await stopChild(playground.child); } } main().catch((err) => { - console.error(err instanceof Error ? err.stack ?? err.message : String(err)); + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); process.exitCode = 1; }); diff --git a/scripts/run-sync-bench.mjs b/scripts/run-sync-bench.mjs index 7e235870..15007f59 100644 --- a/scripts/run-sync-bench.mjs +++ b/scripts/run-sync-bench.mjs @@ -171,6 +171,7 @@ Notes: - local sync benches default TREECRDT_POSTGRES_URL to ${LOCAL_POSTGRES_URL} - remote sync benches never hardcode a server URL; pass TREECRDT_SYNC_SERVER_URL or --sync-server-url=... - use wss:// for public HTTPS/TLS deployments and ws:// for local/plain HTTP servers + - add --postgres-url=postgres://... on a remote target when you have DB access to that same deployment and want direct large-fixture priming instead of websocket upload - use --fanout=20 to model broader trees; default fanout is 10 - add --first-view to include the immediate local read after sync in the measured duration - add --iterations=N and --warmup=N to control sample count explicitly; custom --count/--counts runs now default to multiple samples instead of silently dropping to 1