mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task reads use one shared privacy predicate for detail, list, badge, and derived access. > - Its recursive parent join can scan all tasks for each frontier, including tasks with no parent. > - A 1,200-task local reproduction inspects 1,440,000 recursive task rows without finding any parent. > - Each task has at most one same-company parent, so the query can probe that parent directly. > - This pull request exposes that single-parent contract to the planner and keeps the existing access rules. > - The result removes repeated scans and avoids JIT in the measured large callers. ## Linked Issues or Issue Description Refs #14718 and #17973. I searched public issues and open/closed PRs for privacy ancestry and privacy performance work. No competing open fix was found. **What happened?** Task privacy checks repeat company task scans and private-project checks during list and unread badge reads. The recursive join may do this work even when every parent is NULL. **Expected behavior** Read each actual same-company parent through the existing unique task index. Preserve current grants, project membership, user intersections, cycle termination, and visibility before pagination. **Steps to reproduce** Run `authorization-privacy-query.test.ts` against the pre-fix query. The semantic ID check passes. Its plan-work assertion fails at 1,440,000 rows against a 3,600-row bound. The same test passes after this change. **Paperclip version or commit** Measured baseline `8279c990080b15abdcd9981f5793290fab37f9ad`. The branch was replayed onto `71b448b9cb841e6fa18fe883585430c5081245da`. Both have identical pre-fix authorization, issue-service and participation-helper sources. Treatment source SHA-256 is `a99faf974070a35f2786c92407e023c668c1f7b99b6604f6281c89bce089b1a8`. **Deployment mode** Source checkout, isolated local PostgreSQL 17.11 on ARM64 Linux. No hosted write, migration, merge, or deployment was performed. ## What Changed - Skip NULL privacy parents. Use a lateral lookup for the exact same-company parent through existing indexes. - Keep `UNION` cycle detection and every existing principal, grant, private-project and membership expression. Both `LIMIT 1` clauses are redundant: one anchor and one unique parent give at most one row per recursive generation. The query still follows a complete deep chain. - Cover open ancestors inside private projects, including current project visibility and ancestor grant revocation. Place the benchmark under `server/scripts/` with ordinary package imports. - Add real PostgreSQL regressions and a pinned-baseline benchmark for complete badge/list calls. Keep all 36 measured cells, ranges, neutral paths, failed experiments, and the single-chain proof in [the query proof](https://github.com/paperclipai/paperclip/blob/codex/perf-task-privacy-ancestry/doc/plans/2026-10-28-task-privacy-query.md). ## Verification Local warm full-service medians, with two warmups and five alternating measured calls per arm: | Scoped caller | Baseline | Treatment | Ratio | | --- | --- | --- | --- | | Dense badge, default JIT | 645.33 ms | 77.11 ms | 8.37x | | Dense list, default JIT | 214.77 ms | 29.87 ms | 7.19x | | Sparse list, default JIT | 117.92 ms | 13.22 ms | 8.92x | | Dense list, JIT off | 55.69 ms | 30.62 ms | 1.82x | | Sparse list, JIT off | 16.61 ms | 15.12 ms | 1.10x | All 36 cases match every positional SQL field and row order across all captured queries. Complete final badge IDs and list objects also match. Both badge phases and ordinary list enrichment remain in the measured caller. Dense/sparse fixtures have 1,200 tasks, 300,000 activity rows and 24,000 comments, with wide random content. Small, unrestricted, private fan-out, 64-level ancestry, cycle, and JIT on/off controls are included. The dense ranking plan reduces recursive task and project work from 720,000 visits each to zero. Its cost estimate falls from 103,000.61 to 20,458.73 and JIT disappears. Separate EXPLAIN execution falls from 524.04 to 34.34 ms. The dense JIT-off main list statement falls from 29.52 to 1.89 ms; remaining service work limits the full-call gain. **Retained failed goals:** `--assert-performance --max-dense-ms=60` exited 1. The original 60 ms dense badge goal remains unmet at 77.11 ms. Four full-service cases also miss the exploratory blanket 2x goal. These criteria remain in the harness and are not counted as green. The delivered optimization is proved by the deterministic query-work red/green test and complete result equality. Small and unrestricted controls meet their original 25% or 2 ms allowance. All 12 unrestricted cells use byte-identical SQL and parameters in both arms; their overlapping timing variation is neutral/inconclusive. Modest slower scoped controls are retained, including small JIT-off list 16.18→18.78 ms and cycle JIT-off list 14.78→16.29 ms. There is no claim that all paths improve. - Final head `328078f494d6ec585936fb75a9410ad39eefd099`: full CI passes (55 success, 1 neutral, 2 skipped); fresh Greptile is 5/5 with zero unresolved review threads. The optional Paperclip Review ended neutral because its worker stopped; its earlier two specific recommendations are both addressed below. - Final-runtime focused Docker PostgreSQL run: **44/44 tests passed** across ancestry work, production privacy, unread badge, and creation visibility suites. Includes fresh revocation, private projects, grant direction, three-principal agent intersections, cycles, deep inheritance, foreign-company parents, and access before LIMIT. - `pnpm -r typecheck` and `pnpm build`: passed before replay. The real PostgreSQL ancestry suite passed after replay (5/5 tests, 17.44 seconds). The added private-project ancestor case then passed in the six-test suite (43.04 seconds). Database and server typechecks and builds passed after replay; review follow-ups change tests and benchmark location only. A small relocated-command smoke preserves byte-identical generated SQL and complete result comparisons. - `pnpm test:run`: attempted, then stopped with exit 143 after 16 native database bootstrap failures in 940 executor cases. An independent empty native `initdb` fails with `semget` semaphore exhaustion before application SQL. This local full suite is incomplete, not passed. All 16 affected cases then passed on real PostgreSQL 17 Docker in 155.04 seconds (924 other cases were intentionally filtered out). - An earlier focused command passed 35 tests but skipped eight after a 20-second database setup timeout. The later 44/44 pass resolves that coverage gap; the failed command is retained. - The first CI e2e shard attempt completed functional split-handoff assertions, then cleanup run cancellation hit a one-second row-lock timeout. The unchanged local-trusted cleanup path does not use the changed privacy predicate. A single same-head job retry passed. The original failure is retained. - The first lateral-only draft removed scans but raised estimated ancestry rows from 11 to 101 and retained JIT. The per-generation fence fixes that estimate. No failed attempt was discarded. Reproduce the measured caller pair: ```sh pnpm --filter @paperclipai/server exec tsx scripts/benchmark-task-privacy.ts \ --baseline-ref=71b448b9cb841e6fa18fe883585430c5081245da \ --evidence-dir=/tmp/task-privacy-proof ``` The full matrix takes about 107 seconds locally, including 8 seconds of setup. Capture and EXPLAIN run outside ordinary timing samples. Only the old authorization module changes between arms; caller source and all relevant query helpers remain constant. Hosted request latency remains unverified. ## Risks - The per-generation bound depends on the single anchor and unique scalar parent contract. A future multi-parent model must revise this query and its deep/cycle tests. - Tiny tables can still use a cheap sequential lookup for a real parent. The guard prevents broad scans for absent parents; it does not force index use everywhere. - The 60 ms badge goal and blanket full-service 2x goal remain failed. Unchanged enrichment, application work and local variation remain visible in the measurements. - This changes shared read authorization SQL. No permissions are cached, no authorization moves after LIMIT, and no schema or startup change is included. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, local code execution and real PostgreSQL profiling. The exact model ID and context-window size are not exposed by this session. No more specific ID or window size is claimed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
163 lines
12 KiB
TypeScript
163 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { applyPendingMigrations, createDb, startEmbeddedPostgresTestDatabase, type Db } from "../../packages/db/src/index.js";
|
|
import { sql } from "drizzle-orm";
|
|
import { issueService } from "../src/services/issues.js";
|
|
import { issueReadSqlCondition } from "../src/services/authorization.js";
|
|
import { recursiveIssueWork, seedPrivacyFixture } from "../src/__tests__/helpers/privacy-query-fixture.js";
|
|
|
|
const started = performance.now();
|
|
const root = fileURLToPath(new URL("../../", import.meta.url));
|
|
const git = (...args: string[]) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
|
const arg = (name: string) => process.argv.find(value => value.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
const maxDenseMs = Number(arg("max-dense-ms") ?? Infinity);
|
|
const ref = arg("baseline-ref");
|
|
assert.ok(ref, "Pass a pinned --baseline-ref=<pre-fix commit>");
|
|
const baselineRevision = git("rev-parse", `${ref}^{commit}`);
|
|
const modulePath = "server/src/services/authorization.ts";
|
|
const changedRuntime = git("diff", "--name-only", baselineRevision, "--", "server/src/services", "packages/db/src", "packages/shared/src", "pnpm-lock.yaml").split("\n").filter(Boolean);
|
|
assert.deepEqual(changedRuntime.filter(file => file !== modulePath), [], "Baseline helpers must be isolated when other runtime dependencies change");
|
|
const proofFiles = [modulePath, "server/src/services/issues.ts", "server/src/services/issue-participation.ts"];
|
|
const sourceHashes = Object.fromEntries(await Promise.all(proofFiles.map(async file => [file, createHash("sha256").update(await fs.readFile(path.join(root, file))).digest("hex")])));
|
|
const baselineDir = await fs.mkdtemp(path.join(root, "server/scripts/.privacy-baseline-"));
|
|
const oldSource = git("show", `${baselineRevision}:${modulePath}`);
|
|
const rewritten = oldSource.replace(/from "(\.{1,2}\/[^\"]+)"/g, (_match, specifier: string) => `from ${JSON.stringify(fileURLToPath(new URL(specifier, pathToFileURL(path.join(root, modulePath)))))}`);
|
|
const oldModule = path.join(baselineDir, "authorization.ts");
|
|
await fs.writeFile(oldModule, rewritten);
|
|
const baseline = await import(pathToFileURL(oldModule).href) as typeof import("../src/services/authorization.js");
|
|
const treatment = process.argv.includes("--treatment-is-baseline") ? baseline.issueReadSqlCondition : issueReadSqlCondition;
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let admin: Db | undefined, db: Db | undefined;
|
|
const dbName = `privacy_ancestry_${randomUUID().replaceAll("-", "")}`;
|
|
const keepAlive = setInterval(() => {}, 1000);
|
|
type Query = { sql: string; params: unknown[] };
|
|
function captureDb(target: Db, queries: Query[]): Db {
|
|
function builder(value: any) {
|
|
const from = value.from.bind(value);
|
|
value.from = (...args: any[]) => {
|
|
const query = from(...args), execute = query.execute.bind(query);
|
|
query.execute = async () => { queries.push(query.toSQL()); return execute(); };
|
|
return query;
|
|
};
|
|
return value;
|
|
}
|
|
return new Proxy(target, { get(target, key) {
|
|
if (key === "select") return (...fields: any[]) => builder((target.select as any)(...fields));
|
|
if (key === "with") return (...ctes: any[]) => {
|
|
const value = (target.with as any)(...ctes), select = value.select.bind(value);
|
|
value.select = (...fields: any[]) => builder(select(...fields));
|
|
return value;
|
|
};
|
|
return Reflect.get(target, key);
|
|
} });
|
|
}
|
|
const stats = (values: number[]) => ({ samplesMs: values, medianMs: [...values].sort((a,b) => a-b)[Math.floor(values.length/2)]!, minMs: Math.min(...values), maxMs: Math.max(...values) });
|
|
try {
|
|
if (process.env.PRIVACY_BENCHMARK_ADMIN_URL) {
|
|
const url = new URL(process.env.PRIVACY_BENCHMARK_ADMIN_URL);
|
|
assert.equal(url.hostname, "127.0.0.1", "External benchmark PostgreSQL must be disposable loopback PostgreSQL");
|
|
admin = createDb(url.href, { maxConnections: 1 });
|
|
await admin.$client.unsafe(`CREATE DATABASE "${dbName}"`);
|
|
url.pathname = `/${dbName}`;
|
|
await applyPendingMigrations(url.href);
|
|
db = createDb(url.href, { maxConnections: 1 });
|
|
} else {
|
|
database = await startEmbeddedPostgresTestDatabase("privacy-benchmark-");
|
|
db = createDb(database.connectionString, { maxConnections: 1 });
|
|
}
|
|
await db.$client.unsafe("set client_min_messages=warning");
|
|
const settings = await db.$client.unsafe("select version(),current_setting('shared_buffers') shared_buffers,current_setting('work_mem') work_mem,current_setting('jit_above_cost') jit_above_cost,current_setting('jit_inline_above_cost') jit_inline_above_cost,current_setting('jit_optimize_above_cost') jit_optimize_above_cost");
|
|
const setupMs = performance.now()-started;
|
|
const shapes = [
|
|
{ name: "small", count: 20, participated: 20, history: 2, comments: 2, payloadBytes: 512 },
|
|
{ name: "dense", count: 1200, participated: 1200, history: 50, comments: 20, decoyHistory: 240000 },
|
|
{ name: "sparse", count: 1200, participated: 12, history: 50, comments: 20, decoyHistory: 240000 },
|
|
{ name: "deep", count: 64, participated: 64, history: 2, comments: 2, graph: "deep" as const },
|
|
{ name: "fanout", count: 1200, participated: 1200, history: 2, comments: 2, graph: "fanout" as const },
|
|
{ name: "cycle", count: 32, participated: 32, history: 2, comments: 2, graph: "cycle" as const },
|
|
].filter(shape => !arg("fixture") || shape.name === arg("fixture"));
|
|
assert.ok(shapes.length, "Unknown fixture");
|
|
const results = [];
|
|
for (const shape of shapes) {
|
|
const seedStart = performance.now();
|
|
const fixture = await seedPrivacyFixture(db, shape);
|
|
const fixtureMs = performance.now()-seedStart;
|
|
const actor = { type: "board" as const, source: "session", userId: fixture.userId, authorizingUserId: fixture.userId };
|
|
const cases = [];
|
|
for (const readMode of ["scoped", "unrestricted"]) {
|
|
if (shape.graph && readMode === "unrestricted") continue;
|
|
const conditions = {
|
|
baseline: readMode === "scoped" ? await baseline.issueReadSqlCondition(db, actor) : sql<boolean>`true`,
|
|
treatment: readMode === "scoped" ? await treatment(db, actor) : sql<boolean>`true`,
|
|
};
|
|
for (const jit of ["on", "off"]) {
|
|
await db.$client.unsafe(`set jit=${jit}`);
|
|
for (const caller of ["badge", "list"] as const) {
|
|
const invoke = (target: Db, arm: "baseline" | "treatment") => caller === "badge"
|
|
? issueService(target).unreadInboxIssueIds(fixture.companyId, fixture.userId, conditions[arm])
|
|
: issueService(target).list(fixture.companyId, { readCondition: conditions[arm], touchedByUserId: fixture.userId, limit: 100, sortField: "id", sortDir: "asc" });
|
|
const samples = { baseline: [] as number[], treatment: [] as number[] };
|
|
for (let round=0; round<7; round++) {
|
|
let before: unknown, after: unknown;
|
|
for (const arm of round%2 ? ["treatment", "baseline"] as const : ["baseline", "treatment"] as const) {
|
|
const start = performance.now();
|
|
const result = await invoke(db, arm);
|
|
if (round >= 2) samples[arm].push(performance.now()-start);
|
|
if (arm === "baseline") before=result; else after=result;
|
|
}
|
|
assert.deepEqual(after, before, `${shape.name}/${readMode}/${jit}/${caller}: full service output/order`);
|
|
}
|
|
const plans = [];
|
|
let expectedRows: unknown[][] | undefined;
|
|
for (const arm of ["baseline", "treatment"] as const) {
|
|
const queries: Query[] = [];
|
|
await invoke(captureDb(db, queries), arm);
|
|
assert.ok(queries.length >= (caller === "badge" ? 2 : 1), "Capture the complete real caller");
|
|
const rows: unknown[][] = [], queryPlans = [];
|
|
for (const query of queries) {
|
|
rows.push(await db.$client.unsafe(query.sql, query.params as never[]).values());
|
|
const [explain] = await db.$client.unsafe(`explain (analyze,buffers,format json) ${query.sql}`, query.params as never[]);
|
|
queryPlans.push({ ...query, rows: rows.at(-1)!.length, work: recursiveIssueWork(explain["QUERY PLAN"][0].Plan), plan: explain["QUERY PLAN"] });
|
|
}
|
|
if (arm === "baseline") expectedRows = rows;
|
|
else assert.deepEqual(rows, expectedRows, `${shape.name}/${readMode}/${jit}/${caller}: every positional SQL field and order`);
|
|
plans.push({ arm, queries: queryPlans });
|
|
}
|
|
const before = stats(samples.baseline), after = stats(samples.treatment);
|
|
cases.push({ readMode, jit, caller, baseline: before, treatment: after, speedup: before.medianMs/after.medianMs, positionalEquality: true, serviceEquality: true, plans });
|
|
console.error(JSON.stringify({ fixture: shape.name, readMode, jit, caller, baseline: before.medianMs, treatment: after.medianMs }));
|
|
}
|
|
}
|
|
}
|
|
results.push({ fixture, fixtureMs, cases });
|
|
await db.$client.unsafe("truncate companies cascade");
|
|
}
|
|
const dependencyVersions = Object.fromEntries(await Promise.all(["postgres", "drizzle-orm"].map(async name => [name, JSON.parse(await fs.readFile(path.join(root,`packages/db/node_modules/${name}/package.json`),"utf8")).version])));
|
|
const report = { baselineRevision, dependencyVersions, maxDenseMs: Number.isFinite(maxDenseMs) ? maxDenseMs : null, treatmentRevision: git("rev-parse", "HEAD"), sourceHashes, baselineSourceSha256: createHash("sha256").update(oldSource).digest("hex"), changedRuntime, checkedInLockSha256: createHash("sha256").update(await fs.readFile(path.join(root,"pnpm-lock.yaml"))).digest("hex"), nodeVersion: process.version, settings, setupMs, totalMs: performance.now()-started, warmupsPerArm: 2, measuredSamplesPerArm: 5, results };
|
|
const evidence = arg("evidence-dir");
|
|
if (evidence) { await fs.mkdir(evidence,{ recursive: true }); await fs.writeFile(path.join(evidence,"benchmark.json"),JSON.stringify(report,null,2)); }
|
|
console.log(JSON.stringify({ ...report, results: results.map(result => ({ ...result, cases: result.cases.map(({ plans, ...rest }) => rest) })) },null,2));
|
|
if (process.argv.includes("--assert-performance")) {
|
|
for (const result of results) for (const c of result.cases) {
|
|
if (c.readMode === "scoped") {
|
|
for (const query of c.plans.find(p => p.arm === "treatment")!.queries) {
|
|
if (!result.fixture.graph) assert.equal(query.work.inspectedRows, 0, "Absent parent frontiers must not inspect company tasks");
|
|
}
|
|
if (["dense","sparse"].includes(result.fixture.name)) assert.ok(c.speedup >= 2, `Scoped representative ${result.fixture.name}/${c.jit}/${c.caller} requires 2x improvement`);
|
|
}
|
|
if (result.fixture.name === "dense" && c.caller === "badge" && c.readMode === "scoped" && c.jit === "on") assert.ok(c.treatment.medianMs <= maxDenseMs, `Original dense scoped badge target ${maxDenseMs}ms remains unmet: ${c.treatment.medianMs.toFixed(2)}ms`);
|
|
if (result.fixture.name === "small" || c.readMode === "unrestricted") assert.ok(c.treatment.medianMs <= Math.max(c.baseline.medianMs*1.25,c.baseline.medianMs+2), "Small/open controls must stay within 25% or 2ms");
|
|
}
|
|
}
|
|
} finally {
|
|
await db?.$client.end({timeout:0});
|
|
await database?.cleanup();
|
|
if (admin) { await admin.$client.unsafe(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`); await admin.$client.end({timeout:0}); }
|
|
await fs.rm(baselineDir,{recursive:true,force:true});
|
|
clearInterval(keepAlive);
|
|
}
|