123 lines
3.4 KiB
Go
123 lines
3.4 KiB
Go
// 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"
|
|
"hash/fnv"
|
|
"os"
|
|
"sync"
|
|
)
|
|
|
|
// lockShards bounds the per-key lock memory: keys hash into a fixed set of
|
|
// mutexes rather than a map that grows with every distinct key ever seen. Two
|
|
// unrelated keys that collide on a shard serialise occasionally — harmless.
|
|
const lockShards = 256
|
|
|
|
// 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
|
|
locks [lockShards]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}, 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() {}
|
|
}
|
|
h := fnv.New32a()
|
|
_, _ = h.Write([]byte(key))
|
|
mu := &s.locks[h.Sum32()%lockShards]
|
|
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()
|
|
}
|