Skip to content

perf: cache package.json files#7

Merged
bennypowers merged 3 commits into
mainfrom
fix/2-pkgjson-cache
Jan 14, 2026
Merged

perf: cache package.json files#7
bennypowers merged 3 commits into
mainfrom
fix/2-pkgjson-cache

Conversation

@bennypowers

@bennypowers bennypowers commented Jan 14, 2026

Copy link
Copy Markdown
Owner

Fixes #2

Summary by CodeRabbit

  • New Features

    • Thread-safe in-memory cache for parsed package.json data with an atomic load-or-get operation and public constructor.
    • Resolver accepts an injected package.json cache so resolutions can reuse parsed data (improves performance for monorepos/hot-reload).
  • Tests

    • Comprehensive tests for Get/Set/Invalidate, concurrent load-or-get behavior, and resolver cache population and reuse.

✏️ Tip: You can customize this high-level summary in your review settings.

@bennypowers

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jan 14, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jan 14, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a concurrency-safe package.json caching layer (Cache interface and MemoryCache) with GetOrLoad to deduplicate loads, integrates cache into the local Resolver via WithPackageCache and parsePackageJSON, and adds tests verifying functionality, concurrency, and resolver cache usage.

Changes

Cohort / File(s) Summary
Cache implementation
packagejson/cache.go
Adds Cache interface (Get, Set, Invalidate, GetOrLoad) and MemoryCache with per-path in-flight coordination, sync.Once-style loader deduplication, and NewMemoryCache() constructor.
Cache tests
packagejson/cache_test.go
New tests covering Get/Set/Invalidate, concurrency (100 goroutines), interface conformance, GetOrLoad behavior, and ensuring concurrent GetOrLoad invokes loader exactly once.
Resolver integration
resolve/local/local.go
Adds cache packagejson.Cache field, WithPackageCache(cache) builder, parsePackageJSON(path) helper that uses the cache, replaces direct packagejson.ParseFile calls across resolution paths, and refactors import-map population to use cached parsing.
Resolver tests
resolve/local/local_test.go
Adds TestResolverWithPackageCache, TestResolverWithPrepopulatedCache, and TestResolverCacheReuse to validate cache population, prepopulation usage, and consistent reuse across resolutions.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant MemoryCache
    participant Loader
    participant FileSystem

    Client->>MemoryCache: GetOrLoad(path, loader)
    MemoryCache->>MemoryCache: Check Get(path)
    alt Cached
        MemoryCache-->>Client: Return cached *PackageJSON
    else Not cached
        alt In-flight for path
            MemoryCache-->>Client: Wait for in-flight result
        else First loader for path
            MemoryCache->>Loader: invoke loader()
            Loader->>FileSystem: read package.json (if loader reads disk)
            FileSystem-->>Loader: file content
            Loader-->>MemoryCache: (*PackageJSON, error)
            MemoryCache->>MemoryCache: store result, notify waiters
            MemoryCache-->>Client: Return result or error
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I hopped across nested folders wide,
One loader called while others bide.
Maps and locks in gentle dance,
Cached treasures spared the second glance.
A carrot cheers each saved file's stride.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title concisely summarizes the main change: implementing package.json file caching for performance optimization.
Linked Issues check ✅ Passed All coding requirements from issue #2 are met: Cache interface defined with Get/Set/Invalidate methods, thread-safe MemoryCache implementation, GetOrLoad method for atomic loading, WithPackageCache builder method added, and integration into resolver paths.
Out of Scope Changes check ✅ Passed All changes directly support the caching objectives: cache implementation, cache integration into resolver, test coverage for cache and resolver caching, with no unrelated modifications.

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

✨ Finishing touches
  • 📝 Generate docstrings


📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dcaa496 and 303bf60.

📒 Files selected for processing (2)
  • packagejson/cache.go
  • packagejson/cache_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • packagejson/cache_test.go
🧰 Additional context used
🧬 Code graph analysis (1)
packagejson/cache.go (1)
packagejson/packagejson.go (1)
  • PackageJSON (32-42)
🔇 Additional comments (5)
packagejson/cache.go (5)

21-38: Well-designed cache interface.

The interface provides a clean abstraction with explicit concurrency semantics in GetOrLoad. The documentation clearly explains the purpose and behavior of each method.


40-52: LGTM!

The two-tier structure using sync.Map for in-flight coordination with per-entry sync.Once is an effective pattern for single-flighting concurrent loads.


54-74: LGTM!

Constructor and basic cache operations are correctly implemented with appropriate mutex usage.


76-82: Good fix addressing the loading map cleanup.

Invalidate now correctly removes entries from both c.cache and c.loading, ensuring fresh loads occur after invalidation.


84-115: Implementation is correct; documented design tradeoff is acceptable.

The single-flight loading pattern using sync.Once per entry correctly prevents duplicate loads. The decision to retain entries in c.loading (documented in lines 109-112) is a reasonable tradeoff—entries are small and Invalidate properly clears both maps.

