all the refactor
This commit is contained in:
+16
-81
@@ -1,38 +1,24 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/platform/uid"
|
||||
)
|
||||
|
||||
type GrainId uint64
|
||||
// GrainId is a 64-bit grain identifier with a compact base62 string form.
|
||||
type GrainId uid.ID
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
// String returns the canonical base62 encoding.
|
||||
func (id GrainId) String() string { return uid.ID(id).String() }
|
||||
|
||||
// Reverse lookup (0xFF marks invalid)
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding of the 64-bit id.
|
||||
func (id GrainId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
// MarshalJSON encodes the id as a JSON string.
|
||||
func (id GrainId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
return json.Marshal(uid.ID(id).String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
// UnmarshalJSON decodes from a base62 JSON string.
|
||||
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
@@ -40,7 +26,7 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
parsed, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id: %q", s)
|
||||
return fmt.Errorf("invalid grain id: %q", s)
|
||||
}
|
||||
*id = parsed
|
||||
return nil
|
||||
@@ -48,23 +34,11 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewGrainId() (GrainId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
id, err := uid.New()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("NewGrainId: %w", err)
|
||||
}
|
||||
u := (uint64(b[0]) << 56) |
|
||||
(uint64(b[1]) << 48) |
|
||||
(uint64(b[2]) << 40) |
|
||||
(uint64(b[3]) << 32) |
|
||||
(uint64(b[4]) << 24) |
|
||||
(uint64(b[5]) << 16) |
|
||||
(uint64(b[6]) << 8) |
|
||||
uint64(b[7])
|
||||
if u == 0 {
|
||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||
return NewGrainId()
|
||||
}
|
||||
return GrainId(u), nil
|
||||
return GrainId(id), nil
|
||||
}
|
||||
|
||||
// MustNewGrainId panics if generation fails.
|
||||
@@ -77,55 +51,16 @@ func MustNewGrainId() GrainId {
|
||||
}
|
||||
|
||||
// ParseGrainId parses a base62 string into a GrainId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseGrainId(s string) (GrainId, bool) {
|
||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||
// extensions; here we stay strict.
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return GrainId(u), true
|
||||
id, ok := uid.Parse(s)
|
||||
return GrainId(id), ok
|
||||
}
|
||||
|
||||
// MustParseGrainId panics on invalid base62 input.
|
||||
func MustParseGrainId(s string) GrainId {
|
||||
id, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
||||
panic(fmt.Sprintf("invalid grain id: %q", s))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
@@ -284,9 +284,14 @@ func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Serv
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
|
||||
// Serve runs in a goroutine because it blocks until the listener is closed.
|
||||
// On Serve error we log it instead of crashing the whole process from deep
|
||||
// inside this package — the caller (composition root) should rely on
|
||||
// process supervision (Kubernetes) to restart, or check gRPC health via the
|
||||
// pool after this returns.
|
||||
go func() {
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("failed to serve gRPC: %v", err)
|
||||
log.Printf("gRPC server %s serve error: %v", config.Addr, err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
+58
-17
@@ -1,47 +1,88 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"git.k6n.net/mats/platform/event"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// MutationExchange is the shared topic exchange carrying grain mutation streams.
|
||||
// Routing key is "mutation.<grain>" (e.g. mutation.cart), so a consumer binds
|
||||
// "mutation.#" for every grain type or "mutation.cart" for one. This is a
|
||||
// debug/observability feed (live cart/checkout/profile activity), NOT a
|
||||
// domain-event source — domain events use platform/event's vocabulary.
|
||||
const MutationExchange = "mutations"
|
||||
|
||||
// MutationType is the event.Type for a given grain's mutation stream, e.g.
|
||||
// MutationType("cart") == "mutation.cart".
|
||||
func MutationType(grain string) event.Type { return event.Type("mutation." + grain) }
|
||||
|
||||
type LogListener interface {
|
||||
AppendMutations(id uint64, msg ...ApplyResult)
|
||||
}
|
||||
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{
|
||||
conn: conn,
|
||||
transformer: transformer,
|
||||
// MutationSummary is the default feed transform: it reports the applied mutation
|
||||
// type names. The grain id and name travel in the Event's Subject/Source, so the
|
||||
// payload stays small and uniform across grains.
|
||||
func MutationSummary(_ uint64, results []ApplyResult) (any, error) {
|
||||
types := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
types = append(types, r.Type)
|
||||
}
|
||||
return map[string]any{"mutations": types}, nil
|
||||
}
|
||||
|
||||
// AmqpListener streams a grain pool's applied mutations to the MutationExchange
|
||||
// as platform/event.Event envelopes. grain names the aggregate ("cart",
|
||||
// "checkout", "profile", "order") and forms the routing key. transform shapes the
|
||||
// per-mutation payload (e.g. {cartId, mutations, ...}).
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
grain string
|
||||
typ event.Type
|
||||
transform func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, grain string, transform func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{conn: conn, grain: grain, typ: MutationType(grain), transform: transform}
|
||||
}
|
||||
|
||||
// DefineTopics declares the mutations topic exchange so publishes succeed even
|
||||
// before any consumer binds. Call once at startup.
|
||||
func (l *AmqpListener) DefineTopics() {
|
||||
ch, err := l.conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
log.Printf("mutation feed (%s): open channel: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||
if err := ch.ExchangeDeclare(MutationExchange, "topic", true, false, false, false, nil); err != nil {
|
||||
log.Printf("mutation feed (%s): declare exchange: %v", l.grain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||
data, err := l.transformer(id, msg)
|
||||
payload, err := l.transform(id, msg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to transform mutation event: %v", err)
|
||||
log.Printf("mutation feed (%s): transform: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send mutation event: %v", err)
|
||||
log.Printf("mutation feed (%s): marshal: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
ev := event.Event{
|
||||
Type: l.typ,
|
||||
Source: l.grain,
|
||||
Subject: strconv.FormatUint(id, 10),
|
||||
Payload: body,
|
||||
}
|
||||
if err := rabbit.PublishEvent(l.conn, MutationExchange, ev); err != nil {
|
||||
log.Printf("mutation feed (%s): publish: %v", l.grain, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,21 @@ type RegisteredMutation[V any, T proto.Message] struct {
|
||||
msgType reflect.Type
|
||||
}
|
||||
|
||||
// NewMutation registers a typed mutation handler for state V and message T.
|
||||
//
|
||||
// T MUST be a pointer to a generated proto message (e.g. *cart.AddLineRequest).
|
||||
// Passing a non-pointer (e.g. cart.AddLineRequest) is a developer error caught
|
||||
// at registration time — the type system on its own cannot tell a proto
|
||||
// message from a struct, so we surface the violation here. The convention is
|
||||
// the same as the broader Go MustX pattern (e.g. regexp.MustCompile): failures
|
||||
// here mean the caller is misusing the API at startup, so we panic rather than
|
||||
// silently storing an unusable handler.
|
||||
//
|
||||
// This is INTENTIONALLY not converted to a returned error: there is no
|
||||
// composition-root decision to make — the program is wrong and must not boot.
|
||||
// The composer-side registration code (see e.g. cart.NewCartMultationRegistry)
|
||||
// runs in package init / function init contexts where returning an error is
|
||||
// not possible.
|
||||
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
|
||||
// Derive the name and message type from a concrete instance produced by create().
|
||||
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
|
||||
@@ -109,8 +124,7 @@ func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredM
|
||||
if rt != nil && rt.Kind() == reflect.Pointer {
|
||||
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
||||
}
|
||||
log.Fatalf("expected to create proto message got %+v", rt)
|
||||
return nil
|
||||
panic(fmt.Sprintf("NewMutation: T must be a pointer to a proto message, got %v", rt))
|
||||
}
|
||||
instance := create()
|
||||
rt := reflect.TypeOf(instance)
|
||||
|
||||
Reference in New Issue
Block a user