Compare commits
2
Commits
5fe47746fa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb1a781e7 | ||
|
|
9bd4230ff1 |
@@ -24,6 +24,37 @@ jobs:
|
|||||||
echo "has_go_changes=false" >> $GITHUB_OUTPUT
|
echo "has_go_changes=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
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:
|
run-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: check-changes
|
needs: check-changes
|
||||||
@@ -35,7 +66,8 @@ jobs:
|
|||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: 1.25
|
go-version: 1.26
|
||||||
|
cache: true
|
||||||
|
|
||||||
- name: Run Unit tests
|
- name: Run Unit tests
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/nmoniz/any2anexoj
|
module github.com/nmoniz/any2anexoj
|
||||||
|
|
||||||
go 1.25.3
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/biter777/countries v1.7.5
|
github.com/biter777/countries v1.7.5
|
||||||
@@ -8,17 +8,18 @@ require (
|
|||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
go.uber.org/mock v0.6.0
|
go.uber.org/mock v0.6.0
|
||||||
golang.org/x/sync v0.18.0
|
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sync v0.18.0 // indirect
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
github.com/nicksnyder/go-i18n/v2 v2.6.0
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
golang.org/x/mod v0.27.0 // indirect
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
golang.org/x/sys v0.35.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
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q=
|
||||||
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
|
github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/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 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
|||||||
+23
-21
@@ -9,6 +9,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileStore is a Store backed by a single JSON file on disk. The on-disk
|
// 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)
|
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 +80,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 +104,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 +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)
|
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(),
|
||||||
@@ -118,6 +124,16 @@ func (fs *FileStore) Save(ctx context.Context, queue map[string]*FillerQueue) er
|
|||||||
state.Queues[symbol] = persisted
|
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{}
|
buf := &bytes.Buffer{}
|
||||||
enc := json.NewEncoder(buf)
|
enc := json.NewEncoder(buf)
|
||||||
enc.SetIndent("", " ")
|
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)
|
return fmt.Errorf("encoding state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(fs.filename)
|
dst, err := os.Create(fs.filename)
|
||||||
tmp, err := os.CreateTemp(dir, ".state-*.tmp")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating temp file: %w", err)
|
return fmt.Errorf("creating new state file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpName := tmp.Name()
|
if _, err := io.Copy(dst, buf); err != nil {
|
||||||
// Best-effort cleanup if rename never runs (e.g. process killed between
|
return fmt.Errorf("writing to new state file: %w", err)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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