Internal state persistence #27
@@ -50,6 +50,9 @@ func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error)
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("reading state file: %w", err)
|
return nil, fmt.Errorf("reading state file: %w", err)
|
||||||
}
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return make(map[string]*FillerQueue), nil
|
||||||
|
}
|
||||||
|
|
||||||
var s State
|
var s State
|
||||||
if err := json.Unmarshal(data, &s); err != nil {
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
@@ -74,7 +77,7 @@ func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error)
|
|||||||
for symbol, persisted := range s.Queues {
|
for symbol, persisted := range s.Queues {
|
||||||
q := new(FillerQueue)
|
q := new(FillerQueue)
|
||||||
for _, pf := range persisted {
|
for _, pf := range persisted {
|
||||||
rec, err := fs.serializer.UnmarshalRecord(ctx, pf.ReaderData)
|
rec, err := fs.serializer.UnmarshalRecord(ctx, pf.RecordData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"unmarshalling record for symbol %q: %w", symbol, err,
|
"unmarshalling record for symbol %q: %w", symbol, err,
|
||||||
@@ -98,7 +101,7 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
for symbol, q := range queue {
|
for symbol, q := range queue {
|
||||||
if q == nil {
|
if q == nil || q.Len() == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var persisted []persistedFiller
|
var persisted []persistedFiller
|
||||||
@@ -109,7 +112,7 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
|
|||||||
return fmt.Errorf("marshalling record for symbol %q: %w", symbol, err)
|
return fmt.Errorf("marshalling record for symbol %q: %w", symbol, err)
|
||||||
}
|
}
|
||||||
persisted = append(persisted, persistedFiller{
|
persisted = append(persisted, persistedFiller{
|
||||||
ReaderData: data,
|
RecordData: data,
|
||||||
Quantity: f.Quantity(),
|
Quantity: f.Quantity(),
|
||||||
Price: f.Price(),
|
Price: f.Price(),
|
||||||
Filled: f.Filled(),
|
Filled: f.Filled(),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
func TestFileStore_RoundTrip(t *testing.T) {
|
func TestFileStore_RoundTrip(t *testing.T) {
|
||||||
ctrl := gomock.NewController(t)
|
ctrl := gomock.NewController(t)
|
||||||
ser := roundTripSerializer(ctrl)
|
ser := roundTripSerializer(ctrl)
|
||||||
store, _ := newStore(t, "fake", ser)
|
store, path := newStore(t, "fake", ser)
|
||||||
|
|
||||||
original := map[string]*internal.FillerQueue{
|
original := map[string]*internal.FillerQueue{
|
||||||
"AAA": newQueue(
|
"AAA": newQueue(
|
||||||
@@ -50,6 +50,73 @@ func TestFileStore_RoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assertQueueEqual(t, symbol, gotQ, wantQ)
|
assertQueueEqual(t, symbol, gotQ, wantQ)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression: the on-disk JSON key for the per-record blob must be
|
||||||
|
// "record_data" (renamed from "reader_data") so the field name stays
|
||||||
|
// honest about what it carries.
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read state file: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `"reader_data"`) {
|
||||||
|
t.Errorf("saved state file still contains legacy key \"reader_data\"; want only \"record_data\"")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"record_data"`) {
|
||||||
|
t.Errorf("saved state file does not contain expected key \"record_data\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileStore_SaveSkipsEmptyQueue(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
ser := roundTripSerializer(ctrl)
|
||||||
|
store, path := newStore(t, "fake", ser)
|
||||||
|
|
||||||
|
// A non-nil FillerQueue whose underlying list is nil — i.e. a symbol
|
||||||
|
// that was registered but never received a Push. Save must not panic
|
||||||
|
// when iterating and must not emit any entry for that symbol.
|
||||||
|
queues := map[string]*internal.FillerQueue{
|
||||||
|
"EMPTY": new(internal.FillerQueue),
|
||||||
|
"REAL": newQueue(newFiller(ctrl, "REAL", 5, 10, 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Save(t.Context(), queues); err != nil {
|
||||||
|
t.Fatalf("Save returned unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read state file: %v", err)
|
||||||
|
}
|
||||||
|
body := string(data)
|
||||||
|
if strings.Contains(body, `"EMPTY"`) {
|
||||||
|
t.Errorf("saved state file contains entry for empty queue \"EMPTY\"; want it skipped")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"REAL"`) {
|
||||||
|
t.Errorf("saved state file missing expected entry for \"REAL\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileStore_LoadEmptyFileReturnsEmpty(t *testing.T) {
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
ser := roundTripSerializer(ctrl)
|
||||||
|
store, path := newStore(t, "fake", ser)
|
||||||
|
|
||||||
|
// Pre-create the state file as a zero-byte file. Load must treat this
|
||||||
|
// the same as a missing file rather than failing JSON unmarshal.
|
||||||
|
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
|
||||||
|
t.Fatalf("write empty state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
queues, err := store.Load(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned unexpected error for empty file: %v", err)
|
||||||
|
}
|
||||||
|
if queues == nil {
|
||||||
|
t.Fatalf("Load returned nil map; want empty map")
|
||||||
|
}
|
||||||
|
if len(queues) != 0 {
|
||||||
|
t.Fatalf("Load returned %d entries; want 0", len(queues))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileStore_LoadMissingFileReturnsEmpty(t *testing.T) {
|
func TestFileStore_LoadMissingFileReturnsEmpty(t *testing.T) {
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ type State struct {
|
|||||||
// Record-specific data is kept as opaque bytes so the internal package does
|
// Record-specific data is kept as opaque bytes so the internal package does
|
||||||
// not need to know about any broker package's concrete Record type.
|
// not need to know about any broker package's concrete Record type.
|
||||||
type persistedFiller struct {
|
type persistedFiller struct {
|
||||||
ReaderData []byte `json:"reader_data"`
|
RecordData []byte `json:"record_data"`
|
||||||
Quantity decimal.Decimal `json:"quantity"`
|
Quantity decimal.Decimal `json:"quantity"`
|
||||||
Price decimal.Decimal `json:"price"`
|
Price decimal.Decimal `json:"price"`
|
||||||
Filled decimal.Decimal `json:"filled"`
|
Filled decimal.Decimal `json:"filled"`
|
||||||
|
|||||||
Reference in New Issue
Block a user