more checkout
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// Package idempotency provides a small durable key→id store so a cross-service
|
||||
// mutation can be retried safely. It is the durable replacement for the
|
||||
// in-memory map that guarded order creation in cmd/order: an in-memory map is
|
||||
// lost on restart, so a crash followed by a client retry created a duplicate
|
||||
// order. This store is backed by an append-only JSONL file and survives a
|
||||
// restart — a recorded key is durable the moment Put returns.
|
||||
//
|
||||
// It targets the single-node order service: appends are fsync'd under a mutex,
|
||||
// and a per-key lock lets a caller make a check→create→record sequence atomic
|
||||
// against a concurrent retry carrying the same key.
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Store maps an idempotency key to a result id (e.g. an order id), cached in
|
||||
// memory and persisted to an append-only file.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
m map[string]uint64
|
||||
f *os.File
|
||||
|
||||
klMu sync.Mutex
|
||||
keyLocks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
type record struct {
|
||||
Key string `json:"k"`
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
|
||||
// Open loads the store at path (creating the file if absent) and returns a
|
||||
// Store ready for use. Corrupt trailing lines (e.g. from a crash mid-write) are
|
||||
// skipped rather than failing the load. The caller owns Close.
|
||||
func Open(path string) (*Store, error) {
|
||||
m := map[string]uint64{}
|
||||
if f, err := os.Open(path); err == nil {
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := sc.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var rec record
|
||||
if err := json.Unmarshal(line, &rec); err != nil || rec.Key == "" {
|
||||
continue // skip a torn/corrupt line
|
||||
}
|
||||
m[rec.Key] = rec.ID
|
||||
}
|
||||
_ = f.Close()
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{m: m, f: f, keyLocks: map[string]*sync.Mutex{}}, nil
|
||||
}
|
||||
|
||||
// Get returns the recorded id for key and whether it was present.
|
||||
func (s *Store) Get(key string) (uint64, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.m[key]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// Put records key→id durably. A repeated key keeps the first recorded value
|
||||
// (first writer wins) and performs no disk write, matching idempotent
|
||||
// semantics. The append is fsync'd before Put returns.
|
||||
func (s *Store) Put(key string, id uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.m[key]; ok {
|
||||
return nil
|
||||
}
|
||||
line, err := json.Marshal(record{Key: key, ID: id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.f.Write(append(line, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.m[key] = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lock serialises all work for a single key so a check→create→record sequence
|
||||
// is atomic against a concurrent retry carrying the same key. It returns the
|
||||
// unlock function. An empty key is not locked (returns a no-op).
|
||||
func (s *Store) Lock(key string) func() {
|
||||
if key == "" {
|
||||
return func() {}
|
||||
}
|
||||
s.klMu.Lock()
|
||||
mu, ok := s.keyLocks[key]
|
||||
if !ok {
|
||||
mu = &sync.Mutex{}
|
||||
s.keyLocks[key] = mu
|
||||
}
|
||||
s.klMu.Unlock()
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// Close closes the underlying file.
|
||||
func (s *Store) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.f.Close()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPutGet(t *testing.T) {
|
||||
s, err := Open(filepath.Join(t.TempDir(), "idem.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if _, ok := s.Get("a"); ok {
|
||||
t.Fatal("unexpected hit on empty store")
|
||||
}
|
||||
if err := s.Put("a", 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id, ok := s.Get("a"); !ok || id != 42 {
|
||||
t.Fatalf("Get(a) = %d,%v; want 42,true", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstWriterWins(t *testing.T) {
|
||||
s, _ := Open(filepath.Join(t.TempDir(), "idem.log"))
|
||||
defer s.Close()
|
||||
_ = s.Put("a", 1)
|
||||
_ = s.Put("a", 2) // ignored
|
||||
if id, _ := s.Get("a"); id != 1 {
|
||||
t.Fatalf("got %d, want first value 1", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableAcrossReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "idem.log")
|
||||
s, _ := Open(path)
|
||||
_ = s.Put("checkout-x", 1001)
|
||||
_ = s.Put("checkout-y", 1002)
|
||||
_ = s.Close()
|
||||
|
||||
// A fresh process (the restart case the in-memory map failed) must still see
|
||||
// the recorded keys — this is the duplicate-order bug the durable store fixes.
|
||||
s2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s2.Close()
|
||||
if id, ok := s2.Get("checkout-x"); !ok || id != 1001 {
|
||||
t.Fatalf("after reopen Get(checkout-x) = %d,%v; want 1001,true", id, ok)
|
||||
}
|
||||
if id, ok := s2.Get("checkout-y"); !ok || id != 1002 {
|
||||
t.Fatalf("after reopen Get(checkout-y) = %d,%v; want 1002,true", id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorruptTrailingLineSkipped(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "idem.log")
|
||||
s, _ := Open(path)
|
||||
_ = s.Put("good", 7)
|
||||
_ = s.Close()
|
||||
|
||||
// Simulate a torn write from a crash mid-append.
|
||||
f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
_, _ = f.WriteString(`{"k":"bad","id":`)
|
||||
_ = f.Close()
|
||||
|
||||
s2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open with corrupt tail: %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
if id, ok := s2.Get("good"); !ok || id != 7 {
|
||||
t.Fatalf("good key lost after corrupt tail: %d,%v", id, ok)
|
||||
}
|
||||
if _, ok := s2.Get("bad"); ok {
|
||||
t.Fatal("corrupt line should not have loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockSerialisesKey(t *testing.T) {
|
||||
s, _ := Open(filepath.Join(t.TempDir(), "idem.log"))
|
||||
defer s.Close()
|
||||
|
||||
// Two goroutines contend on the same key; the lock must serialise the
|
||||
// check→create→record so exactly one writes and both agree on the value.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(v uint64) {
|
||||
defer wg.Done()
|
||||
unlock := s.Lock("k")
|
||||
defer unlock()
|
||||
if _, ok := s.Get("k"); !ok {
|
||||
_ = s.Put("k", v)
|
||||
}
|
||||
}(uint64(i + 1))
|
||||
}
|
||||
wg.Wait()
|
||||
if _, ok := s.Get("k"); !ok {
|
||||
t.Fatal("expected a value recorded under contention")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user