mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
Wires PerfTracer (M0) into the writer-side and reader-side hot paths
so every sync round-trip emits the spans the upcoming PerfAggregator
(M4) and microbench (M6) need. Strictly additive: when
`perfTracer.enabled === false` (the default — both `REMOTE_SSH_PERF`
unset and the env-fired singleton at import time), every `begin/end/
point` call is a zero-allocation no-op (M0 contract).
Spans emitted (matches the design in
C:/Users/souta/.claude/plans/obsidian-remote-ssh-composed-sunset.md
§C.0):
| Site | Span/Point | Attrs |
|-----------------------------------|------------|--------------------------------|
| SftpDataAdapter.write | S.adp | op:write, path, bytes |
| SftpDataAdapter.writeBinary | S.adp | op:writeBinary, path, bytes |
| SftpDataAdapter.append | S.adp | op:append, path, bytes |
| SftpDataAdapter.appendBinary | S.adp | op:appendBinary, path, bytes |
| SftpDataAdapter.process | S.adp | op:process, path |
| SftpDataAdapter.remove | S.adp | op:remove, path |
| SftpDataAdapter.rename | S.adp | op:rename, path, newPath |
| RpcRemoteFsClient.writeBinary | S.rpc | method:fs.writeBinary, path, bytes |
| RpcRemoteFsClient.mkdirp | S.rpc | method:fs.mkdir, path |
| RpcRemoteFsClient.remove | S.rpc | method:fs.remove, path |
| RpcRemoteFsClient.rmdir | S.rpc | method:fs.rmdir, path |
| RpcRemoteFsClient.rename | S.rpc | method:fs.rename, path, newPath |
| RpcRemoteFsClient.copy | S.rpc | method:fs.copy, path, destPath |
| main.ts handleFsChanged | T4a (point)| path, event, subscriptionId |
| main.ts applyFsChange | S.app | event, path, newPath |
| VaultModelBuilder.insertFile | T5a (point)| op:create, path |
| VaultModelBuilder.insertFolder | T5a (point)| op:create, path |
| VaultModelBuilder.removeOne | T5a (point)| op:delete, path |
| VaultModelBuilder.modifyOne | T5a (point)| op:modify, path |
| VaultModelBuilder.renameOne | T5a (point)| op:rename, path, newPath |
Cid threading is intentionally NOT done here. Each site mints its own
cid via `perfTracer.newCid()` so spans are independent records.
PerfAggregator (M4) will correlate via the path attribute for the MVP
microbench (M6 — same Node process for writer + reader). Cross-process
cid threading (envelope `meta.cid` actually flowing on the wire +
daemon attaching it to outgoing `fs.changed` notifications) lands as
M3 alongside the daemon-side Go instrumentation, where it pairs
naturally with the path-keyed correlation table the plan describes.
Why no signature changes:
- `RpcClient.call(method, params)` stays untouched — adding `opts?:
{meta?:RpcMeta}` would force every call site to think about meta,
which the M2 atomic boundary explicitly avoids.
- `RemoteFsClient` interface stays untouched — instrumentation is on
the concrete `RpcRemoteFsClient` (the production transport for
the perf bench); `SftpRemoteFsClient` (the SFTP-only path) stays
un-instrumented at this layer because its writes go through the
same `SftpDataAdapter.write*` methods that already carry T1.
- `VaultModelBuilder` constructor stays untouched.
Tests:
- `tests/PerfInstrumentation.test.ts` (new, 12 tests):
- VaultModelBuilder T5a points fire on insertOne/removeOne/
modifyOne/renameOne with the correct `op` and `path` attrs,
immediately before each `vault.trigger(...)` (so an aggregator
that consumes the next vault event is guaranteed to have already
seen the matching T5a record).
- SftpDataAdapter S.adp spans on write/writeBinary/remove/rename
carry the right `op` / `path` / `bytes` attrs and a non-negative
`durMs`.
- RpcRemoteFsClient S.rpc spans on writeBinary/remove/rename/
mkdirp likewise. The stub `RpcClient` confirms call dispatch
didn't break.
- `beforeEach` flips `perfTracer.setEnabled(true)` + `clear()`;
`afterEach` flips it back to false + clears so subsequent test
files (which assume disabled-default) stay isolated.
- The disabled-default no-op contract is held by the unchanged green
run of every other unit test file — 429 tests still pass with no
modification, confirming production behaviour is byte-identical
when REMOTE_SSH_PERF is unset.
Suite results:
- `npx vitest run` — 33 files / 441 tests green (was 429, +12 new)
- `npx tsc --noEmit -p tsconfig.json` — clean
manifest/package/versions bumped 0.4.39 → 0.4.40.
Roadmap context: M2 plus M0/M1/M5 (already merged) closes out the
plugin-side prerequisites of the Phase C MVP. Next:
- M4: PerfAggregator (pure module) consuming these records into
p50/p95/p99 percentiles per (name, op) tuple, NDJSON writer.
- M6: integration bench harnesses two RPC clients on a shared
daemon, replays the matrix (RPC × {1KB, 100KB} × {create,
modify, rename, delete}), feeds spans to PerfAggregator.
- M3: Go daemon instrumentation + path-keyed cid correlator;
flips T4a/S.app/T5a from per-event cids to writer-supplied cids
so S.e2e (T1→T5a) crosses processes cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { perfTracer, type SpanRecord } from '../src/util/PerfTracer';
|
|
import { VaultModelBuilder, type RemoteEntry, type ObsidianClassDeps } from '../src/vault/VaultModelBuilder';
|
|
import { SftpDataAdapter } from '../src/adapter/SftpDataAdapter';
|
|
import { ReadCache } from '../src/cache/ReadCache';
|
|
import { DirCache } from '../src/cache/DirCache';
|
|
import { RpcRemoteFsClient } from '../src/adapter/RpcRemoteFsClient';
|
|
import type { RemoteStat } from '../src/types';
|
|
|
|
/**
|
|
* Pins the M2 instrumentation contract: when `perfTracer.enabled` is
|
|
* true, every instrumented site emits a span / point with the right
|
|
* name and op attribute. When disabled (the default, exercised by all
|
|
* other test files), the same calls are zero-allocation no-ops — that
|
|
* second half of the contract is held by the unchanged green run of
|
|
* the existing 429 unit tests, not retested here.
|
|
*/
|
|
|
|
// ── shared helpers ────────────────────────────────────────────────────
|
|
|
|
function captureSpans(): { records: SpanRecord[]; off: () => void } {
|
|
const records: SpanRecord[] = [];
|
|
const off = perfTracer.onSpan((s) => records.push(s));
|
|
return { records, off };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
perfTracer.clear();
|
|
perfTracer.setEnabled(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
perfTracer.setEnabled(false);
|
|
perfTracer.clear();
|
|
});
|
|
|
|
// ── VaultModelBuilder T5a points ──────────────────────────────────────
|
|
|
|
class FakeTFile {
|
|
vault!: unknown;
|
|
path!: string;
|
|
name!: string;
|
|
basename!: string;
|
|
extension!: string;
|
|
parent!: FakeTFolder;
|
|
stat!: { ctime: number; mtime: number; size: number };
|
|
constructor(vault: unknown, path: string) { this.vault = vault; this.path = path; }
|
|
}
|
|
class FakeTFolder {
|
|
vault!: unknown;
|
|
path: string = '';
|
|
name: string = '';
|
|
parent: FakeTFolder | null = null;
|
|
children: Array<FakeTFile | FakeTFolder> = [];
|
|
constructor(vault?: unknown, path?: string) {
|
|
if (vault !== undefined) this.vault = vault;
|
|
if (path !== undefined) this.path = path;
|
|
}
|
|
}
|
|
const deps: ObsidianClassDeps = {
|
|
TFile: FakeTFile as unknown as ObsidianClassDeps['TFile'],
|
|
TFolder: FakeTFolder as unknown as ObsidianClassDeps['TFolder'],
|
|
};
|
|
|
|
function makeFakeVault() {
|
|
const root = new FakeTFolder();
|
|
const fileMap: Record<string, FakeTFile | FakeTFolder> = {};
|
|
const triggers: Array<{ event: string; args: unknown[] }> = [];
|
|
const vault = {
|
|
fileMap, triggers,
|
|
getRoot: () => root,
|
|
getAbstractFileByPath: (p: string) => fileMap[p] ?? null,
|
|
trigger: (event: string, ...args: unknown[]) => { triggers.push({ event, args }); },
|
|
};
|
|
return { vault, root, fileMap, triggers };
|
|
}
|
|
|
|
describe('M2: VaultModelBuilder emits T5a points alongside vault.trigger', () => {
|
|
it('insertOne(file) emits T5a {op:"create"} immediately before trigger', () => {
|
|
const { vault, triggers } = makeFakeVault();
|
|
const cap = captureSpans();
|
|
const b = new VaultModelBuilder(vault as never, deps);
|
|
|
|
const entry: RemoteEntry = { path: 'Note.md', isDirectory: false, ctime: 1, mtime: 2, size: 3 };
|
|
b.insertOne(entry);
|
|
cap.off();
|
|
|
|
const t5a = cap.records.filter(r => r.name === 'T5a');
|
|
expect(t5a).toHaveLength(1);
|
|
expect(t5a[0].attrs).toEqual({ op: 'create', path: 'Note.md' });
|
|
// The point must precede the vault.trigger so PerfAggregator never sees
|
|
// a T5a after the consumer has already reacted.
|
|
expect(triggers).toHaveLength(1);
|
|
expect(triggers[0].event).toBe('create');
|
|
});
|
|
|
|
it('removeOne emits T5a {op:"delete"}', () => {
|
|
const { vault, fileMap } = makeFakeVault();
|
|
fileMap['Note.md'] = new FakeTFile(vault, 'Note.md');
|
|
const cap = captureSpans();
|
|
|
|
new VaultModelBuilder(vault as never, deps).removeOne('Note.md');
|
|
cap.off();
|
|
|
|
const t5a = cap.records.filter(r => r.name === 'T5a');
|
|
expect(t5a).toHaveLength(1);
|
|
expect(t5a[0].attrs).toEqual({ op: 'delete', path: 'Note.md' });
|
|
});
|
|
|
|
it('modifyOne emits T5a {op:"modify"}', () => {
|
|
const { vault, fileMap } = makeFakeVault();
|
|
const file = new FakeTFile(vault, 'Note.md');
|
|
file.stat = { ctime: 0, mtime: 0, size: 0 };
|
|
fileMap['Note.md'] = file;
|
|
const cap = captureSpans();
|
|
|
|
new VaultModelBuilder(vault as never, deps).modifyOne('Note.md', { ctime: 1, mtime: 2, size: 3 });
|
|
cap.off();
|
|
|
|
const t5a = cap.records.filter(r => r.name === 'T5a');
|
|
expect(t5a).toHaveLength(1);
|
|
expect(t5a[0].attrs).toEqual({ op: 'modify', path: 'Note.md' });
|
|
});
|
|
|
|
it('renameOne emits T5a {op:"rename", path, newPath}', () => {
|
|
const { vault, root, fileMap } = makeFakeVault();
|
|
const file = new FakeTFile(vault, 'old.md');
|
|
file.parent = root;
|
|
root.children.push(file);
|
|
fileMap['old.md'] = file;
|
|
const cap = captureSpans();
|
|
|
|
new VaultModelBuilder(vault as never, deps).renameOne('old.md', 'new.md');
|
|
cap.off();
|
|
|
|
const t5a = cap.records.filter(r => r.name === 'T5a');
|
|
expect(t5a).toHaveLength(1);
|
|
expect(t5a[0].attrs).toEqual({ op: 'rename', path: 'old.md', newPath: 'new.md' });
|
|
});
|
|
});
|
|
|
|
// ── SftpDataAdapter S.adp spans ───────────────────────────────────────
|
|
|
|
function makeMinimalRemoteFsClient() {
|
|
const writes: Array<{ path: string; bytes: number }> = [];
|
|
return {
|
|
writes,
|
|
isAlive: () => true,
|
|
onClose: () => () => { /* noop */ },
|
|
stat: vi.fn(async (_p: string): Promise<RemoteStat> => ({
|
|
isDirectory: false, isFile: true, isSymbolicLink: false,
|
|
mtime: 100, size: 0, mode: 0o100644,
|
|
})),
|
|
exists: vi.fn(async () => true),
|
|
list: vi.fn(async () => []),
|
|
readBinary: vi.fn(async () => Buffer.alloc(0)),
|
|
writeBinary: vi.fn(async (p: string, d: Buffer) => { writes.push({ path: p, bytes: d.length }); }),
|
|
mkdirp: vi.fn(async () => { /* noop */ }),
|
|
remove: vi.fn(async () => { /* noop */ }),
|
|
rmdir: vi.fn(async () => { /* noop */ }),
|
|
rename: vi.fn(async () => { /* noop */ }),
|
|
copy: vi.fn(async () => { /* noop */ }),
|
|
};
|
|
}
|
|
|
|
function makeAdapter() {
|
|
const client = makeMinimalRemoteFsClient();
|
|
const adapter = new SftpDataAdapter(
|
|
client as never,
|
|
'/remote',
|
|
new ReadCache(64 * 1024 * 1024),
|
|
new DirCache(),
|
|
'test-vault',
|
|
);
|
|
return { adapter, client };
|
|
}
|
|
|
|
describe('M2: SftpDataAdapter emits S.adp spans on write-side methods', () => {
|
|
it('write() emits S.adp {op:"write"} with byte count', async () => {
|
|
const cap = captureSpans();
|
|
const { adapter } = makeAdapter();
|
|
await adapter.write('foo.md', 'hello');
|
|
cap.off();
|
|
|
|
const sAdp = cap.records.filter(r => r.name === 'S.adp');
|
|
expect(sAdp).toHaveLength(1);
|
|
expect(sAdp[0].attrs).toMatchObject({ op: 'write', path: 'foo.md', bytes: 5 });
|
|
expect(sAdp[0].durMs).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('writeBinary() emits S.adp {op:"writeBinary"} with byteLength', async () => {
|
|
const cap = captureSpans();
|
|
const { adapter } = makeAdapter();
|
|
const data = new ArrayBuffer(128);
|
|
await adapter.writeBinary('blob.bin', data);
|
|
cap.off();
|
|
|
|
const sAdp = cap.records.filter(r => r.name === 'S.adp');
|
|
expect(sAdp).toHaveLength(1);
|
|
expect(sAdp[0].attrs).toMatchObject({ op: 'writeBinary', path: 'blob.bin', bytes: 128 });
|
|
});
|
|
|
|
it('remove() emits S.adp {op:"remove"} even on the success path', async () => {
|
|
const cap = captureSpans();
|
|
const { adapter } = makeAdapter();
|
|
await adapter.remove('doomed.md');
|
|
cap.off();
|
|
|
|
const sAdp = cap.records.filter(r => r.name === 'S.adp');
|
|
expect(sAdp).toHaveLength(1);
|
|
expect(sAdp[0].attrs).toMatchObject({ op: 'remove', path: 'doomed.md' });
|
|
});
|
|
|
|
it('rename() emits S.adp {op:"rename"} with new path attr', async () => {
|
|
const cap = captureSpans();
|
|
const { adapter } = makeAdapter();
|
|
await adapter.rename('old.md', 'new.md');
|
|
cap.off();
|
|
|
|
const sAdp = cap.records.filter(r => r.name === 'S.adp');
|
|
expect(sAdp).toHaveLength(1);
|
|
expect(sAdp[0].attrs).toMatchObject({ op: 'rename', path: 'old.md', newPath: 'new.md' });
|
|
});
|
|
});
|
|
|
|
// ── RpcRemoteFsClient S.rpc spans ─────────────────────────────────────
|
|
|
|
function makeStubRpcClient() {
|
|
return {
|
|
isClosed: () => false,
|
|
onClose: () => () => { /* noop */ },
|
|
call: vi.fn(async (_method: string, _params: unknown) => ({ mtime: 0 })),
|
|
onNotification: () => () => { /* noop */ },
|
|
};
|
|
}
|
|
|
|
describe('M2: RpcRemoteFsClient emits S.rpc spans on the wire-side calls', () => {
|
|
it('writeBinary() emits S.rpc {method:"fs.writeBinary"} with byte count', async () => {
|
|
const cap = captureSpans();
|
|
const stub = makeStubRpcClient();
|
|
const cli = new RpcRemoteFsClient(stub as never);
|
|
await cli.writeBinary('a.md', Buffer.from('hi'));
|
|
cap.off();
|
|
|
|
const sRpc = cap.records.filter(r => r.name === 'S.rpc');
|
|
expect(sRpc).toHaveLength(1);
|
|
expect(sRpc[0].attrs).toMatchObject({ method: 'fs.writeBinary', path: 'a.md', bytes: 2 });
|
|
expect(stub.call).toHaveBeenCalledWith('fs.writeBinary', expect.objectContaining({ path: 'a.md' }));
|
|
});
|
|
|
|
it('remove() emits S.rpc {method:"fs.remove"}', async () => {
|
|
const cap = captureSpans();
|
|
const cli = new RpcRemoteFsClient(makeStubRpcClient() as never);
|
|
await cli.remove('gone.md');
|
|
cap.off();
|
|
|
|
const sRpc = cap.records.filter(r => r.name === 'S.rpc');
|
|
expect(sRpc).toHaveLength(1);
|
|
expect(sRpc[0].attrs).toMatchObject({ method: 'fs.remove', path: 'gone.md' });
|
|
});
|
|
|
|
it('rename() emits S.rpc {method:"fs.rename"} with newPath attr', async () => {
|
|
const cap = captureSpans();
|
|
const cli = new RpcRemoteFsClient(makeStubRpcClient() as never);
|
|
await cli.rename('old.md', 'new.md');
|
|
cap.off();
|
|
|
|
const sRpc = cap.records.filter(r => r.name === 'S.rpc');
|
|
expect(sRpc).toHaveLength(1);
|
|
expect(sRpc[0].attrs).toMatchObject({ method: 'fs.rename', path: 'old.md', newPath: 'new.md' });
|
|
});
|
|
|
|
it('mkdirp() emits S.rpc {method:"fs.mkdir"}', async () => {
|
|
const cap = captureSpans();
|
|
const cli = new RpcRemoteFsClient(makeStubRpcClient() as never);
|
|
await cli.mkdirp('subdir');
|
|
cap.off();
|
|
|
|
const sRpc = cap.records.filter(r => r.name === 'S.rpc');
|
|
expect(sRpc).toHaveLength(1);
|
|
expect(sRpc[0].attrs).toMatchObject({ method: 'fs.mkdir', path: 'subdir' });
|
|
});
|
|
});
|