Skip to content

perf(host-core): reduce history costs in search and session reads - #1202

Merged
vastsa merged 342 commits into
vastsa:mainfrom
moapop:perf/host-core-persistence-hot-paths
Oct 5, 2026
Merged

vastsa merged 342 commits into
vastsa:mainfrom
moapop:perf/host-core-persistence-hot-paths

Conversation

@moapop

@moapop moapop commented Sep 29, 2026 •

Copy link
Copy Markdown

Problem

Four costs that grow with session history. Each was measured before the change
and again with the change ablated.

  1. The workspace resolvers read a whole transcript to learn one project
    path
    — four call sites, one of them twice per call.
  2. Every search statement sorted its complete match set. messages carried
    only the UNIQUE (session_id, seq) index the write path needs, while each
    statement ranks by created_at and cuts with a LIMIT, so
    EXPLAIN QUERY PLAN reports USE TEMP B-TREE FOR ORDER BY on the uncapped
    LIKE branch, both trigram branches and the per-session snippet lookup.
    snippet() also ran once per FTS match rather than once per returned row.
  3. Two presence questions parsed whole transcripts
    (transcript_contains_id, read_tool_call).
  4. The desktop walked a search query once per emitted page: page
    offset / 30 cost offset / 30 + 1 host calls, each paying the same
    whole-table aggregation, because OFFSET does not change it.

Result

measured before after
session summary vs full read, 21 MB 9.8 ms 0.049 ms (202x)
search.query uncapped LIKE, first 20 of 40k 30.46 ms 0.021 ms
per-session snippet lookup, 40k 0.183 ms 0.028 ms
search.query trigram, 40k 6.39 ms 3.52 ms
transcript_contains_id / read_tool_call, 21 MB 7.42 / 6.24 ms 4.39 / 4.51 ms
six search pages, 40k 11 calls / 27.4 ms 6 calls / 19.0 ms
hover existence check, 21 MB 22.3 ms / 21 MB 0.07 ms / 66 KB

Medians of five runs on one fixture per row. The ablation for the search indexes
is the same statements with the indexes dropped again, so the difference is
attributable to the index rather than to the statement text.

Implementation notes

  • The indexes arrive the way idx_turns_ended_at arrives: an idempotent
    CREATE INDEX IF NOT EXISTS in boot_maintenance plus the schema
    declaration. No row is rewritten, no schema-version bump, no migration backup.
    The first launch after the upgrade builds both indexes once.
  • The snippet() replacement repeats MATCH ?1 in its subquery. An FTS5
    auxiliary function reads the phrase list of the MATCH that positioned its
    cursor, so reaching a document by rowid alone returns the head of the document
    instead of the window around the hit. That faster form was rejected after a
    row-for-row comparison found 0 of 20 snippets identical; the shipped form is
    20 of 20.
  • update_message stays O(history) deliberately: appending the replacement is
    396x cheaper and was rejected because read-path windows are physical line
    coordinates, so a search jump to a stamped message would open on the tail of
    the session.
  • Transcript and revision lines still call sync_data() before the derived
    database transaction. The append-throughput optimization is withdrawn pending
    a transaction-safe durability design; no append-speed result is claimed here.

Verification

  • cargo fmt --all -- --check — passed.
  • cargo clippy -p host-core --all-targets --locked — passed with two
    non-blocking warnings in transcript cache/read code.
  • cargo test -p host-core --locked — 779 passed, 0 failed.
  • cargo build -p host-core --locked — passed.
  • ELECTRON_MAJOR_VER=43 pnpm build:js — passed.
  • pnpm --filter @pi-desktop/desktop typecheck — fails on existing
    LucideProps icon prop errors; the same class of errors reproduces on
    current main. The changed PR lines do not overlap those errors.
  • pnpm lint — passed.
  • pnpm -r --if-present test — passed across all workspaces, including
    desktop 3556/3556.
  • pnpm test:e2e — 23/23 passed, 2 skipped because no API key was set.
  • Changed desktop session tests — 27/27 passed.
  • Architecture check and pnpm check:pr-base — passed; latest origin/main
    is an ancestor of this PR head.

Compatibility, migration, security

  • No output, ordering or protocol shape changes. search.sessions gains an
    optional limit; omitted, it keeps the existing 30-row cursor contract.
  • Transcript/revision sync remains ahead of the index transaction; a failed
    sync returns an error. No append durability contract is weakened. The
    append-speed optimization and its benchmark claim were removed.
  • No dependency, lockfile, workflow or configuration change.
  • No ADR: no architectural boundary, security boundary or frozen decision
    changes.
  • No new E2E scenario: the changed behaviour is host-internal, and the host-core
    search tests plus the desktop search-merge expectations cover it; the latter
    are updated to pin the new request shape.

