// 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() }