more cart
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s

This commit is contained in:
2026-07-01 22:11:37 +02:00
parent 1c97a4bdb0
commit 26fd6dc970
23 changed files with 1717 additions and 250 deletions
+66
View File
@@ -7,6 +7,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -201,6 +202,9 @@ func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
}
func (s *DiskStorage[V]) save() {
if s.queue == nil {
return
}
carts := 0
lines := 0
s.queue.Range(func(key, value any) bool {
@@ -295,3 +299,65 @@ func (s *DiskStorage[V]) openWriterCount() int {
defer s.writersMu.Unlock()
return len(s.writers)
}
func (s *DiskStorage[V]) PurgeOlderThan(ctx context.Context, retention time.Duration, isGrainActive func(id uint64) bool) (int, error) {
if err := s.ensureDir(); err != nil {
return 0, err
}
files, err := os.ReadDir(s.path)
if err != nil {
return 0, fmt.Errorf("read storage dir: %w", err)
}
cutoff := time.Now().Add(-retention)
purgedCount := 0
for _, entry := range files {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".events.log") {
continue
}
var id uint64
_, err := fmt.Sscanf(name, "%d.events.log", &id)
if err != nil {
continue
}
info, err := entry.Info()
if err != nil {
log.Printf("failed to get file info for %s: %v", name, err)
continue
}
if info.ModTime().Before(cutoff) {
// Check if grain is active in memory
if isGrainActive != nil && isGrainActive(id) {
continue
}
// Check if we have an active writer open
s.writersMu.Lock()
_, hasWriter := s.writers[id]
s.writersMu.Unlock()
if hasWriter {
continue
}
// Delete the file
fullPath := filepath.Join(s.path, name)
if err := os.Remove(fullPath); err != nil {
log.Printf("failed to delete expired cart log %s: %v", fullPath, err)
} else {
purgedCount++
}
}
}
return purgedCount, nil
}
+84
View File
@@ -2,6 +2,7 @@ package actor
import (
"context"
"os"
"sync"
"testing"
"time"
@@ -317,3 +318,86 @@ func TestDiskStorageConcurrentWritesDifferentIDs(t *testing.T) {
}
}
}
func TestDiskStoragePurge(t *testing.T) {
reg := diskTestRegistry(t)
dir := t.TempDir()
storage := NewDiskStorage[testState](dir, reg)
t.Cleanup(storage.Close)
ctx := context.Background()
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
// Write mutations to create event logs for 1, 2, 3, 4
ids := []uint64{1, 2, 3, 4}
for _, id := range ids {
if err := storage.AppendMutations(id, msg); err != nil {
t.Fatalf("append %d: %v", id, err)
}
}
// Force save to disk
storage.save()
// 1. Grain 1: Old modification time, inactive, no writer -> should be purged
path1 := storage.logPath(1)
oldTime := time.Now().Add(-10 * 24 * time.Hour)
if err := os.Chtimes(path1, oldTime, oldTime); err != nil {
t.Fatal(err)
}
// We must close the writer for grain 1, 2, 4 to make them eligible for purging (no open writer)
storage.writersMu.Lock()
for _, id := range []uint64{1, 2, 4} {
if w, ok := storage.writers[id]; ok {
w.purged = true
w.file.Close()
delete(storage.writers, id)
}
}
storage.writersMu.Unlock()
// 2. Grain 2: Old mod time, but marked active -> should NOT be purged
path2 := storage.logPath(2)
if err := os.Chtimes(path2, oldTime, oldTime); err != nil {
t.Fatal(err)
}
// 3. Grain 3: Old mod time, but has open writer -> should NOT be purged
path3 := storage.logPath(3)
if err := os.Chtimes(path3, oldTime, oldTime); err != nil {
t.Fatal(err)
}
// 4. Grain 4: Recent mod time -> should NOT be purged
path4 := storage.logPath(4)
activeGrains := map[uint64]bool{2: true}
isGrainActive := func(id uint64) bool {
return activeGrains[id]
}
purged, err := storage.PurgeOlderThan(ctx, 5*24*time.Hour, isGrainActive)
if err != nil {
t.Fatalf("PurgeOlderThan failed: %v", err)
}
if purged != 1 {
t.Errorf("Expected exactly 1 purged file, got %d", purged)
}
// Verify file status
if _, err := os.Stat(path1); !os.IsNotExist(err) {
t.Error("Expected grain 1 log to be deleted")
}
if _, err := os.Stat(path2); err != nil {
t.Error("Expected grain 2 log to exist (active)")
}
if _, err := os.Stat(path3); err != nil {
t.Error("Expected grain 3 log to exist (active writer)")
}
if _, err := os.Stat(path4); err != nil {
t.Error("Expected grain 4 log to exist (recent)")
}
}
+65
View File
@@ -15,12 +15,14 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
"git.k6n.net/mats/platform/rabbit"
amqp "github.com/rabbitmq/amqp091-go"
)
@@ -75,6 +77,69 @@ func New(cfg Config) (*App, error) {
_ = os.MkdirAll(cfg.DataDir, 0755)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
promotionsPath := os.Getenv("PROMOTIONS_FILE")
if promotionsPath == "" {
promotionsPath = "data/promotions.json"
}
if promotionStore, err := promotions.NewStore(promotionsPath); err == nil {
promotionService := promotions.NewPromotionService(nil)
reg.RegisterProcessor(
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
for _, v := range g.Vouchers {
if v != nil {
v.BypassedByPromotions = false
}
}
g.UpdateTotals()
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
hasBypassed := false
for _, res := range results {
if res.Applicable {
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
var codes []string
if s, ok := bc.Value.AsString(); ok {
codes = append(codes, strings.ToLower(s))
} else if arr, ok := bc.Value.AsStringSlice(); ok {
for _, s := range arr {
codes = append(codes, strings.ToLower(s))
}
}
for _, code := range codes {
for _, v := range g.Vouchers {
if v != nil && strings.ToLower(v.Code) == code {
if !v.BypassedByPromotions {
v.BypassedByPromotions = true
hasBypassed = true
}
}
}
}
}
return true
})
}
}
if hasBypassed {
g.UpdateTotals()
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
}
promotionService.ApplyResults(g, results, promotionCtx)
g.Version++
return nil
}),
)
} else {
log.Printf("backofficeadmin: unable to load promotions store from %s: %v", promotionsPath, err)
}
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
+27
View File
@@ -86,6 +86,25 @@ type Notice struct {
Code *string `json:"code,omitempty"`
}
// PushToken is the Go-level mirror of cart_messages.PushToken: a generic,
// provider-agnostic push-delivery target. Stored on the grain so the
// abandoned-cart recovery scanner can hand it to a notifier without
// re-decoding proto messages; the wire shape lives in proto/cart.proto.
//
// SECURITY: the raw Token persists verbatim to the per-pod event log
// (CartGrain JSON-marshals PushTokens on every mutation). Anyone with read
// access to the cart service's data dir — PVC snapshots, debug dumps, log
// archival — can extract device handles. A future hardening pass should
// either hash-on-write (hash.compareAtLaunch) for stateless matching or
// encrypt-at-rest with a key the notifier owns. v0 keeps the seam: a real
// notifier should treat the stored token as opaque, fetch any canonical
// canonicalised handle from the input side (frontend/web SDK), and use
// what's here only for routing.
type PushToken struct {
Platform string `json:"platform"`
Token string `json:"token"`
}
type CartPaymentStatus string
const (
@@ -151,6 +170,14 @@ type CartGrain struct {
Notifications []CartNotification `json:"cartNotification,omitempty"`
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
// Email is the cart's recovery contact address. Set explicitly via the
// SetRecoveryContact mutation (independent of the linked profile's email),
// so abandoned-cart recovery can fire before login. Empty string = no email.
Email string `json:"email,omitempty"`
// PushTokens holds every push delivery target the shopper attached to this
// cart. Empty list = no push. PUT-style replaced by SetRecoveryContact.
PushTokens []PushToken `json:"pushTokens,omitempty"`
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
+1
View File
@@ -88,6 +88,7 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
actor.NewMutation(SetLineItemCustomFields),
actor.NewMutation(SubscriptionAdded),
actor.NewMutation(context.SetCartType),
actor.NewMutation(SetRecoveryContact),
// actor.NewMutation(SubscriptionRemoved),
)
return reg
+52
View File
@@ -0,0 +1,52 @@
package cart
import (
"errors"
"log"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// SetRecoveryContact replaces the entire recovery-contact bundle on a cart in
// a single event. PUT-style semantics — empty email and an empty token list
// both persist as "no contact attached"; callers wanting to clear should send
// an empty bundle.
//
// Trivial whitespace-only normalization is intentionally NOT done here: the
// notifier layer owns validation (and the LoggingNotifier just logs). A future
// real notifier does its own normalization before sending, so we don't want
// to lose the original input.
func SetRecoveryContact(grain *CartGrain, req *messages.SetRecoveryContact) error {
if req == nil {
return errors.New("SetRecoveryContact: nil payload")
}
grain.Email = req.Email
if len(req.PushTokens) == 0 {
grain.PushTokens = nil
return nil
}
tokens := make([]PushToken, 0, len(req.PushTokens))
dropped := 0
for _, t := range req.PushTokens {
if t == nil {
// Defensive: a malformed wire request left a nil slot in the
// repeated field. Skip rather than crash and surface at debug
// level so a misbehaving client is visible without spamming.
dropped++
continue
}
tokens = append(tokens, PushToken{
Platform: t.Platform,
Token: t.Token,
})
}
if dropped > 0 {
log.Printf("SetRecoveryContact: dropped %d nil push-token entries (malformed wire)", dropped)
}
if len(tokens) == 0 {
grain.PushTokens = nil
return nil
}
grain.PushTokens = tokens
return nil
}
@@ -0,0 +1,105 @@
package cart
import (
"context"
"testing"
"time"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// TestSetRecoveryContact_ReplacesEntireBundle verifies PUT semantics: a
// second call fully overwrites the first; calling with empty clears it.
func TestSetRecoveryContact_ReplacesEntireBundle(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
// Initial set.
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
Email: "first@example.com",
PushTokens: []*messages.PushToken{
{Platform: "fcm", Token: "AAA"},
{Platform: "apns", Token: "BBB"},
},
}); err != nil {
t.Fatalf("first set: %v", err)
}
if g.Email != "first@example.com" {
t.Errorf("email = %q, want first@example.com", g.Email)
}
if len(g.PushTokens) != 2 {
t.Fatalf("push tokens = %d, want 2", len(g.PushTokens))
}
if g.PushTokens[0].Platform != "fcm" || g.PushTokens[1].Platform != "apns" {
t.Errorf("platforms = %+v, want [fcm apns]", g.PushTokens)
}
// Replace with one push token.
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
Email: "second@example.com",
PushTokens: []*messages.PushToken{{Platform: "webpush", Token: "CCC"}},
}); err != nil {
t.Fatalf("replace: %v", err)
}
if g.Email != "second@example.com" {
t.Errorf("after replace email = %q, want second@example.com", g.Email)
}
if len(g.PushTokens) != 1 {
t.Fatalf("after replace push tokens = %d, want 1", len(g.PushTokens))
}
if g.PushTokens[0].Platform != "webpush" {
t.Errorf("after replace platform = %q, want webpush", g.PushTokens[0].Platform)
}
// Clear.
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{}); err != nil {
t.Fatalf("clear: %v", err)
}
if g.Email != "" || len(g.PushTokens) != 0 {
t.Errorf("after clear: email=%q tokens=%d, want empty", g.Email, len(g.PushTokens))
}
}
// TestSetRecoveryContact_NilPayload verifies defensive nil-input behavior.
// We document that nil returns an error (no silent swallow of bad requests).
func TestSetRecoveryContact_NilPayload(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(1, time.Now())
ctx := context.Background()
res, err := reg.Apply(ctx, g, (*messages.SetRecoveryContact)(nil))
// registry.Apply treats typed-nil proto messages as a no-op (see
// mutation_registry.go's "Typed nil" handling). The behavior we want is
// "no mutation applied to grain state" — either via no-op via the
// typed-nil path or via an error in the result list. We assert that the
// grain state was not modified.
if g.Email != "" || len(g.PushTokens) != 0 {
t.Errorf("nil-set should not modify grain: email=%q tokens=%d", g.Email, len(g.PushTokens))
}
_ = res
_ = err
}
// TestSetRecoveryContact_SkipsNilTokens verifies nil sub-message tokens are
// dropped rather than crashing.
func TestSetRecoveryContact_SkipsNilTokens(t *testing.T) {
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
g := NewCartGrain(2, time.Now())
ctx := context.Background()
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
Email: "ok@example.com",
PushTokens: []*messages.PushToken{
nil,
{Platform: "fcm", Token: "REAL"},
nil,
},
}); err != nil {
t.Fatalf("set with nil tokens: %v", err)
}
if len(g.PushTokens) != 1 {
t.Fatalf("push tokens = %d, want 1 (nil entries dropped)", len(g.PushTokens))
}
if g.PushTokens[0].Platform != "fcm" {
t.Errorf("kept platform = %q, want fcm", g.PushTokens[0].Platform)
}
}
+59
View File
@@ -0,0 +1,59 @@
package recovery
import (
"context"
"log"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// LoggingNotifier is the v0 default: it writes one INFO line per candidate
// and returns nil. It deliberately does NOT pretend to send anything.
//
// Future implementations (MailerSend, FCM, APNs) implement the same Notifier
// interface; the scanner doesn't change. LoggingNotifier also serves as the
// "dry-run" mode in production by leaving the seam unconfigured.
type LoggingNotifier struct{}
// NewLoggingNotifier returns a Notifier that logs every candidate. The struct
// is empty today; the constructor is kept for parity with future notifiers
// that need credentials / config.
func NewLoggingNotifier() *LoggingNotifier { return &LoggingNotifier{} }
// NotifyAbandoned logs the cart id, contact present, and a tiny summary.
// Push tokens are summarized by platform only — never log the raw token, so
// a misconfigured log aggregator can't leak device handles.
func (LoggingNotifier) NotifyAbandoned(_ context.Context, c RecoveryCandidate) error {
hasEmail := c.Email != ""
hasPush := len(c.PushTokens) > 0
channel := "none"
switch {
case hasEmail && hasPush:
channel = "email+push"
case hasEmail:
channel = "email"
case hasPush:
channel = "push"
}
log.Printf(
"recovery [dry-run]: cart=%d user=%q channel=%s items=%d total=%d currency=%s email=%q pushPlatforms=%v lastChangeUnix=%d",
c.CartID,
c.UserID,
channel,
c.ItemCount,
c.TotalIncVat,
c.Currency,
c.Email,
platformsOf(c.PushTokens),
c.LastChangeUnix,
)
return nil
}
func platformsOf(tokens []cart.PushToken) []string {
out := make([]string, 0, len(tokens))
for _, t := range tokens {
out = append(out, t.Platform)
}
return out
}
@@ -0,0 +1,94 @@
package recovery
import (
"bytes"
"context"
"log"
"strings"
"testing"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// captureLog writes log output to an in-memory buffer for inspection.
func captureLog(t *testing.T) *bytes.Buffer {
t.Helper()
buf := &bytes.Buffer{}
orig := log.Writer()
flags := log.Flags()
prefix := log.Prefix()
log.SetOutput(buf)
log.SetFlags(0)
log.SetPrefix("")
t.Cleanup(func() {
log.SetOutput(orig)
log.SetFlags(flags)
log.SetPrefix(prefix)
})
return buf
}
func TestLoggingNotifier_LogsPlatformsNotTokens(t *testing.T) {
buf := captureLog(t)
n := LoggingNotifier{}
err := n.NotifyAbandoned(context.Background(), RecoveryCandidate{
CartID: 1,
Email: "doc@example.com",
PushTokens: []cart.PushToken{{Platform: "fcm", Token: "SECRET-TOKEN-MUST-NOT-LEAK"}, {Platform: "apns", Token: "ALSO-SECRET-2"}},
})
if err != nil {
t.Fatalf("NotifyAbandoned: %v", err)
}
out := buf.String()
if !strings.Contains(out, "doc@example.com") {
t.Errorf("expected email in log: %s", out)
}
if !strings.Contains(out, "[fcm apns]") {
t.Errorf("expected bracketed platform list in log: %s", out)
}
for _, leak := range []string{"SECRET-TOKEN-MUST-NOT-LEAK", "ALSO-SECRET-2"} {
if strings.Contains(out, leak) {
t.Errorf("token leaked into log: %s line=%s", leak, out)
}
}
}
func TestLoggingNotifier_ClassifiesChannel(t *testing.T) {
buf := captureLog(t)
n := LoggingNotifier{}
cases := []struct {
name string
cand RecoveryCandidate
wantSub string
}{
{
name: "email+push",
cand: RecoveryCandidate{Email: "a@b.c", PushTokens: []cart.PushToken{{Platform: "fcm"}}},
wantSub: "channel=email+push",
},
{
name: "email only",
cand: RecoveryCandidate{Email: "a@b.c"},
wantSub: "channel=email",
},
{
name: "push only",
cand: RecoveryCandidate{PushTokens: []cart.PushToken{{Platform: "fcm"}}},
wantSub: "channel=push",
},
{
name: "no contact",
cand: RecoveryCandidate{},
wantSub: "channel=none",
},
}
for _, tc := range cases {
buf.Reset()
if err := n.NotifyAbandoned(context.Background(), tc.cand); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if !strings.Contains(buf.String(), tc.wantSub) {
t.Errorf("%s: log missing %q; got: %s", tc.name, tc.wantSub, buf.String())
}
}
}
+49
View File
@@ -0,0 +1,49 @@
// Package recovery is the v0 abandoned-cart-recovery seam.
//
// The contract is intentionally narrow: a Scanner finds candidates whose last
// mutation is within an "abandoned" window and that have BOTH an attachable
// contact (email and/or push tokens) and items in the cart, then hands them
// to a Notifier. The Notifier decides what to do — for now a logging shim.
//
// The seam intentionally avoids:
//
// - Email/push-token delivery (lives in the notifier impl).
// - Idempotency tracking ("did we already notify?"). v0: scanner windows
// are sized so a cart falls in once per detection threshold. Real
// idempotency is the next pass — see suggest_followups / plan-commerce-
// maturity C3.
// - Cross-pod coordination. Each cart pod owns its own shard of event logs
// (replicated read cache, no leader), so per-pod scanning is correct.
// A future AMQP fan-out of `cart.recovery.candidate` would let a single
// dedicated notifier consume instead — out of scope here.
package recovery
import (
"context"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
)
// RecoveryCandidate is the projectable "we'd remind this shopper" shape.
// Fields surface just enough for a future email template / push payload; the
// underlying grain carries everything else.
type RecoveryCandidate struct {
CartID cart.CartId
UserID string // empty if the cart hasn't been linked to a profile
Email string
PushTokens []cart.PushToken
ItemCount int
TotalIncVat int64 // minor units (money.Cents == int64)
Currency string
// LastChange is best-effort: the scanner reads the event-log file's
// modification time. A pending notifier shouldn't use it as the canonical
// "when was this cart touched?" wall clock — see cart.LastChange.
LastChangeUnix int64
}
// Notifier is the seam a real email/push implementation plugs into. The
// LoggingNotifier is the v0 default. The cart service ships the seam even
// before a real send exists so call-sites and tracking can be wired once.
type Notifier interface {
NotifyAbandoned(ctx context.Context, c RecoveryCandidate) error
}
+257
View File
@@ -0,0 +1,257 @@
package recovery
import (
"context"
"log"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/proto"
)
// writeFixtureLog writes a synthetic `<id>.events.log` to dir with the given
// mutations, then pins the file's mtime AFTER Close so the canonical
// DiskStorage.SaveLoop flush can't overwrite it on test machines that run
// the inner save() during teardown.
func writeFixtureLog(t *testing.T, dir string, id uint64, mtime time.Time, muts []proto.Message) {
t.Helper()
path := filepath.Join(dir, strconv.FormatUint(id, 10)+".events.log")
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
if err := storage.AppendMutations(id, muts...); err != nil {
t.Fatalf("AppendMutations for fixture %d: %v", id, err)
}
// DiskStorage.Close runs save() which opens+appends the file; that
// updates mtime. Pin AFTER Close so the test mtime wins.
storage.Close()
if err := os.Chtimes(path, mtime, mtime); err != nil {
t.Fatalf("Chtimes %s: %v", path, err)
}
}
// silenceLogs redirects the standard logger while the scanner runs so test
// output stays readable.
func silenceLogs(t *testing.T) {
t.Helper()
orig := log.Writer()
prevFlags := log.Flags()
log.SetOutput(devNull{})
log.SetFlags(0)
t.Cleanup(func() {
log.SetOutput(orig)
log.SetFlags(prevFlags)
})
}
type devNull struct{}
func (devNull) Write(p []byte) (int, error) { return len(p), nil }
func TestScanner_FindsCandidateInWindow(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
// Cart that became eligible exactly in the middle of the window:
// delay 1h, window 1h → eligible mtime ∈ [now-2h, now-1h].
mtime := now.Add(-90 * time.Minute)
writeFixtureLog(t, dir, 42, mtime, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000, Sku: "A"},
&messages.SetRecoveryContact{Email: "abandoned@example.com"},
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 1 {
t.Fatalf("candidates = %d, want 1; full result=%+v", len(got), got)
}
c := got[0]
if c.CartID != cart.CartId(42) {
t.Errorf("CartID = %d, want 42", c.CartID)
}
if c.Email != "abandoned@example.com" {
t.Errorf("Email = %q, want abandoned@example.com", c.Email)
}
if c.ItemCount != 1 {
t.Errorf("ItemCount = %d, want 1", c.ItemCount)
}
if c.TotalIncVat != 1000 {
t.Errorf("TotalIncVat = %d, want 1000 (AddItem price 1000)", c.TotalIncVat)
}
}
func TestScanner_SkipsCartsOutsideWindow(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
// Too fresh (mtime = now-30m, inside the 1h "still active" cutoff).
fresh := now.Add(-30 * time.Minute)
writeFixtureLog(t, dir, 7, fresh, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
&messages.SetRecoveryContact{Email: "fresh@example.com"},
})
// Too old (mtime = now-25h, past the 2h window upper bound).
old := now.Add(-25 * time.Hour)
writeFixtureLog(t, dir, 8, old, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
&messages.SetRecoveryContact{Email: "old@example.com"},
})
// Inside window with NO contact (eligibility gate).
mid := now.Add(-90 * time.Minute)
writeFixtureLog(t, dir, 9, mid, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
// no SetRecoveryContact
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 0 {
t.Fatalf("candidates = %d, want 0 (all filtered out); result=%+v", len(got), got)
}
}
func TestScanner_FindsPushOnlyCandidate(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
mtime := now.Add(-75 * time.Minute)
writeFixtureLog(t, dir, 11, mtime, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
&messages.SetRecoveryContact{
PushTokens: []*messages.PushToken{
{Platform: "fcm", Token: "tok-1"},
{Platform: "apns", Token: "tok-2"},
},
},
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 1 {
t.Fatalf("candidates = %d, want 1", len(got))
}
if got[0].Email != "" {
t.Errorf("Email = %q, want empty (push-only)", got[0].Email)
}
if len(got[0].PushTokens) != 2 {
t.Fatalf("PushTokens = %d, want 2", len(got[0].PushTokens))
}
}
func TestScanner_IgnoresWishlistCarts(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
mtime := now.Add(-90 * time.Minute)
writeFixtureLog(t, dir, 13, mtime, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
&messages.SetRecoveryContact{Email: "wishlist@example.com"},
&messages.SetCartType{Type: messages.CartType_WISHLIST},
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 0 {
t.Fatalf("wishlist candidate leaked: got %+v", got)
}
}
// TestScanner_TotalPostReplay verifies the bug fix: replay does not run the
// post-mutation processor, so the scanner MUST call UpdateTotals to surface
// an accurate TotalIncVat.
func TestScanner_TotalPostReplay(t *testing.T) {
silenceLogs(t)
dir := t.TempDir()
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
mtime := now.Add(-75 * time.Minute)
writeFixtureLog(t, dir, 21, mtime, []proto.Message{
&messages.AddItem{ItemId: 1, Quantity: 2, Price: 500, Sku: "A"},
&messages.AddItem{ItemId: 2, Quantity: 1, Price: 1500, Sku: "B"},
&messages.SetRecoveryContact{Email: "totals@example.com"},
})
defer storage.Close()
scanner := NewScanner(dir, storage)
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != 1 {
t.Fatalf("candidates = %d, want 1", len(got))
}
// 2*500 + 1*1500 = 2500 inc-vat
if got[0].TotalIncVat != 2500 {
t.Errorf("TotalIncVat = %d, want 2500 (1000+1500 across two lines)", got[0].TotalIncVat)
}
if got[0].ItemCount != 2 {
t.Errorf("ItemCount = %d, want 2", got[0].ItemCount)
}
}
// TestIsValidEmail sanity for the optional helper exported for notifiers.
func TestIsValidEmail(t *testing.T) {
if !IsValidEmail("user@example.com") {
t.Errorf("user@example.com should be valid")
}
if IsValidEmail("not-an-email") {
t.Errorf("not-an-email should be invalid")
}
}
// TestFileIDFor verifies the helper that parses <id>.events.log filenames.
func TestFileIDFor(t *testing.T) {
if id, ok := fileIDFor("12345.events.log"); !ok || id != 12345 {
t.Errorf("12345.events.log -> %d, %v; want 12345, true", id, ok)
}
if _, ok := fileIDFor("not-a-number.events.log"); ok {
t.Errorf("non-numeric should fail")
}
if _, ok := fileIDFor("0.events.log"); ok {
t.Errorf("id=0 should fail (no implicit zero-cart grain)")
}
if _, ok := fileIDFor("garbage.txt"); ok {
t.Errorf("non-event-log suffix should fail")
}
}
+187
View File
@@ -0,0 +1,187 @@
package recovery
import (
"context"
"fmt"
"net/mail"
"os"
"strconv"
"strings"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
)
// Scanner finds RecoveryCandidates by scanning the per-pod event-log directory.
//
// The model:
// - Each cart pod owns a shard of `<id>.events.log` files under `dir`.
// - File mtime approximates "last successful mutation" cheaply — replay only
// when something lands in the window.
// - For each candidate file, replay the grain (a few kb, ms-scale) and build
// a RecoveryCandidate iff it has items + a contact.
//
// Two important implementation details:
//
// 1. After LoadEvents we MUST call UpdateTotals(). Replaying event handlers
// runs the per-mutation registered handlers (SetUserId, SetRecoveryContact,
// AddItem, ...) but NOT the post-mutation processors — those run only on
// the live mutation path (see cmd/cart/main.go::RegisterProcessor for the
// "Totals and promotions" pipeline). Without this call, g.TotalPrice is
// nil and TotalIncVat in the candidate is always 0.
//
// 2. Read-vs-write races: DiskStorage may be in the middle of appending while
// Scan reads. The bufio scanner will fail JSON-unmarshal on a torn line and
// we silently continue — LogEvents treats that as "skip", here we do too.
// A persistent unreadable file is a separate problem runPurge handles.
//
// v0 deliberately does NOT track "already notified" within this scanner —
// the window sizing plus caller conventions give roughly-once delivery.
// Idempotency is the next pass.
type Scanner struct {
dir string
storage *actor.DiskStorage[cart.CartGrain]
}
// NewScanner returns a Scanner over dir (the same CART_DIR used by the cart
// service). storage must share the same dir so LoadEvents reading paths
// match the scanning paths.
func NewScanner(dir string, storage *actor.DiskStorage[cart.CartGrain]) *Scanner {
return &Scanner{dir: dir, storage: storage}
}
// Scan returns candidates whose event-log mtime falls in
// [now-delay-window, now-delay] AND whose grain has items AND (email OR
// push tokens). The half-open window semantics match the caller's intent:
//
// "carts whose mtime is in this slice of the recent past"
//
// — younger than now-delay (still active) and older than now-delay-window
// (never recovery-eligible yet).
//
// Empty slice + nil error means "no candidates this pass"; an error means the
// pass itself failed and the caller should log + retry.
func (s *Scanner) Scan(ctx context.Context, delay, window time.Duration, now time.Time) ([]RecoveryCandidate, error) {
if s == nil || s.storage == nil || s.dir == "" {
return nil, fmt.Errorf("recovery: scanner not configured")
}
if delay <= 0 {
return nil, nil
}
if window <= 0 {
window = delay
}
lower := now.Add(-(delay + window))
upper := now.Add(-delay)
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, fmt.Errorf("recovery: read dir %s: %w", s.dir, err)
}
out := make([]RecoveryCandidate, 0)
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".events.log") {
continue
}
id, ok := fileIDFor(name)
if !ok {
continue
}
info, infoErr := e.Info()
if infoErr != nil {
continue
}
mtime := info.ModTime()
// Closed window:
// mtime BEFORE lower → never reached the abandonment threshold yet.
// mtime AFTER upper → still too fresh (caller is actively using it).
if mtime.Before(lower) || mtime.After(upper) {
continue
}
grain := cart.NewCartGrain(id, mtime)
if loadErr := s.storage.LoadEvents(ctx, id, grain); loadErr != nil {
// Skip unreadable grains — they're purged separately by runPurge.
continue
}
// Replay does NOT run the Totals-and-promotions processor (see the
// package doc); recompute TotalPrice from the item list so the
// candidate TotalIncVat matches what the live API would return.
grain.UpdateTotals()
if !eligible(grain) {
continue
}
out = append(out, buildCandidate(id, grain, mtime))
}
return out, nil
}
// eligible mirrors "would we ever want to send a reminder?" — items present
// and a contact available. The grain's own UpdateTotals has already been
// run by every mutation's processor, so TotalPrice is reliable here.
//
// Wishlists / offers are excluded — a wishlist cart isn't forgotten, and an
// offer cart has its own delivery semantics.
func eligible(g *cart.CartGrain) bool {
if g == nil || len(g.Items) == 0 {
return false
}
switch g.Type {
case cart_messages.CartType_WISHLIST, cart_messages.CartType_OFFER:
return false
}
hasContact := strings.TrimSpace(g.Email) != "" || len(g.PushTokens) > 0
return hasContact
}
// buildCandidate projects the replayed grain into a RecoveryCandidate. Email
// validation is intentionally lenient — we hand the value to the notifier
// and the notifier decides what to send; rejecting "looks-invalid" addresses
// here would just hide useful debugging signal from the dry-run logs.
func buildCandidate(id uint64, g *cart.CartGrain, mtime time.Time) RecoveryCandidate {
var total int64
if g.TotalPrice != nil {
// tax.Price.IncVat is int64-backed via money.Cents. Reading the field
// directly keeps the recovery layer decoupled from platform/money
// internals.
total = int64(g.TotalPrice.IncVat)
}
tokens := make([]cart.PushToken, len(g.PushTokens))
copy(tokens, g.PushTokens)
return RecoveryCandidate{
CartID: cart.CartId(id),
UserID: g.UserId,
Email: g.Email,
PushTokens: tokens,
ItemCount: len(g.Items),
TotalIncVat: total,
Currency: g.Currency,
LastChangeUnix: mtime.Unix(),
}
}
// IsValidEmail is exported as a convenience the notifier may use to decide
// whether to actually send (MailerSend will reject invalid); the scanner
// itself is permissive.
func IsValidEmail(s string) bool {
_, err := mail.ParseAddress(s)
return err == nil
}
// fileIDFor is a small helper that extracts the grain id from an .events.log
// filename. Returns 0/false if the name doesn't match the expected shape.
func fileIDFor(name string) (uint64, bool) {
base := strings.TrimSuffix(name, ".events.log")
id, err := strconv.ParseUint(base, 10, 64)
if err != nil || id == 0 {
return 0, false
}
return id, true
}