Skip to content

v2.0.0

Choose a tag to compare

@ringsaturn ringsaturn released this 10 Sep 15:02
· 26 commits to main since this release
0c58323

v2.0.0

tzf v2 is protobuf-free. The runtime no longer decodes protobuf on any code
path: boundary data ships as the TZF embedded binary format (.tzb) and its
memory-image profile (.tzm), and the public surface is five constructors
that all return the tzf.F interface.

Measured on Apple M3 Max with the 2026c dataset: the default finder's
retained heap is 12.8 MiB, against ~32 MB for the v1 default (geometry is
stored in read-only data and aliased in place instead of being copied to the
heap). Load time is about 5× lower than the protobuf path (lite 18.6 ms
against 96 ms; full 78.5 ms against 288 ms). The new NewEmbeddedFinder
queries the 3.97 MB file in place with under 1 KB of heap.

Module path is now github.com/ringsaturn/tzf/v2.

go get github.com/ringsaturn/tzf/v2

Public surface

Five constructors, all returning tzf.F. The mechanism behind a finder is not
part of the API, and every artifact carries the same dataset (2026c, 444
timezone names). Latency and memory figures are from make bench and
make bench-memory on Apple M3 Max, 2026c:

Constructor Mechanism Memory Query (p50, random / border)
NewDefaultFinder() lite .tzm memory image, polygon storage aliasing rodata 12.8 MiB heap + ~10 MB rodata 208 ns / 542 ns
NewEmbeddedFinder() lite .tzb queried in place, no geometry expansion <1 KB heap + ~4 MB rodata 583 ns / 6.9 µs
NewFullFinder() full-precision .tzb expanded at load 146.7 MiB heap 208 ns / 666 ns
NewFinderFromTZB(data) any .tzb, always expanded; data released after load 27.5 MiB heap (lite dataset) 208 ns
NewFinderFromTZM(data) any .tzm, always aliased in place; data retained as NewDefaultFinder 208 ns

Open times on the same machine and dataset, 16 cores / 1 core (the
single-core figure models a cgroup-quota pod): lite .tzb in place
1.7 / 1.7 ms; lite .tzm 7.7 / 28 ms; lite .tzb expanded 18.6 / ~41 ms;
full .tzb expanded 78.5 / 214 ms.

Also public:

  • tzf.GeoJSONer: GetTZGeoJSON, GetGeoJSON, GetTZPreindexGeoJSON,
    GetPreindexGeoJSON. F covers the four query methods only, so this is a
    separate interface and test doubles and third-party F implementations need
    not produce geometry; assert it on the value a constructor returned
    (f.(tzf.GeoJSONer)). Every finder this package builds satisfies it,
    including the in-place one, which decodes only the requested timezone's
    rings on demand. Output is byte-identical across mechanisms.
  • tzf.ErrNoTimezoneFound / tzf.ErrNoFuzzySection: sentinel errors.
  • github.com/ringsaturn/tzf/v2/x: experimental surface holding
    NewFinderFromTZBReaderAt(source io.ReaderAt, size int64) for in-place
    queries over a file, an mmap'd region, or a bytes.Reader over
    caller-owned bytes. It is exempt from the module's semver promise; see
    Known caveats.

GetTimezoneName is fuzzy-first: a preindex tile resolves most queries with
no point-in-polygon work. GetTimezoneNames runs the polygon scan in every
finder and is the call to use when a point can belong to more than one
timezone.

Removed API

Every protobuf-typed constructor is gone, together with the per-mechanism
constructors and the tile-only finder:

  • NewFinderFromPB, NewFinderFromCompressed, NewFinderFromCompressedTopo,
    NewFuzzyFinderFromPB, NewFinderFromRawJSON
  • NewFinderFromTZBExpanded, NewFuzzyFinderFromTZB,
    NewDefaultFinderFromTZB, NewDefaultFinderFromTZM, and the root-package
    NewFinderFromTZBReaderAt (moved to x)
  • the exported finder types Finder, FuzzyFinder, DefaultFinder;
    constructors return the F interface
  • FuzzyFinder in any form: there is no tile-only finder, no
    NewFuzzyFinder*, and no replacement for a finder that resolves queries
    from tiles alone. The preindex remains as the internal fast path inside
    every finder, and its tiles are exportable as GeoJSON through GeoJSONer.
  • the public pipeline packages reduce, preindex, and convert.Do; the
    pipeline is internal and is driven through the cmd/ binaries
  • GetTZGeoJSON / GetGeoJSON return serialized GeoJSON bytes; the
    *convert.BoundaryFile return type is gone and the boundary-file types are
    internal

Data source change

tzf-dist now ships three artifacts, all carrying one data_version and each
bundling its own FUZZY preindex section, so there is no separate preindex
artifact to keep in sync:

