package main import ( "context" "google.golang.org/protobuf/proto" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/profile" ) // clusterAwareApplier is a ProfileApplier that chooses between an // in-process pool call and a remote forward based on which replica // currently owns the grain. It is the single seam at which the auth // server (`customerauth.Server`) and the UCP customer handler // (`ucp.CustomerServer`) ever speak to the grain pool — keeping the // decision of "go to the authoritative owner" vs "spawn locally" in // one place avoids the split-brain hazard introduced when multiple // code paths (HTTP middleware + the handlers' own pool.Get) could each // independently decide to spawn a stale or empty grain and broadcast // conflicting ownership for it. // // The decision rule: // // - pool.OwnerHost(id) returns a remote host → forward Get/Apply // to that host. The host holds the authoritative in-memory state. // - pool.OwnerHost(id) returns no host → delegate to // pool.Get / pool.Apply. On a local cache miss pool.Get spawns // the grain from disk on this pod, broadcasts the new ownership, // and returns the grain; this is the only code path that ever // claims a grain on this pod. // // pool.Get's spawn path is unchanged on purpose: the disk-backed event // log is the source of truth, and the local cache is just a projection // rebuilt from it. The risk surface that the applicr layer removes is // the HTTP-middleware fall-through that re-entered the same pool.Get // from a different code path and duplicated the decision. type clusterAwareApplier struct { pool *actor.SimpleGrainPool[profile.ProfileGrain] } // Get returns the current state of grain id, preferring the // authoritative remote owner when one is registered. With no remote // owner the pool spawns the grain locally (from the disk-backed event // log), takes ownership, and returns the grain. func (a *clusterAwareApplier) Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error) { if owner, ok := a.pool.OwnerHost(id); ok { return owner.Get(ctx, id) } return a.pool.Get(ctx, id) } // Apply mutates grain id with messages, forwarding to the authoritative // remote owner when one is registered. With no remote owner the pool // spawns the grain locally, applies the mutation, takes ownership, and // returns the mutated state — its listeners (including the AMQP // mutation feed) fire from this pod. func (a *clusterAwareApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) { if owner, ok := a.pool.OwnerHost(id); ok { return owner.Apply(ctx, id, msgs...) } return a.pool.Apply(ctx, id, msgs...) }