Skip to content

perf(run): do file-backend I/O on the main thread (LAB-12103) - #45

Merged
27Bslash6 merged 1 commit into
mainfrom
lab-12103-inline-file-io
Nov 1, 2026
Merged

27Bslash6 merged 1 commit into
mainfrom
lab-12103-inline-file-io

Conversation

@27Bslash6

Copy link
Copy Markdown
Contributor

A file-backend ck run hit no longer starts a thread. This PR enables cachekit-rs's unsync feature, so the file backend reads and writes inline on ck's main thread instead of handing each operation to tokio's blocking pool. Before, a one-shot process had no pooled thread to reuse, so every hit paid one clone3 to read one entry. On ubuntu-latest the hit median falls from 1,082 µs to 946 µs (−12.6%), and the ck --version floor stays put.

Change

  • Cargo.toml: add unsync to the cachekit-rs features. Cargo.lock is unchanged: features are not recorded there, and unsync pulls in no crate.
  • tests/saas.rs: the fake backend's impl becomes #[async_trait(?Send)], which matches the Backend trait under unsync.
  • tests/run.rs: helper_threads_block_the_handled_signals now expects exactly one helper thread on a file-backend miss, the signal thread, and still checks that it blocks HUP, INT, QUIT and TERM. It used to expect at least two (the signal thread and a blocking-pool thread).
  • tests/run.rs: new test output_past_20_mib_is_streamed_while_the_command_runs. The command writes 21 MiB, sleeps 3 s, then touches a file. The test reads all 21 MiB and asserts the file does not exist yet, so the output past the cap reached the caller while the command was still running. A mutant collect that holds output until EOF fails it.
  • Three comments now match the code: src/store.rs (why a runtime still exists), src/child.rs (which threads ck starts) and tests/bench.rs (a hit does not list the cache directory).

ck's src/ needs no code change: it already drives the backend from one current-thread runtime and never sends a backend future or error across threads.

Miss path: nothing new can stall the command

While the command runs, ck does no backend I/O: main reads the pipe and holds output up to the 20 MiB cap, then streams. Only the reads before the run and the write-back after it move onto main. Before, main already blocked on each of those operations (block_on awaited the blocking task), so they take the same time as before. That includes the first write's sweep for orphaned temp files, which lists the cache directory. The new streaming test pins this down.

Measurements (GitHub-hosted, interleaved, A/A floor)

Three arms, each run from one directory under file names of equal length: A1 is base (84aa440), A2 is a byte-identical copy of base, and B is this branch. Every round runs all three in shuffled order. There are 200 warm-up rounds, then 3,000 timed rounds per arm for a hit (ck run --ttl 1d -- true on a warm entry) and 1,000 for ck --version. The differences are per-round paired medians with bootstrap 95% CIs. The run used a throwaway probe branch, since deleted: actions run 41196317736.

ubuntu-latest, AMD EPYC 7763 (4 vCPU), rustc 1.99.1

A1 median A2 median B median A/A (A2−A1) A/B (B−A1)
hit 1,082.1 µs 1,081.8 µs 945.8 µs −0.3 µs [−1.6, 0.8] −136.7 µs [−137.9, −135.2], −12.6%
--version 821.1 µs 821.6 µs 819.9 µs +0.5 µs [−2.4, 3.3] +0.5 µs [−2.2, 3.4]

Above the process-start floor, a hit went from about 261 µs to about 126 µs.

macos-latest, Apple M1 (Virtual, 3 CPU), rustc 1.99.1

A1 median A2 median B median A/A (A2−A1) A/B (B−A1)
hit 3,217.9 µs 3,221.4 µs 3,086.4 µs +1.8 µs [−10.2, 16.9] −106.0 µs [−119.5, −95.4], −4.1%
--version 2,913.9 µs 2,905.5 µs 2,875.3 µs +2.2 µs [−22.3, 34.8] −36.7 µs [−69.9, −15.4]

