Compare commits

..
2 Commits
Author SHA1 Message Date
natercio 9eb1a781e7 Add several quality assurance jobs (#28)
Badges / coveralls (push) Successful in 1m33s
Reviewed-on: #28
Co-authored-by: Natercio Moniz <[email protected]>
2026-08-05 10:01:30 +01:00
natercio 9bd4230ff1 Internal state persistence (#27)
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]>
2026-08-03 00:33:06 +01:00
8 changed files with 131 additions and 29 deletions
+33 -1
View File
@@ -24,6 +24,37 @@ jobs:
echo "has_go_changes=false" >> $GITHUB_OUTPUT
fi
static-checks:
runs-on: ubuntu-latest
needs: check-changes
if: needs.check-changes.outputs.has_go_changes == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.26
cache: true
- name: Run go vet
run: go vet ./...
- name: Check go mod tidy
run: |
go mod tidy
if [ -n "$(git status --porcelain go.mod go.sum)" ]; then
echo "go.mod or go.sum changed after go mod tidy; please run go mod tidy and commit the result" >&2
git diff go.mod go.sum >&2
exit 1
fi
- name: Run govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
run-tests:
runs-on: ubuntu-latest
needs: check-changes
@@ -35,7 +66,8 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.25
go-version: 1.26
cache: true
- name: Run Unit tests
run: |
+4 -3
View File
@@ -1,6 +1,6 @@
module github.com/nmoniz/any2anexoj
go 1.25.3
go 1.26.4
require (
github.com/biter777/countries v1.7.5
@@ -8,17 +8,18 @@ require (
github.com/shopspring/decimal v1.4.0
github.com/spf13/pflag v1.0.10
go.uber.org/mock v0.6.0
golang.org/x/sync v0.18.0
golang.org/x/time v0.14.0
)
require golang.org/x/sync v0.18.0 // indirect
require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/text v0.23.0
golang.org/x/tools v0.36.0 // indirect
)
+2 -2
View File
@@ -1,3 +1,5 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q=
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -29,8 +31,6 @@ golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+23 -21
View File
@@ -9,6 +9,9 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// FileStore is a Store backed by a single JSON file on disk. The on-disk
@@ -50,6 +53,9 @@ func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error)
}
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 {
@@ -74,7 +80,7 @@ func (fs *FileStore) Load(ctx context.Context) (map[string]*FillerQueue, error)
for symbol, persisted := range s.Queues {
q := new(FillerQueue)
for _, pf := range persisted {
rec, err := fs.serializer.UnmarshalRecord(ctx, pf.ReaderData)
rec, err := fs.serializer.UnmarshalRecord(ctx, pf.RecordData)
if err != nil {
return nil, fmt.Errorf(
"unmarshalling record for symbol %q: %w", symbol, err,
@@ -98,7 +104,7 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
}
for symbol, q := range queue {
if q == nil {
if q == nil || q.Len() == 0 {
continue
}
var persisted []persistedFiller
@@ -109,7 +115,7 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
return fmt.Errorf("marshalling record for symbol %q: %w", symbol, err)
}
persisted = append(persisted, persistedFiller{
ReaderData: data,
RecordData: data,
Quantity: f.Quantity(),
Price: f.Price(),
Filled: f.Filled(),
@@ -118,6 +124,16 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
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("", " ")
@@ -125,27 +141,13 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
return fmt.Errorf("encoding state: %w", err)
}
dir := filepath.Dir(fs.filename)
tmp, err := os.CreateTemp(dir, ".state-*.tmp")
dst, err := os.Create(fs.filename)
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
return fmt.Errorf("creating new state file: %w", err)
}
tmpName := tmp.Name()
// Best-effort cleanup if rename never runs (e.g. process killed between
// CreateTemp and Rename). On success the temp file has been consumed by
// Rename and Remove returns ENOENT which we ignore.
defer os.Remove(tmpName)
if _, err := io.Copy(tmp, buf); err != nil {
_ = tmp.Close()
return fmt.Errorf("writing temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing temp file: %w", err)
}
if err := os.Rename(tmpName, fs.filename); err != nil {
return fmt.Errorf("renaming temp file: %w", err)
if _, err := io.Copy(dst, buf); err != nil {
return fmt.Errorf("writing to new state file: %w", err)
}
return nil
}
+68 -1
View File
@@ -19,7 +19,7 @@ import (
func TestFileStore_RoundTrip(t *testing.T) {
ctrl := gomock.NewController(t)
ser := roundTripSerializer(ctrl)
store, _ := newStore(t, "fake", ser)
store, path := newStore(t, "fake", ser)
original := map[string]*internal.FillerQueue{
"AAA": newQueue(
@@ -50,6 +50,73 @@ func TestFileStore_RoundTrip(t *testing.T) {
}
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) {
+1 -1
View File
@@ -21,7 +21,7 @@ type State struct {
// 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.
type persistedFiller struct {
ReaderData []byte `json:"reader_data"`
RecordData []byte `json:"record_data"`
Quantity decimal.Decimal `json:"quantity"`
Price decimal.Decimal `json:"price"`
Filled decimal.Decimal `json:"filled"`