more cart
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user