Artifact Size Backs
lite.tzb 3.97 MB NewEmbeddedFinder, x, generic TZB use
lite.tzm 10.18 MB NewDefaultFinder
full.tzb 13.77 MB NewFullFinder
  • The protobuf artifacts stop being published. Once this set ships,
    tzf-dist no longer builds or publishes v1's protobuf artifacts, and the
    v1 line is frozen at its last data release: staying on v1 means staying on
    that data version, and updated boundaries require moving to v2.
  • .tzb is the transport format. No full-precision .tzm is distributed;
    derive one locally with cmd/tzb2tzm (tzb2tzm -o out.tzm in.tzb), whose
    output is byte-identical to encoding the .tzm from source. The compact
    .tzb can be shipped and the memory image produced where it is consumed.
  • The data build runs directly from the upstream raw GeoJSON, with no
    protobuf step at any stage; intermediates are gob and are never distributed.
    Rebuilding from GeoJSON was verified equivalent to the protobuf-era
    artifacts (full.tzb byte-identical).

Boundary accuracy is unchanged: the lite dataset's simplification is bounded
at ~111 m (certified maximum 111.7 m; 0.41% of boundary length displaced more
than 100 m; 0% more than 500 m). See BORDER_CHANGE.md.

Migration

Update the import path (github.com/ringsaturn/tzf →
github.com/ringsaturn/tzf/v2), then map the call sites:

v1 v2
tzf.NewDefaultFinder() tzf.NewDefaultFinder() (unchanged call sites; now backed by lite.tzm)
tzf.NewFullFinder() tzf.NewFullFinder() (unchanged call sites)
*tzf.Finder, *tzf.DefaultFinder (named types) removed; constructors return tzf.F
tzf.FuzzyFinder, tzf.NewFuzzyFinderFromPB removed, no replacement; the preindex is the fast path inside every finder
tzf.NewFinderFromCompressedTopo(pb) tzf.NewFinderFromTZB(data)
tzf.NewFinderFromCompressed(pb) tzf.NewFinderFromTZB(data)
tzf.NewFinderFromPB(pb) convert offline with cmd/geojson2tzpb … cmd/topo2embed, then NewFinderFromTZB
tzf.NewFinderFromRawJSON(...) removed; build a .tzb with the pipeline commands instead
tzf.NewFinderFromTZBExpanded(data) tzf.NewFinderFromTZB(data) (expansion is the only TZB behavior)
tzf.NewDefaultFinderFromTZB/TZM, NewFuzzyFinderFromTZB tzf.NewFinderFromTZB / tzf.NewFinderFromTZM; composition is derived from the file
tzf.NewFinderFromTZBReaderAt(r, size) x.NewFinderFromTZBReaderAt(r, size); see the x stability policy
in-place querying over caller-owned bytes x.NewFinderFromTZBReaderAt(bytes.NewReader(data), int64(len(data))); same policy
f.(*tzf.Finder).GetTZGeoJSON(name) f.(tzf.GeoJSONer).GetTZGeoJSON(name) (returns bytes)
FuzzyFinder tile-bbox GeoJSON f.(tzf.GeoJSONer).GetTZPreindexGeoJSON(name) / GetPreindexGeoJSON()
convert.Do, convert.Revert, reduce, preindex internal; the pipeline is driven through the cmd/ binaries

Behavior changes:

  • GetTimezoneNames results are sorted lexicographically.
  • A point exactly on a shared border belongs to every touching polygon
    (exterior rings allow on-edge, hole rings do not).
  • v1's tile-bbox GeoJSON on FuzzyFinder is replaced by the preindex export
    on GeoJSONer, which covers the same tiles from any finder that carries a
    FUZZY section.

Known caveats

  • x package stability: within v2.y.z, a bump of y may change or remove
    anything in x; only z bumps are guaranteed not to. The root package
    keeps the normal promise, with breaking changes only at v3. The convention
    follows golang.org/x/…; pin an exact version if you depend on x and
    cannot absorb a break at a minor release.
  • .tzm alignment: NewFinderFromTZM aliases the file's point section as
    polygon storage on little-endian hosts, which requires the slice to be
    8-byte aligned. A misaligned slice or a big-endian host falls back to a
    one-time decoded copy, which produces the same results and does not reduce
    memory. tzf-dist pins the alignment of its embedded lite.tzm with a
    regression test, so a toolchain change fails a test instead of doubling
    NewDefaultFinder's footprint.
  • NewFinderFromTZM retains its input: the byte slice is used directly as
    polygon storage and must stay live and unmodified for the finder's lifetime.
    Pass go:embed bytes or an mmap'd region. NewFinderFromTZB releases
    data after loading.
  • NewEmbeddedFinder latency: preindex hits stay under 1 µs, and a miss scans
    the compressed geometry at about 6.9 µs on border cases against 542 ns for
    NewDefaultFinder (Apple M3 Max, 2026c). GetTimezoneNames always scans,
    at about 8.8 µs.
  • The x ReaderAt route serializes queries internally to keep the query path
    allocation-free, so its throughput does not scale with core count the way it
    does for the byte-backed and expanded finders.
  • Command names in cmd/ still read *tzpb (geojson2tzpb, reducetzpb,
    and so on) although no protobuf remains in the pipeline; they are kept so
    the existing data-build workflows keep working.

Full Changelog: v1.2.5...v2.0.0