package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings" "time" ) // FileStore is a Store backed by a single JSON file on disk. The on-disk // schema is the platform-agnostic State struct; per-broker Record data is // encoded and decoded through the supplied RecordSerializer. type FileStore struct { filename string platform string serializer RecordSerializer } // NewFileStore constructs a FileStore that reads from and writes to the given // filename. The FileStore does not keep an *os.File open — Load and Save each // open the file themselves — so callers do not need to close it. The platform // string is recorded into the saved state and validated on Load so a state // file from a different broker cannot be loaded by mistake. func NewFileStore(filename string, platform string, serializer RecordSerializer) (*FileStore, error) { if filename == "" { return nil, fmt.Errorf("filename cannot be empty") } if serializer == nil { return nil, fmt.Errorf("serializer cannot be nil") } return &FileStore{ filename: filename, platform: platform, serializer: serializer, }, nil } // Load reads the state file and reconstructs the per-symbol FillerQueue map. // A missing file is not an error — it returns an empty map so the first run // against a new state file Just Works. func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error) { data, err := os.ReadFile(fs.filename) if err != nil { if errors.Is(err, os.ErrNotExist) { return make(map[string]*FillerQueue), nil } return nil, fmt.Errorf("reading state file: %w", err) } if len(data) == 0 { return make(map[string]*FillerQueue), nil } var s State if err := json.Unmarshal(data, &s); err != nil { return nil, fmt.Errorf("unmarshalling state: %w", err) } if s.Version != StateVersion { return nil, fmt.Errorf( "unexpected state version %q: expected %q", s.Version, StateVersion, ) } if s.Platform != fs.platform { return nil, fmt.Errorf( "unexpected state platform %q: expected %q", s.Platform, fs.platform, ) } queues := make(map[string]*FillerQueue) for symbol, persisted := range s.Queues { q := new(FillerQueue) for _, pf := range persisted { rec, err := fs.serializer.UnmarshalRecord(ctx, pf.RecordData) if err != nil { return nil, fmt.Errorf( "unmarshalling record for symbol %q: %w", symbol, err, ) } q.Push(NewFillerFromState(rec, pf.Quantity, pf.Price, pf.Filled)) } queues[symbol] = q } return queues, nil } // Save serialises the queue map to disk. The write is done via a temp file in // the same directory followed by an atomic rename so a crash mid-write cannot // leave a half-written state file. func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) error { state := State{ Version: StateVersion, Platform: fs.platform, Queues: make(map[string][]persistedFiller), } for symbol, q := range queue { if q == nil || q.Len() == 0 { continue } var persisted []persistedFiller for e := q.l.Front(); e != nil; e = e.Next() { f := e.Value.(*Filler) data, err := fs.serializer.MarshalRecord(ctx, f.Record) if err != nil { return fmt.Errorf("marshalling record for symbol %q: %w", symbol, err) } persisted = append(persisted, persistedFiller{ RecordData: data, Quantity: f.Quantity(), Price: f.Price(), Filled: f.Filled(), }) } state.Queues[symbol] = persisted } ext := filepath.Ext(fs.filename) name, _ := strings.CutSuffix(fs.filename, ext) backupFilename := name + "." + strconv.FormatInt(time.Now().UnixMilli(), 10) + ext err := os.Rename(fs.filename, backupFilename) if err != nil { if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("rename old state file: %w", err) } } buf := &bytes.Buffer{} enc := json.NewEncoder(buf) enc.SetIndent("", " ") if err := enc.Encode(state); err != nil { return fmt.Errorf("encoding state: %w", err) } dst, err := os.Create(fs.filename) if err != nil { return fmt.Errorf("creating new state file: %w", err) } if _, err := io.Copy(dst, buf); err != nil { return fmt.Errorf("writing to new state file: %w", err) } return nil }