Files
zeekay 50a5190bce fix(store): a read-modify-write swaps, so no replica loses another's
Refunds and disputes were recorded under an in-process mutex, and the
renewal guard was a read then a write under another. Two processes over
one store, or a webhook beside the billing cycle, could each read the
invoice and each write it back, the second wiping the first's refund.

A swap writes only while the stored entity's updatedAt is still the one
read (insert-if-absent for a new or deleted entity) and otherwise reads
again: db.Swapper on SQLite and Postgres as one statement,
datastore.Swap, mixin.Model.Swap. Refund and dispute recording swap the
invoice or payment record in, and idempotencykey.Begin swaps its first
sighting and its stale re-claim, so a guarded money move runs once
across replicas. The returns and lockPeriod mutexes are gone.

lockFunds stays: api/transaction mounts only in standalone commerce,
whose store has one writer.

The tests open one store file through two handles, each with its own
write lock: 24 refunds delivered twice to both, with dispute reports
between them, land once each; 32 sightings of one key proceed once. A
blind write in the refund recorder fails the first test every run.
2026-10-09 10:40:59 -07:00

42 lines
1.2 KiB
Go

package datastore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/hanzoai/commerce/db"
"github.com/hanzoai/commerce/mintauth"
)
// Swap writes val under keyOrKind only while the stored entity is still the one
// read at prev, its updatedAt (zero for an entity never stored): db.Swapper. It
// reports whether it wrote; false means another writer changed the entity since
// the read, and the caller reads again. A store that cannot swap refuses rather
// than fall back to a blind put.
func (d *Datastore) Swap(keyOrKind interface{}, prev time.Time, val interface{}) (bool, error) {
if d.database == nil {
return false, errors.New("datastore: database not initialized")
}
if err := mintauth.Enforce(d.Context, val); err != nil {
d.warn("Refused unauthorized mint (%v, %#v): %v", convertKeyOrKind(d, keyOrKind), val, err, d.Context)
return false, err
}
s, ok := d.database.(db.Swapper)
if !ok {
return false, fmt.Errorf("datastore: %T cannot swap", d.database)
}
stamp := ""
if !prev.IsZero() {
b, err := json.Marshal(prev)
if err != nil {
return false, err
}
stamp = strings.Trim(string(b), `"`)
}
dskey := convertKeyOrKind(d, keyOrKind)
return s.Swap(d.Context, dskey.ToDBKey(d.database), stamp, val)
}