One consideration: if loader returns an error, the error is effectively cached in the entry (via once.Do's one-shot semantics). Subsequent calls for the same path will return the same error without retry until Invalidate is called. This may be intentional, but worth confirming it aligns with expected behavior for transient parse failures.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packagejson/cache.go`:
- Around line 83-109: GetOrLoad leaks entries in c.loading because cacheEntry
values are never removed; after entry.once finishes (inside GetOrLoad) delete
the loading map entry for path (use c.loading.Delete(path)) so completed or
failed loads don't accumulate, and update Invalidate to also remove the path
from c.loading in addition to removing from c.cache so subsequent loads create
fresh cacheEntry objects; ensure deletion happens after entry.once.Do returns to
avoid racing the loader.
🧹 Nitpick comments (1)
packagejson/cache_test.go (1)

116-148: Data race: loadCount is accessed from a closure without synchronization.

In this single-threaded test, the race is benign because GetOrLoad is called sequentially. However, if the test were refactored or run with -race, the non-atomic increment inside the closure could be flagged. Consider using atomic.Int32 for consistency with the concurrent test.

♻️ Suggested fix using atomic counter
 func TestMemoryCacheGetOrLoad(t *testing.T) {
 	cache := packagejson.NewMemoryCache()

-	loadCount := 0
+	var loadCount atomic.Int32
 	loader := func() (*packagejson.PackageJSON, error) {
-		loadCount++
+		loadCount.Add(1)
 		return &packagejson.PackageJSON{Name: "loaded"}, nil
 	}

 	// First call should invoke loader
 	pkg, err := cache.GetOrLoad("/path/to/package.json", loader)
 	if err != nil {
 		t.Fatalf("GetOrLoad failed: %v", err)
 	}
 	if pkg.Name != "loaded" {
 		t.Errorf("Expected name 'loaded', got %q", pkg.Name)
 	}
-	if loadCount != 1 {
-		t.Errorf("Expected loader to be called once, called %d times", loadCount)
+	if loadCount.Load() != 1 {
+		t.Errorf("Expected loader to be called once, called %d times", loadCount.Load())
 	}

 	// Second call should use cached value, not invoke loader
 	pkg, err = cache.GetOrLoad("/path/to/package.json", loader)
 	if err != nil {
 		t.Fatalf("GetOrLoad failed: %v", err)
 	}
 	if pkg.Name != "loaded" {
 		t.Errorf("Expected name 'loaded', got %q", pkg.Name)
 	}
-	if loadCount != 1 {
-		t.Errorf("Expected loader to still be called once, called %d times", loadCount)
+	if loadCount.Load() != 1 {
+		t.Errorf("Expected loader to still be called once, called %d times", loadCount.Load())
 	}
 }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0c1b3b1 and dcaa496.

📒 Files selected for processing (3)
  • packagejson/cache.go
  • packagejson/cache_test.go
  • resolve/local/local.go
🧰 Additional context used
🧬 Code graph analysis (2)
resolve/local/local.go (6)
fs/fs.go (1)
  • FileSystem (29-46)
resolve/resolve.go (2)
  • Logger (36-39)
  • WorkspacePackage (42-45)
resolve/template.go (1)
  • Template (33-36)
importmap/importmap.go (1)
  • ImportMap (27-37)
packagejson/cache.go (1)
  • Cache (24-38)
packagejson/packagejson.go (2)
  • PackageJSON (32-42)
  • ParseFile (66-72)
packagejson/cache.go (1)
packagejson/packagejson.go (1)
  • PackageJSON (32-42)
🔇 Additional comments (27)
packagejson/cache_test.go (7)

27-38: LGTM!

Clean test for cache miss behavior, correctly verifying both the boolean return and nil package.


40-61: LGTM!

Good coverage of the Set/Get roundtrip with field verification.


63-81: LGTM!

Proper test flow: set → verify hit → invalidate → verify miss.


83-88: LGTM!

Good defensive test ensuring Invalidate doesn't panic on nonexistent keys.


90-109: LGTM!

Reasonable concurrency stress test. The done channel pattern works correctly for synchronization.


111-114: LGTM!

Compile-time interface conformance check is idiomatic Go.


150-177: LGTM!

Excellent concurrent test verifying the single-loader guarantee. Correct use of atomic.Int32 and sync.WaitGroup.

packagejson/cache.go (4)

24-38: LGTM!

Well-designed interface with clear documentation. The GetOrLoad method is a good addition for atomic cache-or-compute semantics.


40-52: LGTM!

Good design separating the main cache map (with RWMutex) from in-flight load coordination (with sync.Map). The cacheEntry with sync.Once ensures single-execution semantics.


54-67: LGTM!

Constructor and Get method are correctly implemented with proper locking.


69-81: LGTM!

Set and Invalidate methods correctly use exclusive locks.

resolve/local/local.go (16)

33-42: LGTM!

Clean addition of the cache field to the Resolver struct.


57-67: LGTM!

Cache field correctly propagated in WithPackages.


76-85: LGTM!

Cache field correctly propagated in WithTemplate.


91-100: LGTM!

Cache field correctly propagated in WithInputMap.


107-116: LGTM!

Cache field correctly propagated in WithWorkspacePackages.


123-132: LGTM!

Cache field correctly propagated in WithIncludeRootExports.


135-150: LGTM!

Well-documented WithPackageCache method following the established builder pattern. Correctly sets the cache field while preserving all other resolver configuration.


152-161: LGTM!

Clean helper that transparently uses GetOrLoad when cache is available, falling back to direct parsing otherwise. This centralizes the caching logic effectively.


186-194: LGTM!

Root package.json parsing now uses the cached helper.


237-243: LGTM!

Direct dependencies now use the cached parsing method.


293-302: LGTM!

Workspace package dependencies now use cached parsing.


332-336: LGTM!

Workspace node_modules dependencies now use cached parsing.


370-374: LGTM!

Workspace package export resolution now uses cached parsing.


457-502: LGTM!

Good refactoring of addPackageToImportMap to build entries locally before merging under the lock, minimizing lock contention. Cache usage is correctly integrated.


547-550: LGTM!

Transitive dependency processing now uses cached parsing.


571-575: LGTM!

Nested dependency parsing in processPackageDependenciesParallel now uses the cache.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment thread packagejson/cache.go
@bennypowers bennypowers merged commit 0c66394 into main Jan 14, 2026
1 check passed
@bennypowers bennypowers deleted the fix/2-pkgjson-cache branch January 14, 2026 10:24
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.

feat: expose package.json caching layer

1 participant