On macOS, --version also moved (−37 µs, with a CI that excludes zero) although its code path is unchanged. That is probably binary layout, so roughly a third of the macOS hit gain may be layout rather than the removed thread.

strace on a hit (ubuntu-latest, same run): base makes one clone3 per hit, and this branch makes none.

== base: 1 clone/fork calls
7006  clone3({flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, exit_signal=0, stack=0x7f9d871ff000, stack_size=0x1ffe80, tls=0x7f9d873ff6c0} => {parent_tid=[7007]}, 88) = 7007
ck: debug: served fresh: backend=file program="true" entry=232b773c6eed age=8ms exit=0 elapsed=1ms
== head: 0 clone/fork calls
Probe harness (probe/hit.py, not part of this PR)
"""Interleaved hit-path timing: base vs a byte-identical copy of base (A/A)
and base vs head (A/B). Throwaway measurement probe; not for merge.

usage: hit.py <base-binary> <head-binary> <rounds>
"""

import json
import os
import random
import shutil
import statistics
import subprocess
import sys
import tempfile
import time

base, head, rounds = sys.argv[1], sys.argv[2], int(sys.argv[3])
# Every arm runs from the same directory under a name of the same length, so
# only the bytes differ (A2 is a copy of base: the A/A floor).
work = tempfile.mkdtemp()
arms = {arm: os.path.join(work, f"ck-{arm.lower():0<2}") for arm in ("A1", "A2", "B")}
for arm, src in (("A1", base), ("A2", base), ("B", head)):
    shutil.copy2(src, arms[arm])
env0 = {k: v for k, v in os.environ.items() if not k.startswith(("CK_", "CACHEKIT_"))}
homes = {arm: tempfile.mkdtemp() for arm in arms}
HIT = ["run", "--ttl", "1d", "--", "true"]
VERSION = ["--version"]
devnull = [
    (os.POSIX_SPAWN_OPEN, 1, os.devnull, os.O_WRONLY, 0),
    (os.POSIX_SPAWN_OPEN, 2, os.devnull, os.O_WRONLY, 0),
]


def sample(arm, args):
    exe = arms[arm]
    env = dict(env0, HOME=homes[arm])
    t0 = time.perf_counter_ns()
    pid = os.posix_spawn(exe, [exe, *args], env, file_actions=devnull)
    _, status = os.waitpid(pid, 0)
    t1 = time.perf_counter_ns()
    if os.waitstatus_to_exitcode(status) != 0:
        sys.exit(f"{arm} {args} failed: {status}")
    return (t1 - t0) / 1000


for arm, exe in arms.items():
    env = dict(env0, HOME=homes[arm])
    subprocess.run([exe, *HIT], env=env, check=True)
    debug = subprocess.run([exe, *HIT], env=dict(env, CK_LOG="debug"), check=True, capture_output=True, text=True)
    if "served fresh" not in debug.stderr:
        sys.exit(f"{arm} did not hit: {debug.stderr}")


def interleave(args, n, warmup):
    times = {arm: [] for arm in arms}
    for i in range(warmup + n):
        order = list(arms)
        random.shuffle(order)
        for arm in order:
            t = sample(arm, args)
            if i >= warmup:
                times[arm].append(t)
    return times


def paired(a, b, resamples=2000):
    """Median of per-round differences b - a, with a bootstrap 95% CI."""
    diffs = [y - x for x, y in zip(a, b)]
    meds = sorted(statistics.median(random.choices(diffs, k=len(diffs))) for _ in range(resamples))
    return {
        "median_diff_us": round(statistics.median(diffs), 1),
        "ci95_us": [round(meds[int(0.025 * resamples)], 1), round(meds[int(0.975 * resamples)], 1)],
    }


def report(times):
    med = {arm: statistics.median(ts) for arm, ts in times.items()}
    return {
        "n_per_arm": len(times["A1"]),
        "median_us": {arm: round(m, 1) for arm, m in med.items()},
        "aa_A2_vs_A1": {**paired(times["A1"], times["A2"]), "pct": round(100 * (med["A2"] / med["A1"] - 1), 2)},
        "ab_B_vs_A1": {**paired(times["A1"], times["B"]), "pct": round(100 * (med["B"] / med["A1"] - 1), 2)},
        "ab_B_vs_A2": {**paired(times["A2"], times["B"]), "pct": round(100 * (med["B"] / med["A2"] - 1), 2)},
    }


