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
+104
View File
@@ -17,6 +17,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
"git.k6n.net/mats/go-cart-actor/pkg/cart/recovery"
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
@@ -389,6 +390,65 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
// Data Retention (C5) GDPR Purge
retentionDays := config.EnvInt("CART_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
if retentionDays > 0 {
purgeInterval := config.EnvDuration("CART_PURGE_INTERVAL", 24*time.Hour)
log.Printf("cart: data retention enabled. Retaining carts for %d days, purging every %v", retentionDays, purgeInterval)
go func() {
ticker := time.NewTicker(purgeInterval)
defer ticker.Stop()
// Run immediately on startup
runPurge(ctx, diskStorage, pool, retentionDays)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runPurge(ctx, diskStorage, pool, retentionDays)
}
}
}()
} else {
log.Print("cart: data retention / GDPR purge disabled (CART_RETENTION_DAYS not set or <= 0)")
}
// Abandoned-cart recovery check (C3-notifications seam, dry-run by default).
// Scans local pod event-log files for carts whose modtime falls into the
// "[now - delay - window, now - delay]" window and that have items + a
// contact (email and/or push tokens). Hands each candidate to the
// configured Notifier (LoggingNotifier v0). The scanner reads local shard
// files only — the control plane doesn't fan out between pods on purpose
// (each pod owns the carts it is the owner of). Idempotency is the next
// pass; the v0 window size keeps duplicate notifications minute-scoped.
abandonedDelay := config.EnvDuration("CART_ABANDONED_DELAY", time.Hour)
if abandonedDelay > 0 {
abandonedWindow := config.EnvDuration("CART_ABANDONED_WINDOW", time.Hour)
abandonedInterval := config.EnvDuration("CART_ABANDONED_SCAN_INTERVAL", 15*time.Minute)
notifier := recovery.NewLoggingNotifier()
scanner := recovery.NewScanner(config.EnvString("CART_DIR", "data"), diskStorage)
log.Printf("cart: abandonment recovery ENABLED. Delay=%v window=%v scan_interval=%v (notifier=LoggingNotifier dry-run)", abandonedDelay, abandonedWindow, abandonedInterval)
go func() {
ticker := time.NewTicker(abandonedInterval)
defer ticker.Stop()
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
}
}
}()
} else {
log.Print("cart: abandonment recovery DISABLED (CART_ABANDONED_DELAY not set or <= 0)")
}
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
@@ -535,3 +595,47 @@ func main() {
}
}
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[cart.CartGrain], pool *actor.SimpleGrainPool[cart.CartGrain], retentionDays int) {
log.Printf("cart: starting data retention purge...")
retention := time.Duration(retentionDays) * 24 * time.Hour
localIds := make(map[uint64]bool)
for _, id := range pool.GetLocalIds() {
localIds[id] = true
}
isGrainActive := func(id uint64) bool {
return localIds[id]
}
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
if err != nil {
log.Printf("cart: data retention purge failed: %v", err)
} else {
log.Printf("cart: data retention purge completed. Purged %d expired cart logs", purged)
}
}
// runRecovery is the abandoned-cart scanner pass. It finds carts that crossed
// the abandonment threshold in the recent window and hands each to the
// configured Notifier. Each pass is independent; idempotency across ticks is
// the next pass (logged duplicates are not catastrophic for the dry-run
// notifier, but a real MailerSend/FCM impl will need a SETNX side-key).
func runRecovery(ctx context.Context, scanner *recovery.Scanner, notifier recovery.Notifier, delay, window time.Duration) {
candidates, err := scanner.Scan(ctx, delay, window, time.Now())
if err != nil {
log.Printf("cart: recovery scan failed: %v", err)
return
}
if len(candidates) == 0 {
log.Printf("cart: recovery scan complete, 0 candidates (window delay=%v width=%v)", delay, window)
return
}
log.Printf("cart: recovery scan complete, %d candidates", len(candidates))
for _, c := range candidates {
if err := notifier.NotifyAbandoned(ctx, c); err != nil {
log.Printf("cart: recovery notify (cart %d) failed: %v", c.CartID, err)
}
}
}