Files
aether/CLAUDE.md
Hugo Nijhuis 02847bdaf5
All checks were successful
CI / build (pull_request) Successful in 16s
CI / build (push) Successful in 15s
Add event versioning validation
- Add ErrVersionConflict error type and VersionConflictError for detailed
  conflict information
- Implement version validation in InMemoryEventStore.SaveEvent that rejects
  events with version <= current latest version
- Implement version validation in JetStreamEventStore.SaveEvent with version
  caching for performance
- Add comprehensive tests for version conflict detection including concurrent
  writes to same actor
- Document versioning semantics in EventStore interface and CLAUDE.md

This ensures events have monotonically increasing versions per actor and
provides clear error messages for version conflicts, enabling optimistic
concurrency control patterns.

Closes #6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 17:56:50 +01:00

4.4 KiB

Aether

Distributed actor system with event sourcing for Go, powered by NATS.

Organization Context

This repo is part of Flowmade. See:

Setup

git clone git@git.flowmade.one:flowmade-one/aether.git
cd aether
go mod download

Requires NATS server for integration tests:

# Install NATS
brew install nats-server

# Run with JetStream enabled
nats-server -js

Project Structure

aether/
├── event.go           # Event, ActorSnapshot, EventStore interface
├── eventbus.go        # EventBus, EventBroadcaster interface
├── nats_eventbus.go   # NATSEventBus - cross-node event broadcasting
├── store/
│   ├── memory.go      # InMemoryEventStore (testing)
│   └── jetstream.go   # JetStreamEventStore (production)
├── cluster/
│   ├── manager.go     # ClusterManager
│   ├── discovery.go   # NodeDiscovery
│   ├── hashring.go    # ConsistentHashRing
│   ├── shard.go       # ShardManager
│   ├── leader.go      # LeaderElection
│   └── types.go       # Cluster types
└── model/
    └── model.go       # EventStorming model types

Development

make build    # Build the library
make test     # Run tests
make lint     # Run linters

Architecture

Event Sourcing

Events are the source of truth. State is derived by replaying events.

// Create an event
event := &aether.Event{
    ID:        uuid.New().String(),
    EventType: "OrderPlaced",
    ActorID:   "order-123",
    Version:   1,
    Data:      map[string]interface{}{"total": 100.00},
    Timestamp: time.Now(),
}

// Persist to event store
store.SaveEvent(event)

// Replay events to rebuild state
events, _ := store.GetEvents("order-123", 0)

Event Versioning

Events for each actor must have monotonically increasing versions. This ensures event stream integrity and enables optimistic concurrency control.

Version Semantics

  • Each actor has an independent version sequence
  • Version must be strictly greater than the current latest version
  • For new actors (no events), the first event must have version > 0
  • Non-consecutive versions are allowed (gaps are permitted)

Optimistic Concurrency Pattern

// 1. Get current version
currentVersion, _ := store.GetLatestVersion("order-123")

// 2. Create event with next version
event := &aether.Event{
    ID:        uuid.New().String(),
    EventType: "OrderUpdated",
    ActorID:   "order-123",
    Version:   currentVersion + 1,
    Data:      map[string]interface{}{"status": "shipped"},
    Timestamp: time.Now(),
}

// 3. Attempt to save
err := store.SaveEvent(event)
if errors.Is(err, aether.ErrVersionConflict) {
    // Another writer won - reload and retry if appropriate
    var versionErr *aether.VersionConflictError
    errors.As(err, &versionErr)
    log.Printf("Conflict: actor %s has version %d, attempted %d",
        versionErr.ActorID, versionErr.CurrentVersion, versionErr.AttemptedVersion)
}

Error Types

  • ErrVersionConflict - Sentinel error for version conflicts (use with errors.Is)
  • VersionConflictError - Detailed error with ActorID, CurrentVersion, and AttemptedVersion

Namespace Isolation

Namespaces provide logical boundaries for events and subscriptions:

// Subscribe to events in a namespace
ch := eventBus.Subscribe("tenant-abc")

// Events are isolated per namespace
eventBus.Publish("tenant-abc", event)  // Only tenant-abc subscribers see this

Clustering

Aether handles node discovery, leader election, and shard distribution:

// Create cluster manager
manager := cluster.NewClusterManager(natsConn, nodeID)

// Join cluster
manager.Start()

// Leader election happens automatically
if manager.IsLeader() {
    // Coordinate shard assignments
}

Key Patterns

  • Events are immutable - Never modify, only append
  • Versions are monotonic - Each event must have version > previous for same actor
  • Snapshots for performance - Periodically snapshot state to avoid full replay
  • Namespaces for isolation - Not multi-tenancy, just logical boundaries
  • NATS for everything - Events, pub/sub, clustering all use NATS