13 files changed, all modified — none added, none deleted.

@vastsa

vastsa commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Review result: do not merge yet.

The measured search/session improvements are plausible, and I ran the relevant host-core coverage: cargo test -p host-core --locked passed 677/677, including the new session-search, transcript, index, and deferred-flush tests.

There is nevertheless a production data-safety regression in the append path. Before this PR, append_line() called file.sync_data()? before returning, so an append RPC did not succeed when the device flush failed. Now it returns after write_all + flush, queues sync_data() on a background thread, and only logs a flush error. main.rs drains only on clean shutdown; a crash/power loss or a failed sync can leave the transcript non-durable while the RPC and subsequent SQLite commit have already succeeded. That changes the persisted-data and error contract and can make the database claim a message that the transcript has lost.

This is not a cosmetic concern: the PR description itself calls it a reliability change, but no durable acknowledgement/error propagation or retry path replaces the old guarantee. Please preserve the existing durability semantics (or define and validate an explicit new contract) before landing. The PR head is also behind current origin/main (78d0e884, after #1200), so it needs a fresh integration candidate after correction.

@moapop
moapop force-pushed the perf/host-core-persistence-hot-paths branch 6 times, most recently from 12fea91 to f98d71c Compare September 30, 2026 17:05
@vastsa
vastsa force-pushed the perf/host-core-persistence-hot-paths branch from a315b0a to 6fae5e8 Compare October 1, 2026 02:41
@vastsa

vastsa commented Oct 1, 2026

Copy link
Copy Markdown
Owner

The durability regression is resolved in the current head: transcript writes are committed before the RPC response, and a failed sync_data() is returned as request failure. I verified cargo test -p host-core --locked: 705/705 passed after refreshing the branch onto current main.

I am not merging yet because GitHub reports no checks for this PR, while the current diff also changes desktop JS search/session paths. The local desktop typecheck could not be run from the isolated worktree because pnpm rejects the reused symlinked task-state directory. Please provide a CI candidate (or a clean JS build/typecheck/lint/test result) before landing this 12-file performance change.

@vastsa

vastsa commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Found a remaining root-cause durability issue in the current deferred-flush design; please do not merge yet.

append_record() writes the JSONL line, then commits the SQLite messages row, while sync_data() is deferred until commit_writes_of() runs after the handler returns. If sync_data() fails, response_for() correctly returns an RPC error, but the SQLite row has already committed. A retry of the same append then hits message_indexed() and returns success/no-op without rewriting or re-registering the transcript flush. After a transient/device flush failure this can leave SQLite claiming the message exists while the transcript bytes are not durable; a crash at that point loses the message.

The new tests only prove that the deferred request reports the flush error; they do not assert the post-failure DB/transcript state or retry behavior. The fix needs to make the DB/index commit conditional on durable transcript success, or add an explicit durable-pending/retry/reconciliation state that prevents a retry from becoming a no-op until the transcript is synced.

vastsa and others added 20 commits October 2, 2026 16:29
The Markdown renderer rejects raw Windows drive-letter URLs as unsafe schemes. Encode the resolved path in the inline-code branch, matching the established text-path behavior, and assert that ReactMarkdown emits an anchor around the inline code.
…ing-20261002

fix(host-core): safely inline Read image results
…landing-20261002

fix(desktop): preserve clickable Windows paths in inline code
Rollup emits Main as out/main/index.js plus shared chunks under
out/main/chunks/. getModuleDirectory returned the chunk directory,
so renderer/preload/plugin-host paths resolved one level too deep
and the window loaded a missing index.html (black screen).
Add a regression test proving getModuleDirectory() resolves to
out/main from both the entry bundle and out/main/chunks/*.js, so
renderer/preload/plugin-host paths cannot drift one level deep
again. Document the shared-chunk layout in E2E-001.
Reconcile renderer pending queues with successful Host reads so requests resolved outside the renderer disappear without dropping requests delivered during a read. Guard overlapping restores by generation and cover the lifecycle with regression tests.\n\nRefs: mocode vastsa#484
…-blackscreen

fix(electron): resolve main output root from inside Rollup chunks
When an OpenAI-compatible provider configures a canonical model name
such as gemini-3.8-flash, relay catalog resolution matches the official
Google model entry and attaches api: "google-generative-ai" to its
modelConfig. Previously, providerRequestTransport allowed any model-level
api to override the provider apiStyle unconditionally, forcing the runtime
to route requests through the Google Generative AI adapter against the
OpenAI proxy endpoint, causing immediate HTTP 404 errors.

Scope model-level wire API overrides to wire-compatible protocols,
ensuring that foreign catalog protocols (e.g. Google Generative AI or
Anthropic Messages) do not overwrite an OpenAI-compatible provider's
configured chat_completions transport.

fixes vastsa#1310
The Command-K recents view and the tray still offered a scheduled run's
transcript, because only the SessionList and search hits applied the ownership
rule: the palette's empty-query list reads the store directly, and the tray
builds its recents from `session.list`. Both now go through the same rule — one
`listableSessions` helper in the renderer and its main-process twin for the tray
— so no session list offers a conversation that belongs to the Scheduled page.

Also repairs the spec text an earlier change left truncated (a duplicated English
fragment, the Chinese UI/IA paragraph and the config_json field list), records
the retention boundary a fast cadence reaches — a pruned run takes its session's
ownership marker with it and that transcript returns to the ordinary lists — and
indexes `task_runs(session_id)` for the ownership probe that summaries and
search run per row.

Reported by @vastsa in review of vastsa#1298.

refs vastsa#1291
Use the published catalog for chat model limits and capabilities so
provider model lists no longer inherit one repeated pi-ai context value.
Keep endpoint discovery and explicit per-account user overrides intact. Add
regression coverage and update the catalog decision and runtime specs.

Fixes vastsa#1304
The reordering left the previous three lines in place, so the step list repeated
"fields and verify its first occurrence is one hour away; pause/resume; Run now"
and trailed off at "conversation, use model tool calls…". Restores the original
"observe automatic completion; delete the settled task." step, which the Chinese
plan still states.
Pin the issue's one-million-token Astra configuration through live
model discovery, metadata caching, settings selection, and runtime budget
resolution. This locks down the distinction between published catalog data
and an account's explicit context window.
Keep the Chinese decisions log aligned with the new models.dev chat
metadata authority and preserve the superseded catalog decisions as
historical context. Match the source decision table and timeline.
…etail

feat(scheduled): 定时任务页改造为「任务 + 运行记录」主从视图(vastsa#1291 阶段一)
Update source-contract and lookup fixtures that still encode the former
Pi metadata authority. Keep the assertions focused on bundled models.dev
metadata and explicit provider-binding resolution.
fix(models): use models.dev as metadata authority
vastsa and others added 12 commits October 5, 2026 08:08
Large chat content could trigger unbounded scans and repeated parser work on the renderer path. Cap synchronous work while keeping complete source available through localized plain-text fallbacks.
fix(renderer): bound transcript rendering work
The TodoDock contract update left a repeated phrase in the component spec.\nRemove the duplicate so the revised behavior reads cleanly in the landing diff.
…list

feat(todo): 展开后的 TodoDock 列出全部清单并在 dock 内滚动(vastsa#1319 方案 A)
Keep the header as the native drag owner while limiting no-drag to
interactive tabs and existing action controls. This restores window
dragging without changing tab selection or reorder behavior.
Coordinate screenshot capture with the pane that owns native bounds so
Chromium cannot restore a stale viewport after a concurrent resize.
Pin queued captures to their originating page and preserve caller errors.
fix(work-panel): restore window dragging in unused tab-strip space
…ure-resize

fix(browser): preserve the latest viewport after screenshot capture
projectSessionGetResult projected only the newest `session.compaction` record.
`session.compactions` is the unbounded compaction history and every entry keeps
its own `summary` / `retainedTail` / `details.modifiedFiles`, so a session that
has been compacted several times still overflowed the 512 KiB `MAX_RESULT_CHARS`
limit and the whole answer was replaced by a `{truncated, reason:
"MCP_RESULT_LIMIT", preview}` envelope. External clients (`pi_session_get`) then
never reached `messages`, and reducing the transcript page could not help
because the overflow is independent of `messageLimit` / `contentLimit`.

Project every history entry to the same compact identity the tools contract
promises (`createdAt` and `details.generation`), and keep the projection working
for sessions that carry history but no newest record. Answers already under the
limit, sessions without any compaction record, and the desktop's own session
detail are unchanged.

Reported from the MoCode client (mocode issue 506).
…on-history

fix(mcp): bound the session/get compaction history before truncating

@vastsa vastsa left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful measurements and the substantial performance work. I re-checked the current head and cannot approve or merge it yet: the deferred flush still leaves a data-safety gap.

sessions::append_message calls append_record, which writes the transcript line and commits the SQLite message index inside the handler. append_line only registers the file for sync_data() in the request-local queue; commit_writes_of runs that queue after the handler has returned. If the flush fails, response_for correctly returns an RPC error, but the SQLite row remains committed. A retry then hits message_indexed() and returns success without rewriting or re-registering the transcript write. A failed device flush can therefore leave SQLite claiming a message whose transcript is not durable, and a later crash can lose that message.

The current a_request_only_answers_after_its_lines_reached_the_device test checks the RPC error, but not the database/transcript state or retry behavior. Please make the index commit conditional on a successful transcript flush (or add an explicit durable-pending/rollback path that prevents retry from becoming a no-op) and add a failing-flush-then-retry regression test that verifies the canonical transcript and index agree.

This head is also behind current main (PR base 4dee458, current origin/main c1afefe), and GitHub reports no checks for it. After correcting the persistence ordering, please refresh it from current main and provide the required green integration checks. The performance direction is valuable; these are landing blockers, not requests to redo the measurements.

GalaxyXieyu and others added 14 commits October 5, 2026 12:30
The legacy Edit shape (`old_string` / `new_string` without `tag` / `ops`)
located the unique match by byte offset but then emitted a whole-line
`PUT first.=last:` with `new_string` as the body. `PUT N.=M` replaces whole
lines, so when `old_string` matched only part of a line the unmatched prefix
of the first line and suffix of the last line were silently deleted while the
tool still reported success (`let x = foo;` with `= foo;` -> `= bar;` became
`= bar;`). Counting `old_string.split('\n')` lines also overshot by one when
`old_string` ended in a newline, so deleting `foo\n` wiped the next line too.

Build the exact substring result instead, trim the unchanged leading and
trailing lines, and lower the difference to a single line-anchored op
(`PUT a.=b:`, `CUT a.=b`, or a `PUT >N:` insertion) that still goes through
`hashline::apply_edit` with the live tag. Only changed lines are anchored, so
the provenance gate sees exactly what changed. Exact whole-line matches produce
the same file as before; an identical replacement still fails with
`EDIT_NO_CHANGE`, and not-found / ambiguous matches still fail with
`EDIT_LEGACY_MATCH_FAILED`.

Refs vastsa#1106
Give skill invocations an explicit namespace so they can be distinguished
from ordinary slash commands while retaining the existing skill IDs and
send-time resolution.
The new Skill namespace left runtime specs and a provider acceptance scenario describing the old syntax. Align those contracts and exercise catalog generation, completion text, multiple mentions, and send-time ID resolution with a permanent regression test.
…refix

feat(composer): Skill 命令统一使用 /skill: 前缀
The OpenAI Realtime adapter required an HTTPS base URL and the Live
WebSocket transport accepted only wss, so a Realtime-compatible server
on the user's own machine (http://127.0.0.1:8010/v1) could not be used.
ADR 0304 already lets endpoints the user typed reach loopback and LAN
over plain http under the default relaxed network mode; the Realtime
path was the one user-supplied endpoint still hard-coded to TLS.

Map an http base URL to ws and let the existing public-network guard
decide, from networkPolicy.mode, whether that plaintext hop is allowed.
Third-party Live endpoints (Gemini) keep wss only, base URLs with
credentials, a query or a fragment stay refused, and a plain ws
endpoint on a proxied route fails closed rather than being tunneled in
the clear. The scheme rules move to a small Electron-free module so
they can be tested directly.

Refs vastsa#1318
The line-anchored edit format preserves terminal newline state, so the
legacy substring adapter cannot express a terminal-newline-only change.
Pin the result in a regression test and make the compatibility contract
explicit.
…al-line

fix(tools): preserve unmatched text in partial-line legacy edits
Exercise the production transport against a loopback WebSocket and keep the proxied plaintext refusal covered. Add a plain-HTTP fixture mode so the full isolated call journey can validate the user-endpoint policy.
…k-http

fix(live-voice): let Realtime use a plain-HTTP user endpoint
Transcript content is the source of truth for indexed messages. Sync a line before committing its SQLite row so a failed flush cannot leave a retry with a phantom index.

This integration keeps the contributor commits and merges the latest main, including both migration index changes.
@vastsa vastsa changed the title perf(host-core): keep history out of the append and search critical path perf(host-core): reduce history costs in search and session reads Oct 5, 2026

@vastsa vastsa left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful performance work. I verified the original durability issue and added a failing-flush-then-retry regression test. The transcript sync now completes before the derived SQLite transaction, so a failed sync cannot leave the index claiming a transcript line that was not persisted; the retry writes the line exactly once.

The append-throughput optimization and its benchmark claim have been removed from this PR because the deferred-flush design could not preserve that durability ordering. The remaining search and session-read improvements are retained. The PR integration merge ref (a701f65, tree df4c0ab) has the same executable tree as the validated task candidate.

Validation: host-core tests 779/779, workspace tests passed (desktop 3556/3556), E2E 23/23 with 2 API-key-dependent skips, changed desktop session tests 27/27, build, lint, formatting, and architecture checks passed. Desktop typecheck still reports existing LucideProps icon prop errors, reproduced on current main; the changed lines do not overlap them. Approving and merging.

@vastsa
vastsa merged commit e23cc93 into vastsa:main Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.