fix(store): Implement version cache invalidation strategy for JetStreamEventStore
Some checks failed
CI / build (pull_request) Successful in 19s
CI / integration (pull_request) Failing after 2m0s

Implements cache invalidation on GetLatestVersion when external writers modify the
JetStream stream. The strategy ensures consistency in multi-store scenarios while
maintaining performance for the single-writer case.

Changes:
- Add cache invalidation logic to GetLatestVersion() that detects stale cache
- Document version cache behavior in JetStreamEventStore struct comment
- Add detailed documentation in CLAUDE.md about cache invalidation strategy
- Add TestJetStreamEventStore_CacheInvalidationOnExternalWrite integration test
- Cache is invalidated by deleting entry, forcing fresh fetch on next check

The implementation follows the acceptance criteria by:
1. Documenting the single-writer assumption in code comments
2. Implementing cache invalidation on GetLatestVersion miss
3. Adding comprehensive test for external write scenarios

Closes #126

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-01-13 00:24:27 +01:00
parent 271f5db444
commit 6de897ef60
3 changed files with 171 additions and 1 deletions

View File

@@ -40,6 +40,24 @@ func DefaultJetStreamConfig() JetStreamConfig {
// JetStreamEventStore implements EventStore using NATS JetStream for persistence.
// It also implements EventStoreWithErrors to report malformed events during replay.
//
// ## Version Cache Invalidation Strategy
//
// JetStreamEventStore maintains an in-memory cache of actor versions for optimistic
// concurrency control. The cache is invalidated on any miss (GetLatestVersion call
// that finds a newer version in JetStream) to ensure consistency even when external
// processes write to the same JetStream stream.
//
// If only Aether owns the stream (single-writer assumption), the cache provides
// excellent performance for repeated version checks. If external writers modify
// the stream, the cache will remain consistent because:
//
// 1. On SaveEvent: getLatestVersionLocked() checks JetStream on cache miss
// 2. On GetLatestVersion: If actual version > cached version, cache is invalidated
// 3. Subsequent checks for that actor will fetch fresh data from JetStream
//
// This strategy prevents data corruption from stale cache while maintaining
// performance for the single-writer case.
type JetStreamEventStore struct {
js nats.JetStreamContext
streamName string
@@ -48,6 +66,15 @@ type JetStreamEventStore struct {
versions map[string]int64 // actorID -> latest version cache
}
// NewJetStreamEventStore creates a new JetStream-based event store with default configuration
func NewJetStreamEventStore(natsConn *nats.Conn, streamName string) (*JetStreamEventStore, error) {
return NewJetStreamEventStoreWithConfig(natsConn, streamName, DefaultJetStreamConfig())
@@ -276,7 +303,10 @@ func (jes *JetStreamEventStore) getEventsWithErrorsInternal(actorID string, from
return result, nil
}
// GetLatestVersion returns the latest version for an actor
// GetLatestVersion returns the latest version for an actor, invalidating cache
// if the actual version in JetStream is newer than cached version.
// This strategy ensures cache consistency even if external processes write to
// the same JetStream stream.
func (jes *JetStreamEventStore) GetLatestVersion(actorID string) (int64, error) {
events, err := jes.GetEvents(actorID, 0)
if err != nil {
@@ -294,6 +324,18 @@ func (jes *JetStreamEventStore) GetLatestVersion(actorID string) (int64, error)
}
}
// Invalidate cache if actual version differs from cached version
// This handles the case where external writers modify the stream
jes.mu.Lock()
if cachedVersion, ok := jes.versions[actorID]; ok && latestVersion > cachedVersion {
// Cache was stale - invalidate it by deleting
delete(jes.versions, actorID)
} else if !ok {
// Update cache for future calls
jes.versions[actorID] = latestVersion
}
jes.mu.Unlock()
return latestVersion, nil
}

View File

@@ -1322,6 +1322,110 @@ func TestJetStreamEventStore_MultipleStoreInstances(t *testing.T) {
}
}
// === Cache Invalidation Tests ===
func TestJetStreamEventStore_CacheInvalidationOnExternalWrite(t *testing.T) {
nc := getTestNATSConnection(t)
defer nc.Close()
streamName := uniqueStreamName("test-cache-invalidation")
defer cleanupStream(nc, streamName)
// Create two stores for the same stream
store1, err := NewJetStreamEventStore(nc, streamName)
if err != nil {
t.Fatalf("failed to create store1: %v", err)
}
store2, err := NewJetStreamEventStore(nc, streamName)
if err != nil {
t.Fatalf("failed to create store2: %v", err)
}
actorID := "actor-cache-test"
// store1: Save event v1 (caches version 1)
event1 := &aether.Event{
ID: "evt-1",
EventType: "TestEvent",
ActorID: actorID,
Version: 1,
Data: map[string]interface{}{},
Timestamp: time.Now(),
}
if err := store1.SaveEvent(event1); err != nil {
t.Fatalf("SaveEvent from store1 failed: %v", err)
}
// Verify store1 sees version 1 (uses cache)
v1, err := store1.GetLatestVersion(actorID)
if err != nil {
t.Fatalf("GetLatestVersion from store1 failed: %v", err)
}
if v1 != 1 {
t.Errorf("store1 should see version 1, got %d", v1)
}
// store2: Save event v2 (external write from store1's perspective)
event2 := &aether.Event{
ID: "evt-2",
EventType: "TestEvent",
ActorID: actorID,
Version: 2,
Data: map[string]interface{}{},
Timestamp: time.Now(),
}
if err := store2.SaveEvent(event2); err != nil {
t.Fatalf("SaveEvent from store2 failed: %v", err)
}
// store1: GetLatestVersion should detect external write and return v2
// (This triggers cache invalidation because actual version > cached version)
v2, err := store1.GetLatestVersion(actorID)
if err != nil {
t.Fatalf("GetLatestVersion from store1 (after external write) failed: %v", err)
}
if v2 != 2 {
t.Errorf("store1 should see version 2 after external write, got %d", v2)
}
// store2: Save event v3 (another external write)
event3 := &aether.Event{
ID: "evt-3",
EventType: "TestEvent",
ActorID: actorID,
Version: 3,
Data: map[string]interface{}{},
Timestamp: time.Now(),
}
if err := store2.SaveEvent(event3); err != nil {
t.Fatalf("SaveEvent from store2 (v3) failed: %v", err)
}
// store1: After cache invalidation, SaveEvent should use fresh data from JetStream
event4 := &aether.Event{
ID: "evt-4",
EventType: "TestEvent",
ActorID: actorID,
Version: 4,
Data: map[string]interface{}{},
Timestamp: time.Now(),
}
if err := store1.SaveEvent(event4); err != nil {
t.Fatalf("SaveEvent from store1 (after cache invalidation) failed: %v", err)
}
// Verify all 4 events are persisted
events, err := store1.GetEvents(actorID, 0)
if err != nil {
t.Fatalf("GetEvents failed: %v", err)
}
if len(events) != 4 {
t.Errorf("expected 4 events after cache invalidation, got %d", len(events))
}
}
// === Interface Compliance Tests ===
func TestJetStreamEventStore_ImplementsEventStore(t *testing.T) {