result = {
    "hit": report(interleave(HIT, rounds, 200)),
    "version_floor": report(interleave(VERSION, rounds // 3, 100)),
}
print(json.dumps(result, indent=2))

Scope and risk

  • unsync is crate-wide. The backend traits become ?Send, BackendError loses Send/Sync, and the high-level client swaps Arc for Rc. ck uses Backend, FileBackend, CachekitIO and EncryptionLayer directly, never the client, and it compiles unchanged.
  • cachekit-rs CI does not build unsync together with file. This repo's file-backend tests now exercise that combination: lib 22, run 46, saas 17, readme 1.
  • The saas backend keeps its runtime and its blocking pool for DNS lookups. Its ?Send build gives the same result as base: both binaries print the same 401 message against the live API with a bogus key and then run uncached.
  • Sibling paths: every file-backend command goes through the same Store, so each one gets the same change. The saas path is deliberately unchanged.

Checks (local, as CI runs them)

  • cargo fmt --all -- --check: pass
  • cargo clippy --locked --all-targets -- -D warnings: pass
  • cargo test --locked: lib 22, readme 1, run 46, saas 17 passed; bench 1 ignored; 0 failed
  • cargo test --locked --release --test bench -- --ignored: pass
  • cargo +1.91 check --locked --all-targets (MSRV): pass
  • cargo deny check: advisories, bans, licenses, sources ok

No docs change: user-visible behaviour is unchanged apart from speed. The README makes no latency or threading claim, and the changelog entry comes from the perf: commit.

Closes LAB-12103

Enable cachekit-rs's `unsync` feature. Its file backend then runs each
read and write inline instead of on tokio's blocking pool, so a hit no
longer starts a thread just to read one entry. ck already drives the
backend from one current-thread runtime, so no source change is needed.

The fake saas backend's impl becomes `async_trait(?Send)` to match the
trait under `unsync`. The helper-thread test now expects exactly the
signal thread on a file-backend miss. A new test proves output past the
capture cap still streams while the command runs: a miss does no backend
I/O until the command exits.
@kodus-27b

kodus-27b Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the `@kody start-review` command at the root of your PR.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Providing Context (Files & MCPs)

Add these hints in your PR description (or a comment) to unlock deeper checks:

  • Ticket / Acceptance Criteria: `Refs: ABC-123` (Linear/Jira/Asana/ClickUp/Trello) or a direct ticket link.
  • Bugfix Validation: a Sentry/Datadog/Bugsnag event link (or paste the stack trace/error message).
  • Endpoint Risk: mention the route (e.g., `POST /api/payments`) or controller/action name.
  • Attach a repo file as context: use an explicit marker like `@file:docs/guide.mdx#L10-L50` (replace with your real path).
  • API Contract Docs: include `@file:openapi.yaml` or `@file:swagger.json` when changing routes/schemas.
  • Definition of Done / Standards: include `@file:DOD.md` or `@file:CONTRIBUTING.md` if your repo has them.
  • Design System Source of Truth: include `@file:ui/index.ts` (replace with your DS entrypoint path).
  • Feature Flags: include the flag key/name and `@file:flags.ts` / `@file:config.json` (and optionally the PostHog flag name).
  • Edge/CDN Rules: link the Cloudflare rule/zone or describe the intended redirect/header behavior.
  • Attach an MCP tool output: use `@mcp<provider|tool>` (replace with an installed MCP provider + tool, e.g., `@mcp<sentry|events.search>`).
Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug ✅
Performance ✅
Security ✅
Business Logic ✅

Access your configuration settings here.

@27Bslash6
27Bslash6 merged commit 1b465d7 into main Nov 1, 2026
5 checks passed
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.

1 participant