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
+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
}