feat(loyalty): expire balances whose time is up
Adds the expiry job (docs/prd-point-coin.md F12, PC-503).
Every 15 minutes it lists the lots whose expiry has passed and that still
hold something, the longest overdue first, 500 at a time, and expires each
in its own transaction through WalletProcessor.ExpireLot: lock the wallet,
read the lot again, and take what is left with an EXPIRE row pointing at
the lot, keyed expire:{lot_id}. The description is frozen as
"Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", using the amount read
under the lock. Lots expire at the end of their day, so none stays past it
for more than about a quarter of an hour.
It is safe on several instances and across restarts, keeping no state in
memory as OmsetMilestoneScheduler does. Selecting the lots FOR UPDATE SKIP
LOCKED, as PC-503 suggested, would lock a lot before its wallet and
deadlock against payments, which lock the wallet first; instead the
listing takes no lock, and the wallet lock plus the idempotency key make a
second instance find the lot empty or the key used and take nothing.
A lot that fails is logged and retried on the next run without stopping
the others. Each customer gets one FCM push per currency with the total
that expired ("180 EnakPoint kamu sudah kedaluwarsa.", type
WALLET_EXPIRED).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4d63673a25
commit
d293786cde
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/logger"
|
||||
)
|
||||
|
||||
// Lots expire at the end of their day, so running every quarter of an hour keeps any
|
||||
// lot from staying past its expiry for more than about that long (PC-503).
|
||||
const defaultWalletExpiryInterval = 15 * time.Minute
|
||||
|
||||
type dueExpirer interface {
|
||||
ExpireDue(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// WalletExpiryJob expires the balances whose time is up (docs/prd-point-coin.md F12).
|
||||
// Unlike OmsetMilestoneScheduler it keeps no state in memory: several instances can
|
||||
// run it at once, and a restart repeats nothing, because every lot is expired under
|
||||
// its wallet's lock with an idempotency key.
|
||||
type WalletExpiryJob struct {
|
||||
expirer dueExpirer
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewWalletExpiryJob(expirer dueExpirer) *WalletExpiryJob {
|
||||
return &WalletExpiryJob{expirer: expirer, stopCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (j *WalletExpiryJob) Start(interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = defaultWalletExpiryInterval
|
||||
}
|
||||
go func() {
|
||||
j.RunOnce(context.Background())
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
j.RunOnce(context.Background())
|
||||
case <-j.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.NonContext.Infof("Wallet expiry job started (interval: %s)", interval)
|
||||
}
|
||||
|
||||
func (j *WalletExpiryJob) Stop() {
|
||||
j.stopOnce.Do(func() { close(j.stopCh) })
|
||||
}
|
||||
|
||||
// RunOnce expires what is due and returns how many lots it expired.
|
||||
func (j *WalletExpiryJob) RunOnce(ctx context.Context) int {
|
||||
expired, err := j.expirer.ExpireDue(ctx)
|
||||
if err != nil {
|
||||
logger.NonContext.Error("Wallet expiry failed to run", err)
|
||||
}
|
||||
if expired > 0 {
|
||||
logger.NonContext.Infof("Wallet expiry expired %d lots", expired)
|
||||
}
|
||||
return expired
|
||||
}
|
||||
Reference in New Issue
Block a user