Skip to content

perf: SDK read cache + in-flight dedup; FUSE content TTL 2s→30s (issue #300 solutions A+B)#303

Merged
khaliqgant merged 3 commits into
mainfrom
feat/read-cache-and-fuse-content-ttl
Jun 18, 2026
Merged

perf: SDK read cache + in-flight dedup; FUSE content TTL 2s→30s (issue #300 solutions A+B)#303
khaliqgant merged 3 commits into
mainfrom
feat/read-cache-and-fuse-content-ttl

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

Addresses solutions A and B from issue #300 (high service call volume when agents read/write through the deployed API). No server-side changes required.

Live prod signal (wrangler tail, 89 events / 43s): 8 of 11 file reads are /discovery/** schema re-reads; Go mount client accounts for 55/89 events. Both patterns are directly cut by these changes.

Solution A — SDK read cache with in-flight deduplication

  • FileReadCache class added to client.ts, stored per-client via WeakMap<RelayFileClient, FileReadCache> (same pattern as changeLogSettings)
  • Default: 5s TTL, max 500 entries (LRU eviction)
  • In-flight dedup: concurrent reads for the same {workspaceId}:{path} share one outstanding Promise — no parallel duplicate requests
  • Event-driven eviction: active change streams (subscribe/open) evict the cache entry immediately on file.updated, file.created, file.deleted — TTL is only a fallback for paths with no active subscription
  • Fork bypass: forkId-scoped reads skip the cache (fork state is isolated)
  • Opt-out: readCache: false in RelayFileClientOptions
  • New exported type: RelayFileReadCacheOptions from @relayfile/sdk
// Before: 5 HTTP GETs for the same schema file
for (let i = 0; i < 5; i++) {
  await client.readFile({ workspaceId, path: "/discovery/linear/schema.json" });
}

// After: 1 HTTP GET, 4 cache hits

Solution B — FUSE content cache TTL decoupled from kernel attrTTL

  • Config.ContentTTL added to mountfuse.Config (default 30s, previously shared 2s attrTTL)
  • AttrTTL (kernel attribute cache) stays at 2s — stat() still refreshes quickly
  • Only the in-memory content blob uses the longer TTL — safe because WSInvalidator already evicts on remote change
  • Configurable: --fuse-content-ttl flag or RELAYFILE_MOUNT_FUSE_CONTENT_TTL env var

Impact projection

Scenario Before After
Agent reads schema file 5× per session 5 HTTP GETs 1 GET + 4 cache hits
10 change events for same path 10 readFile calls 1 in-flight + 9 deduped
FUSE agent reads file every 10s 1 fetch/2s 1 fetch/30s
2 concurrent subscribers, same file 2 parallel GETs 1 (in-flight dedup)

From wrangler tail data: combined projected ~3–4× reduction on fs/file volume.

Drift note

RelayFileReadCacheOptions is now the canonical SDK type. Cloud should consume via @relayfile/sdk — not re-implement caching locally. A follow-up cloud PR (solutions C+D: bulk-read + ETag/304) is in progress separately.

Test plan

  • TypeScript type-check: npx tsc --noEmit — clean
  • SDK tests: 204 tests pass (vitest run)
  • Go build: go build ./internal/mountfuse/... ./cmd/relayfile-mount/... — clean
  • FUSE unit tests: go test ./internal/mountfuse/... — pass
  • Integration: mount a workspace, read the same file 5× and confirm 1 HTTP request in server logs
  • FUSE integration: mount with --fuse and --fuse-content-ttl=60s, confirm content served from cache between reads

Closes #300 (solutions A and B).

🤖 Generated with Claude Code

Review in cubic

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@khaliqgant, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 39 minutes and 32 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: abe80c9c-b272-4593-8e42-b16ff75eb07b

📥 Commits

Reviewing files that changed from the base of the PR and between bfa2957 and 42c7a39.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json
  • packages/sdk/typescript/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (1)
  • packages/sdk/typescript/src/client.ts
📝 Walkthrough

Walkthrough

Adds a client-side FileReadCache to the TypeScript SDK's RelayFileClient with TTL eviction, entry capping, in-flight deduplication, and event-driven cache invalidation on file mutations. Separately, decouples the FUSE filesystem's in-memory file content cache TTL from the kernel attribute TTL by introducing a dedicated ContentTTL field, defaulting to 30 seconds.

Changes

TypeScript SDK: FileReadCache

Layer / File(s) Summary
ReadCache options type and public export
packages/sdk/typescript/src/types.ts, packages/sdk/typescript/src/client.ts, packages/sdk/typescript/src/index.ts
Defines RelayFileReadCacheOptions with ttlMs and maxEntries fields, adds readCache?: false | RelayFileReadCacheOptions to RelayFileClientOptions, and re-exports the new type from the SDK barrel.
FileReadCache implementation and readFile integration
packages/sdk/typescript/src/client.ts
Implements FileReadCache class with TTL expiry, entry capping, and in-flight promise deduplication; initializes it in RelayFileClient constructor; updates readFile to return cache hits or shared in-flight promises for non-fork reads; evicts entries on file.updated/file.created/file.deleted sync events.

Go FUSE: ContentTTL Decoupling

Layer / File(s) Summary
ContentTTL in Config, fsState, and putFile
internal/mountfuse/fs.go
Adds defaultContentTTL constant, ContentTTL field to Config, contentTTL field to fsState, initializes it in newFSState with fallback to default, and changes putFile to use contentTTL instead of attrTTL for file cache expiry.
CLI flag and mount config wiring
cmd/relayfile-mount/main.go, cmd/relayfile-mount/fuse_mount.go
Adds fuseContentTTL to mountConfig, registers --fuse-content-ttl CLI flag with env RELAYFILE_MOUNT_FUSE_CONTENT_TTL, assigns the resolved value, and passes it as ContentTTL in mountfuse.Config at mount time.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant RelayFileClient
  participant FileReadCache
  participant SyncStream
  participant Server

  Caller->>RelayFileClient: readFile(workspaceId, path)
  RelayFileClient->>FileReadCache: get("workspaceId:path")
  alt TTL cache hit
    FileReadCache-->>RelayFileClient: cached content
    RelayFileClient-->>Caller: content (no HTTP)
  else in-flight dedup
    FileReadCache-->>RelayFileClient: existing Promise
    RelayFileClient-->>Caller: shared Promise
  else cache miss
    RelayFileClient->>Server: GET /fs/file
    Server-->>RelayFileClient: file content
    RelayFileClient->>FileReadCache: set(key, content, TTL)
    RelayFileClient-->>Caller: content
  end

  SyncStream-->>RelayFileClient: file.updated / file.created / file.deleted
  RelayFileClient->>FileReadCache: evict("workspaceId:path")
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 Hoppy news from the burrow today,
No more fetching the same file away!
A cache holds your reads with a TTL tight,
While FUSE content lingers for thirty-second delight.
Duplicates silenced, the server can rest —
One promise shared is simply the best! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: SDK read cache with in-flight deduplication and FUSE content TTL improvement, matching the primary objectives.
Description check ✅ Passed The description is well-structured and directly relates to the changeset, explaining the rationale, implementation details, and impact of both solutions A and B.
Linked Issues check ✅ Passed The PR implements solutions A and B from issue #300 as specified. Solution A adds SDK read cache with in-flight deduplication and event-driven invalidation; solution B decouples and raises FUSE content TTL to 30s while maintaining 2s kernel attrTTL.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #300 solutions A and B. No unrelated modifications or scope creep detected in TypeScript SDK, FUSE mount, or CLI components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/read-cache-and-fuse-content-ttl

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 018c5702dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/mountsync/syncer.go Outdated
Comment on lines 3876 to 3880
// Transient HTTP error (503, 429, etc.): skip this path for
// the current cycle. The cursor-based resume on the next
// cycle will retry it without restarting from scratch.
s.logf("transient error reading %s (status %d); skipping for this cycle: %v", result.RemotePath, httpErr.StatusCode, result.Err)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not complete bootstrap after transient read failures

When ListTree succeeds but a per-file ReadFile returns a non-403 HTTP error such as 429 or 503, this branch logs and continues without adding result.RemotePath to remotePaths or preserving a retry cursor for that file. If the traversal started from an empty cursor, the code later treats the snapshot as authoritative, runs snapshot deletes, and marks bootstrap complete, so an existing local copy of the skipped path can be deleted and the next cycle will not retry it. Propagate retryable HTTP errors, or otherwise keep the path out of the authoritative delete/complete path, rather than treating them like denied reads.

Useful? React with 👍 / 👎.

Comment on lines +1481 to +1485
if (cache && cacheKey) {
const hit = cache.get(cacheKey);
if (hit) return hit;
const pending = cache.getInFlight(cacheKey);
if (pending) return pending;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Evict cached reads when SDK writes mutate paths

With readCache enabled by default, this cache-hit path can return a cached FileReadResponse even after the same RelayFileClient has had a writeFile, bulkWrite, or deleteFile request accepted for that workspace/path, because those mutation methods do not evict fileReadCaches unless an active change stream later delivers an event. In read→write/update/delete→read flows without an active stream, callers can keep seeing stale content for ttlMs (5s by default) instead of the state the server would now return; evict the affected keys when mutations are issued or accepted.

Useful? React with 👍 / 👎.

Solutions A and B from issue #300:

SDK FileReadCache (client.ts):
- In-process LRU cache keyed by workspaceId:path, default 5s TTL, max 500
  entries. In-flight deduplication: concurrent reads for the same key share
  one outstanding Promise instead of issuing parallel requests.
- Active change streams evict cache entries immediately on file.updated /
  file.created / file.deleted, so TTL expiry is only a fallback.
- Fork-scoped reads (forkId set) bypass the cache — fork state is isolated.
- Opt-out: pass readCache: false to RelayFileClientOptions.
- New type RelayFileReadCacheOptions exported from @relayfile/sdk.

FUSE content TTL (fs.go, main.go, fuse_mount.go):
- Config.ContentTTL decoupled from AttrTTL. Default 30s vs previous 2s.
  AttrTTL (kernel attribute cache) stays at 2s for fast stat; only the
  in-memory content blob is extended.
- WSInvalidator already evicts on remote change so longer TTL does not
  increase staleness risk.
- Configurable via --fuse-content-ttl flag or RELAYFILE_MOUNT_FUSE_CONTENT_TTL
  env var for operator tuning.

Live prod impact (wrangler tail sample, 89 events / 43s):
- 8 of 11 file reads are /discovery/** schema re-reads → SDK cache collapses
  repeated reads to 1 per TTL window.
- Go mount client = 55/89 events → FUSE TTL 2s→30s reduces re-fetches ~15x.
- Combined projected reduction: ~3-4x on fs/file volume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@khaliqgant khaliqgant force-pushed the feat/read-cache-and-fuse-content-ttl branch from 018c570 to bfa2957 Compare June 18, 2026 05:30
Root and standalone SDK lockfiles both had mount binary packages pinned
at 0.9.6 while package.json requires 0.10.0, causing `npm ci` to fail
in Contract CI. Updated all four platform binaries in both lockfiles
with registry-verified integrity hashes for 0.10.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown

Relayfile Eval Review

Run: .relayfile/evals/runs/2026-06-18T05-55-14-547Z-HEAD-provider
Mode: provider
Git SHA: 035e99d

Passed: 4 | Needs human: 0 | Reviewable: 0 | Missing output: 0 | Failed: 0 | Skipped: 0

Human Review Cases

No reviewable human-review cases captured Relayfile output.

Without this, a read→write→read sequence on the same client without an
active change stream could return stale cached content for up to ttlMs
(default 5s). Now cache entries for mutated paths are evicted immediately
when the server accepts the write, matching the behaviour callers expect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@khaliqgant khaliqgant merged commit e95e61d into main Jun 18, 2026
9 checks passed
@khaliqgant khaliqgant deleted the feat/read-cache-and-fuse-content-ttl branch June 18, 2026 07:34
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.

High service call volume when agents read/write through the deployed API

1 participant