Badges / coveralls (push) Successful in 1m4s
Allow us to generate year over year reports without having to rerun everything from the beginning. Co-authored-by: Natercio Moniz <[email protected]>
154 lines
4.2 KiB
Go
154 lines
4.2 KiB
Go
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
|
|
}
|