diff options
Diffstat (limited to 'internal/cache')
| -rw-r--r-- | internal/cache/cache.go | 251 | ||||
| -rw-r--r-- | internal/cache/cache_test.go | 282 |
2 files changed, 533 insertions, 0 deletions
diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..892df4a --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,251 @@ +// Package cache stores the lookups that never change: a place name's +// coordinates, and a coordinate's TERYT powiat code. +// +// Forecasts are never cached -- they would be stale immediately. +package cache + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// Geo is a resolved location. +type Geo struct { + Lat float64 + Lon float64 + Label string + Country string +} + +// Geo is stored as a 4-element array, not an object, because the Python +// implementation shares this file and reads [lat, lon, label, country]. The two +// coexist until the Go version reaches parity, and a cache one of them cannot +// read makes the other crash on its own data. +func (g Geo) MarshalJSON() ([]byte, error) { return g.jsonLine() } + +// jsonLine renders the stored array with a space after each comma, which is how +// it is written to disk. encoding/json compacts whatever a Marshaler returns, so +// the file writer calls this directly; going through json.Marshal would strip +// the spaces again. Element order is the contract described above, kept here so +// it is stated once. +func (g Geo) jsonLine() ([]byte, error) { + parts := make([][]byte, 0, 4) + for _, v := range []any{g.Lat, g.Lon, g.Label, g.Country} { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + parts = append(parts, b) + } + return append(append([]byte{'['}, bytes.Join(parts, []byte(", "))...), ']'), nil +} + +func (g *Geo) UnmarshalJSON(b []byte) error { + var raw []any + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if len(raw) < 3 { + return fmt.Errorf("geo entry has %d fields, want at least 3", len(raw)) + } + lat, ok1 := raw[0].(float64) + lon, ok2 := raw[1].(float64) + if !ok1 || !ok2 { + return fmt.Errorf("geo entry has non-numeric coordinates") + } + label, _ := raw[2].(string) + country := "" + if len(raw) > 3 { + country, _ = raw[3].(string) + } + *g = Geo{Lat: lat, Lon: lon, Label: label, Country: country} + return nil +} + +type store struct { + Geo map[string]Geo `json:"geo"` + Teryt map[string]string `json:"teryt"` +} + +// Cache is a JSON file holding both sections. +type Cache struct{ path string } + +// New returns a cache backed by path. The file need not exist. +func New(path string) *Cache { return &Cache{path: path} } + +// DefaultPath is ~/.cache/prognosis/cache.json. +func DefaultPath() string { + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "prognosis", "cache.json") + } + return filepath.Join(os.Getenv("HOME"), ".cache", "prognosis", "cache.json") +} + +// load never fails the run: a cache it cannot read is treated as empty, because +// losing a cache costs one extra request and failing the run costs the forecast. +// +// It salvages per entry rather than per file. Decoding the sections whole meant +// one unreadable entry made the entire cache look empty, and the save that +// followed then wrote that emptiness over every entry that was still good. +// +// The second result reports whether the file was usable. It is false only when +// the document itself will not parse, which tells the writers to move it aside +// before replacing it: the cache is disposable, but a file someone hand-edited +// is the only copy of what they typed. +func (c *Cache) load() (store, bool) { + s := store{Geo: map[string]Geo{}, Teryt: map[string]string{}} + data, err := os.ReadFile(c.path) + if err != nil { + return s, true // absent is not corrupt; a fresh cache may be written + } + if len(bytes.TrimSpace(data)) == 0 { + return s, true // an empty file is simply no cache yet + } + var raw struct { + Geo map[string]json.RawMessage `json:"geo"` + Teryt map[string]json.RawMessage `json:"teryt"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return s, false + } + for k, v := range raw.Geo { + var g Geo + if err := json.Unmarshal(v, &g); err == nil { + s.Geo[k] = g + } + } + for k, v := range raw.Teryt { + var code string + if err := json.Unmarshal(v, &code); err == nil { + s.Teryt[k] = code + } + } + return s, true +} + +// encode writes the store one entry per line, keys sorted so the file is stable +// between runs. It is small, hand-edited, and read in a terminal, none of which +// a single long line serves. +func encode(s store) ([]byte, error) { + geo := make(map[string]json.RawMessage, len(s.Geo)) + for k, g := range s.Geo { + v, err := g.jsonLine() + if err != nil { + return nil, err + } + geo[k] = v + } + teryt := make(map[string]json.RawMessage, len(s.Teryt)) + for k, code := range s.Teryt { + v, err := json.Marshal(code) + if err != nil { + return nil, err + } + teryt[k] = v + } + var b bytes.Buffer + b.WriteString("{\n") + writeSection(&b, "geo", geo) + b.WriteString(",\n") + writeSection(&b, "teryt", teryt) + b.WriteString("\n}\n") + return b.Bytes(), nil +} + +func writeSection(b *bytes.Buffer, name string, entries map[string]json.RawMessage) { + if len(entries) == 0 { + fmt.Fprintf(b, " %q: {}", name) + return + } + keys := make([]string, 0, len(entries)) + for k := range entries { + keys = append(keys, k) + } + sort.Strings(keys) + fmt.Fprintf(b, " %q: {\n", name) + for i, k := range keys { + key, err := json.Marshal(k) // a place name may contain anything + if err != nil { + continue + } + fmt.Fprintf(b, " %s: %s", key, entries[k]) + if i < len(keys)-1 { + b.WriteByte(',') + } + b.WriteByte('\n') + } + b.WriteString(" }") +} + +// save writes atomically: a temporary file in the same directory, then a +// rename. Two runs at once would otherwise interleave and leave truncated JSON +// that the next run silently reads as an empty cache. +func (c *Cache) save(s store) error { + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return err + } + tmp := fmt.Sprintf("%s.%d.tmp", c.path, os.Getpid()) + data, err := encode(s) + if err != nil { + return err + } + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + if err := os.Rename(tmp, c.path); err != nil { + os.Remove(tmp) + return err + } + return nil +} + +// quarantine moves a cache that will not parse to <path>.bad, so replacing it +// costs nothing that cannot be recovered. Losing the cache is cheap -- one extra +// request -- but losing a hand-edit is not, and the two used to be the same act. +func (c *Cache) quarantine() error { + return os.Rename(c.path, c.path+".bad") +} + +// Geo returns a cached location. +func (c *Cache) Geo(place string) (Geo, bool) { + s, _ := c.load() + g, ok := s.Geo[place] + return g, ok +} + +// PutGeo records a location. +func (c *Cache) PutGeo(place string, g Geo) error { + s, ok := c.load() + if !ok { + if err := c.quarantine(); err != nil { + return err + } + } + s.Geo[place] = g + return c.save(s) +} + +// Teryt returns a cached powiat code. The empty string is a real answer meaning +// "GUGiK knows this point is not in Poland"; the boolean distinguishes it from +// never having asked. +func (c *Cache) Teryt(key string) (string, bool) { + s, _ := c.load() + code, ok := s.Teryt[key] + return code, ok +} + +// PutTeryt records a powiat code, or "" for a point outside Poland. +func (c *Cache) PutTeryt(key, code string) error { + s, ok := c.load() + if !ok { + if err := c.quarantine(); err != nil { + return err + } + } + s.Teryt[key] = code + return c.save(s) +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..3283db2 --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,282 @@ +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" +) + +func tmpCache(t *testing.T) *Cache { + t.Helper() + return New(filepath.Join(t.TempDir(), "sub", "cache.json")) +} + +func TestGeoRoundTrip(t *testing.T) { + c := tmpCache(t) + if _, ok := c.Geo("Krakow"); ok { + t.Fatal("empty cache reported a hit") + } + want := Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"} + if err := c.PutGeo("Krakow", want); err != nil { + t.Fatal(err) + } + got, ok := c.Geo("Krakow") + if !ok || got != want { + t.Fatalf("got %+v (%v), want %+v", got, ok, want) + } +} + +// "" is a real answer -- not in Poland -- and must be distinguishable from +// never having asked, or every foreign location re-queries GUGiK forever. +func TestTerytEmptyStringIsARealAnswer(t *testing.T) { + c := tmpCache(t) + if _, ok := c.Teryt("52.5200,13.4000"); ok { + t.Fatal("empty cache reported a hit") + } + if err := c.PutTeryt("52.5200,13.4000", ""); err != nil { + t.Fatal(err) + } + code, ok := c.Teryt("52.5200,13.4000") + if !ok { + t.Fatal("a cached empty code must report as present") + } + if code != "" { + t.Fatalf("code = %q, want empty", code) + } +} + +func TestCorruptFileIsTreatedAsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.json") + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + c := New(path) + if _, ok := c.Geo("anything"); ok { + t.Fatal("corrupt cache must read as empty, not error") + } + if err := c.PutGeo("x", Geo{Lat: 1}); err != nil { + t.Fatalf("must be able to overwrite a corrupt cache: %v", err) + } +} + +func TestSaveLeavesNoTempFiles(t *testing.T) { + dir := t.TempDir() + c := New(filepath.Join(dir, "cache.json")) + if err := c.PutGeo("a", Geo{Lat: 1}); err != nil { + t.Fatal(err) + } + entries, _ := filepath.Glob(filepath.Join(dir, "*.tmp")) + if len(entries) != 0 { + t.Fatalf("temp files left behind: %v", entries) + } +} + +// The whole point of the atomic write: concurrent writers must never leave a +// file that fails to parse. +func TestConcurrentWritesKeepValidJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + New(path).PutGeo("place", Geo{Lat: float64(i)}) + }(i) + } + wg.Wait() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var s store + if err := json.Unmarshal(data, &s); err != nil { + t.Fatalf("cache is not valid JSON after concurrent writes: %v\n%s", err, data) + } +} + +// The Python implementation shares this file and stores geo entries as +// [lat, lon, label, country]. If Go writes an object instead, Python crashes on +// its own cache -- which is exactly what happened once. +func TestGeoIsStoredAsAnArrayForPythonInterop(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + c := New(path) + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + var probe struct { + Geo map[string][]any `json:"geo"` + } + if err := json.Unmarshal(data, &probe); err != nil { + t.Fatalf("geo must decode as arrays: %v\n%s", err, data) + } + entry := probe.Geo["Krakow"] + if len(entry) != 4 { + t.Fatalf("geo entry = %v, want 4 elements", entry) + } + if entry[2] != "Krakow, PL" { + t.Errorf("third element must be the label, got %v", entry[2]) + } +} + +// A cache written in the Python shape must load here unchanged. +func TestReadsPythonWrittenCache(t *testing.T) { + path := filepath.Join(t.TempDir(), "cache.json") + body := `{"geo":{"Krakow":[50.06170,19.93730,"Krakow, PL","PL"]},"teryt":{"50.0617,19.9373":"1815"}}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + c := New(path) + g, ok := c.Geo("Krakow") + if !ok || g.Label != "Krakow, PL" || g.Country != "PL" { + t.Fatalf("got %+v (%v)", g, ok) + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Fatalf("teryt = %q (%v)", code, ok) + } +} + +// writeCache puts raw bytes where the cache expects its file. +func writeCache(t *testing.T, c *Cache, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(c.path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// One unreadable entry used to cost the whole file: load treated the parse +// error as "empty cache", and the next save wrote that emptiness over +// everything that was still fine. +func TestOneMalformedEntryDoesNotDestroyTheOthers(t *testing.T) { + c := tmpCache(t) + writeCache(t, c, `{"geo":{"Gdansk":[54.35227,18.64912,"Gdansk, PL","PL"],"Broken":[1]},"teryt":{"50.0617,19.9373":"1815"}}`) + + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + if _, ok := c.Geo("Gdansk"); !ok { + t.Error("a good entry was destroyed by an unrelated malformed one") + } + if _, ok := c.Geo("Krakow"); !ok { + t.Error("the new entry was not stored") + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Errorf("teryt section lost too: got %q, %v", code, ok) + } + if _, ok := c.Geo("Broken"); ok { + t.Error("the malformed entry should be dropped, not resurrected") + } +} + +// A hand-edit that breaks the whole document must not cost the file. The cache +// is disposable, but whatever was typed into it is not, so it moves aside +// rather than being overwritten -- and the run still gets a working cache. +func TestAnUnparseableFileIsMovedAsideNotDestroyed(t *testing.T) { + c := tmpCache(t) + const broken = `{"geo":{"Gdansk":[54.35227,` + writeCache(t, c, broken) + + if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373}); err != nil { + t.Fatalf("the tool must keep working: %v", err) + } + kept, err := os.ReadFile(c.path + ".bad") + if err != nil { + t.Fatalf("the unparseable file was not preserved: %v", err) + } + if string(kept) != broken { + t.Errorf("preserved copy differs:\n got %s\nwant %s", kept, broken) + } + if _, ok := c.Geo("Krakow"); !ok { + t.Error("the fresh cache did not take the new entry") + } +} + +func TestPutTerytAlsoMovesAnUnparseableFileAside(t *testing.T) { + c := tmpCache(t) + const broken = `{oops` + writeCache(t, c, broken) + if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil { + t.Fatalf("the tool must keep working: %v", err) + } + kept, err := os.ReadFile(c.path + ".bad") + if err != nil || string(kept) != broken { + t.Errorf("unparseable file not preserved: %q, %v", kept, err) + } + if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1815" { + t.Errorf("fresh cache did not take the entry: %q, %v", code, ok) + } +} + +// The file is read by a human at least as often as by the program. +func TestSaveWritesOneEntryPerLine(t *testing.T) { + c := tmpCache(t) + if err := c.PutGeo("Krakow", Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"}); err != nil { + t.Fatal(err) + } + if err := c.PutGeo("Chiang Mai", Geo{Lat: 18.79038, Lon: 98.98468, Label: "Chiang Mai, TH", Country: "TH"}); err != nil { + t.Fatal(err) + } + if err := c.PutTeryt("50.0617,19.9373", "1815"); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(c.path) + if err != nil { + t.Fatal(err) + } + want := `{ + "geo": { + "Chiang Mai": [18.79038, 98.98468, "Chiang Mai, TH", "TH"], + "Krakow": [50.06170, 19.93730, "Krakow, PL", "PL"] + }, + "teryt": { + "50.0617,19.9373": "1815" + } +} +` + if string(body) != want { + t.Errorf("got:\n%s\nwant:\n%s", body, want) + } +} + +func TestSavedFileIsStillValidJSONForTheOtherImplementation(t *testing.T) { + c := tmpCache(t) + want := Geo{Lat: 50.06170, Lon: 19.93730, Label: "Krakow, PL", Country: "PL"} + if err := c.PutGeo("Krakow", want); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(c.path) + if err != nil { + t.Fatal(err) + } + var got struct { + Geo map[string][]any `json:"geo"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("a stock JSON parser could not read it: %v", err) + } + row := got.Geo["Krakow"] + if len(row) != 4 { + t.Fatalf("got %d fields, want the 4-element shape Python reads: %v", len(row), row) + } + if row[2] != "Krakow, PL" { + t.Errorf("label field is %v, want the third element", row[2]) + } +} + +func TestEmptyCacheSavesReadableJSON(t *testing.T) { + c := tmpCache(t) + if err := c.PutTeryt("52.5200,13.4000", ""); err != nil { + t.Fatal(err) + } + body, _ := os.ReadFile(c.path) + if !json.Valid(body) { + t.Fatalf("invalid JSON with an empty geo section:\n%s", body) + } +} |
