From e4ede101b89a1f14698abb724a231de963502fe0 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 5 Nov 2026 16:37:34 -0600 Subject: [PATCH] Prototype portable KV-backed Geo indexing and lookup --- .../2026-11-05-kv-geo-prototype-results.md | 100 ++ .../specs/2026-11-05-kv-geo-design.md | 225 +++ examples/geo-kv-prototype/Cargo.lock | 1246 +++++++++++++++++ examples/geo-kv-prototype/Cargo.toml | 20 + examples/geo-kv-prototype/README.md | 79 ++ examples/geo-kv-prototype/src/import.rs | 337 +++++ examples/geo-kv-prototype/src/index.rs | 767 ++++++++++ examples/geo-kv-prototype/src/main.rs | 237 ++++ examples/geo-kv-prototype/src/store.rs | 129 ++ 9 files changed, 3140 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-05-kv-geo-prototype-results.md create mode 100644 docs/superpowers/specs/2026-11-05-kv-geo-design.md create mode 100644 examples/geo-kv-prototype/Cargo.lock create mode 100644 examples/geo-kv-prototype/Cargo.toml create mode 100644 examples/geo-kv-prototype/README.md create mode 100644 examples/geo-kv-prototype/src/import.rs create mode 100644 examples/geo-kv-prototype/src/index.rs create mode 100644 examples/geo-kv-prototype/src/main.rs create mode 100644 examples/geo-kv-prototype/src/store.rs diff --git a/docs/superpowers/plans/2026-11-05-kv-geo-prototype-results.md b/docs/superpowers/plans/2026-11-05-kv-geo-prototype-results.md new file mode 100644 index 000000000..24b2b59a2 --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-kv-geo-prototype-results.md @@ -0,0 +1,100 @@ +# KV Geo prototype results + +- Date: 2026-11-05. +- Status: format/lookup experiment completed; production integration and remote KV acceptance remain open. +- Spec: [portable KV Geo](../specs/2026-11-05-kv-geo-design.md). +- Code and reproduction commands: [prototype README](../../../examples/geo-kv-prototype/README.md). +- Base revision: `72ceed7e2a802cfa536851dd20c95ca55ebea4fc`, plus the standalone prototype identified by the source fingerprint below. +- Environment: Linux x86_64, Rust 1.95.0, optimized Cargo release build, local file-backed artifact KV. +- Tested source fingerprint: `70227e734eee4709c7072a45a50dcc00ea15bd1c947c82596025a309e9c2ddbf`. Compute with `sha256sum examples/geo-kv-prototype/src/*.rs examples/geo-kv-prototype/Cargo.toml examples/geo-kv-prototype/Cargo.lock | sha256sum`. +- Tested release binary SHA-256: `dd9af4b519c1213f2094b6805a41307fc637137eded40751491384dbfb5e6878`. + +## Dataset + +Used the user's `GeoLite2-City-CSV_20261103.zip`, kept outside Git. Archive SHA-256 is `94745697bcd25751e38c9386260eeb29527f851ec9e3732cfe58aa62a07b9ab8`. ZIP CRC checks passed; a publisher-provided checksum was not available for comparison. + +| Input | Count | +| --- | --- | +| English location rows | 75,449 | +| IPv4 source networks | 3,632,756 | +| IPv6 source networks | 2,091,942 | +| Merged IPv4 geographic intervals | 1,371,640 | +| Merged IPv6 geographic intervals | 605,625 | +| IPv4 networks without country | 768 | +| IPv6 networks without country | 724 | + +The import joins geolocation IDs only. It does not substitute registered-country IDs or include names, coordinates or postal codes in the index. Source networks without country produce gaps, not guessed locations. + +## Index shape + +All objects were written and read back through the actual EdgeZero `KvHandle`. The transport was the prototype's local artifact-file `KvStore`, not a cloud service. + +| Object budget | Encoded total | Objects, including manifest | Uncached path objects, IPv4/IPv6 | Import, build and read-back | +| --- | --- | --- | --- | --- | +| 16 KiB | 40.423 MiB | 2595 | 3/3 | 1.482 s | +| 64 KiB | 40.302 MiB | 649 | 2/2 | 1.487 s | +| 256 KiB | 40.272 MiB | 165 | 2/2 | 1.490 s | +| 384 KiB | 40.269 MiB | 111 | 2/2 | 1.460 s | + +A resolver acquires the manifest with one additional read when opened. The path counts above exclude it and are maxima; root/directory gaps can return without a leaf read. + +The 64 KiB build contained 42,259,649 encoded bytes and 649 objects. Its largest object was 65,532 bytes. Both address families fit a root directory followed by a leaf. Increasing shards beyond 64 KiB reduced key count but did not remove another read from this dataset's path. + +Importer whole-process peak RSS was about 231.4 MiB. That process retains raw CIDRs for verification and builds merged ranges, so this is not serving memory. + +## Lookup checks and local timings + +For each size, checked 2,048 points per family in three passes: no object cache, an initially empty 4 MiB object cache, and a repeat using that same cache. All 49,152 comparisons across four sizes and three modes matched the independent source-CIDR oracle. These are repeated comparisons over the same 4,096-point workload, not 49,152 distinct random addresses or exhaustive dataset coverage. + +The sample mix includes family endpoints, stratified source-network endpoints and neighbors, and deterministic uniform random addresses. Each pass found 2,917 geographic locations and 1,179 no-matches. It is a correctness workload with correlated neighbors and many uncovered addresses, not a user-traffic distribution. + +| Object budget | Cache-disabled p95, microseconds | Repeated-cache p95, microseconds | Repeated-cache reads/query | Repeated-cache bytes/query | +| --- | --- | --- | --- | --- | +| 16 KiB | 63.5 | 46.5 | 0.240 | 3.83 KiB | +| 64 KiB | 158.5 | 142.9 | 0.228 | 14.51 KiB | +| 256 KiB | 524.9 | 514.6 | 0.184 | 46.51 KiB | +| 384 KiB | 1266.1 | 749.0 | 0.184 | 69.96 KiB | + +Timings include local file reads, hashing, validation and lookup. OS page cache was uncontrolled. They do not measure remote latency, cold storage, concurrency, provider billing or cache behavior across short-lived isolates. The mean read/byte rates are specific to this sample ordering and cache budget. + +A fresh 64 KiB `lookup` process resolved four sample inputs without loading source CSV or an inventory. It read one manifest, one directory and one leaf, retained 77,304 cache payload bytes, and peaked at 3,006,464 bytes of whole-process RSS. This proves a small lookup workload avoids the importer's full-data memory footprint. It does not establish maximum serving RSS under load. Cache limits count retained encoded payload, with allocator metadata and validation scratch memory in addition. + +## Country-code policy mismatch + +The real release contains `XK`, while EdgeZero's existing `CountryCode` rejects it. There are 393 IPv4 and 68 IPv6 source networks with that code. + +The initial strict import failed on this mismatch. To finish the format experiment without changing production country policy, the standalone prototype preserves syntactically valid source country codes in a separate DTO and reports whether core accepts them. A fresh lookup over an actual `XK` interval returned the preserved code with `core_country_supported: false`. No shared allowlist or application outcome changed. + +This is a decision needed before production implementation. Rejecting a complete release because it contains one unsupported code prevents using this dataset as supplied. Alternatives must explicitly define unsupported-code behavior rather than silently losing those intervals or calling them `NoMatch`. + +## Verification record + +| Check | Result | +| --- | --- | +| `cargo test --offline --locked --manifest-path examples/geo-kv-prototype/Cargo.toml` | 10 prototype tests passed | +| `cargo clippy --offline --locked --manifest-path examples/geo-kv-prototype/Cargo.toml --all-targets -- -D warnings` | Passed | +| Prototype and workspace `cargo fmt -- --check` commands | Passed | +| `cargo test --offline --locked -p edgezero-core` | 753 unit tests and enabled doctests passed; 13 existing doctests ignored | +| `cargo build --offline --locked --release --manifest-path examples/geo-kv-prototype/Cargo.toml` | Passed | +| Real-data build at 16/64/256/384 KiB | Every exported object read back and digest-checked | +| Repeat real-data builds | Same receipts and immutable object bytes accepted for every size | +| CSV oracle with cache off/on/repeated | 49,152 comparisons, zero mismatches | +| Fresh-process lookup, including real `XK` interval | No CSV/inventory preload; unsupported-domain flag preserved | +| Pre-PR `cargo test --offline --locked --workspace --all-targets` | 2,115 passed, six existing tests ignored | +| Pre-PR `cargo clippy --offline --locked --workspace --all-targets --all-features -- -D warnings` | Passed | +| Pre-PR `cargo check --offline --locked --workspace --all-targets --features 'fastly cloudflare spin'` | Passed | +| Pre-PR `cargo check --offline --locked -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` | Passed | + +Synthetic tests cover multilevel traversal, deterministic output, cache eviction, IPv4/IPv6 zero and maximum addresses, empty families, gaps, country-only results, absent geolocation without registered-country fallback, quoted CSV fields, bad references, duplicate identities, overlaps, bad family/CIDR input, missing objects, wrong digests and malformed records with matching digests. Missing expected data is an error, not a no-match. + +Logs from this session are under `/tmp/geo-kv-prototype-*.log`. Dataset, artifacts and durable machine-readable measurements are under `~/.cache/edgezero/geo-kv-prototype/`; each `artifacts-k/` directory contains `receipt.json`, `inventory.json`, `build-report.json` and `verification-report.json`. These are local artifacts, not committed production data. + +## Next decisions + +64 KiB is a reasonable next remote-KV experiment: it preserved two-read lookup paths while transferring and validating less data than 256/384 KiB shards. The locally fastest 16 KiB objects needed a third uncached read, which may lose once network latency is included. No serving default or wire format is frozen by these results. + +Next work should decide country-code policy, exercise actual provider upload/read paths, and measure representative traffic and remote KV costs before changing request APIs or dispatch. City import and synthetic Country-shaped CSV were exercised; a full real Country release was not. + +Not verified: production async provider composition, request cancellation/deadlines, reader-IP admission on edge adapters, WASM runtime behavior, live KV latency/capacity, globally visible publication, crash-safe uploads, licensing review or deletion across deployed caches. No live credentials or cloud resources were used. The pre-PR workspace gates above passed without changing the prototype's measured source fingerprint; logs are under `/tmp/geo-kv-pr-*.log`. No new Fastly/Cloudflare WASM runtime execution or live-service acceptance was performed. + +The existing GitHub workflow base-branch filters do not include `feature/pluggable-geo-providers`, and root workspace checks do not include this standalone crate. The explicit prototype commands in this record are its local verification, not a claim of automated GitHub coverage. diff --git a/docs/superpowers/specs/2026-11-05-kv-geo-design.md b/docs/superpowers/specs/2026-11-05-kv-geo-design.md new file mode 100644 index 000000000..468bc06ec --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-kv-geo-design.md @@ -0,0 +1,225 @@ +# Portable KV-backed Geo + +- Status: draft for review. The format prototype is measured; production Geo integration and remote KV behavior are not implemented or verified. +- Date: 2026-11-05. +- Base: `72ceed7e2a802cfa536851dd20c95ca55ebea4fc`, the shared base of this branch and `feature/axum-local-geoip` before its MMDB implementation. +- Related work: [pluggable Geo providers](2026-10-22-pluggable-geo-providers-design.md), issues [#432](https://github.com/IABTechLab/edgezero/issues/432) and [#438](https://github.com/IABTechLab/edgezero/issues/438). + +## 1. Outcome and decisions + +Consumers can resolve an admitted reader IP to an estimated country and optional first-level subdivision without platform-provided geography, a runtime filesystem, or loading the entire dataset into memory. They supply a populated KV store through EdgeZero's existing store abstractions. + +Use GeoLite CSV as the initial import format. Transform it outside the serving runtime into an immutable, versioned IP index. Resolve requests asynchronously through `KvHandle`, with optional bounded in-memory caching. Geography remains an estimate; applications own jurisdiction recognition and policy. + +KV is the portable serving dependency, not a claim of better lookup performance than MMDB. Remote KV adds read cost, latency and failure modes. The first implementation must measure these costs before making production performance claims. + +Required invariants: + +- A deployment explicitly selects zero or one Geo provider. There is no automatic source selection or fallback. +- One immutable Geo outcome is resolved after ingress admission and before application middleware and handlers. +- The adapter owns reader-IP trust. The resolver receives a typed, admitted IP rather than parsing request headers. +- Request lookup uses exact-key reads, never key listing, full-dataset loading or per-prefix probing. +- Every request uses one pinned dataset generation, including cache hits and misses. +- Missing or corrupt required index data is a provider failure, not an unmatched address. +- Runtime Geo resolution requires no filesystem, local sidecar, MaxMind credentials or downloader. +- Portable code gains no native SDK, Tokio dependency, mandatory `Send` future or platform-specific timer. + +CloudFront assertions remain an optional selected provider. This work does not depend on the sibling MMDB implementation and does not remove it from that branch. + +## 2. Ownership and flow + +| Concern | Owner | +| --- | --- | +| Dataset acquisition, download integrity and licensing | Operator's update process | +| CSV validation, geographic projection and index construction | Offline importer | +| KV resource creation, credentials and backend bindings | Existing platform tooling and operator | +| Index lookup and decoding | Portable KV Geo implementation | +| Socket/platform client identity and forwarding trust | Adapter ingress | +| Per-request outcome and geographic value types | Portable Geo contract | +| Jurisdiction, consent, permissions and response caching | Application | + +```mermaid +flowchart TD + CSV[GeoLite City or Country CSV release] --> Import[Offline validation and index construction] + Import --> Publish[Publish immutable KV objects] + Publish --> Verify[Read back and verify generation] + Verify --> Select[Deploy with pinned manifest identity] + Request[Incoming request] --> Ingress[Adapter admission and checked reader IP] + Ingress --> Resolver[Portable async Geo resolution] + Select --> Resolver + Resolver --> Cache{Index object cached?} + Cache -->|Yes| Search[Search validated object] + Cache -->|No| Read[Read exact KV key and validate object] + Publish --> Read + Read --> Search + Search --> Next{Directory or leaf?} + Next -->|Directory| Cache + Next -->|Leaf or address gap| Outcome[Immutable request Geo outcome] + Outcome --> App[Application middleware and handlers] +``` + +## 3. Import contract + +Initially support GeoLite2 City and Country CSV releases. City is needed for subdivisions. For City, consume these files from the same archive: + +```text +GeoLite2-City-Blocks-IPv4.csv +GeoLite2-City-Blocks-IPv6.csv +GeoLite2-City-Locations-en.csv +``` + +Country uses the corresponding Country filenames and produces country-only locations. Read columns by header name so unrelated added columns do not change the projection. + +Join block `geoname_id` to location `geoname_id`. Keep `country_iso_code` and, for City, `subdivision_1_iso_code`. Do not substitute `registered_country_geoname_id` or `represented_country_geoname_id`; those identify different geographic facts. Names, coordinates, postal codes and network registration metadata are not needed in the serving index. + +Validate address-family membership, CIDRs, duplicate location identities, referenced locations and consumed geographic codes. A populated reference with no matching location is an import error. An absent geolocation reference or location without country yields no geographic coverage, not a guessed country. A subdivision without country is invalid. Country-only locations remain partial; subdivision validation checks syntax, not worldwide assignment or application recognition. + +Convert networks into sorted numeric intervals, separately for IPv4 and IPv6. Reject conflicting or overlapping source intervals rather than inventing precedence. Adjacent intervals with identical projected locations may be merged. Preserve gaps. Use overflow-safe address arithmetic, including the last address in each family. + +The importer emits deterministic objects, their keys and digests, and a publication inventory. Metadata records the dataset edition, source release identifier, source file digests, index schema version and importer version. Repeated import with the same source and format settings produces the same object bytes. + +The importer may use a filesystem and native tools. Those requirements belong to the publishing environment, not deployed applications. MaxMind authentication stays outside the serving runtime. + +## 4. KV index + +### Object layout + +Use a versioned tree of directory objects and leaf objects, separately addressable through exact KV keys. A small manifest identifies both family roots and the schema. Empty families are explicit. + +Conceptual keys: + +```text +geo/v1//manifest +geo/v1//v4/directory/ +geo/v1//v4/leaf/ +geo/v1//v6/directory/ +geo/v1//v6/leaf/ +``` + +A leaf contains sorted, disjoint inclusive intervals and their country and optional subdivision. A directory contains sorted, disjoint child envelopes, exact child keys and expected content digests. Envelopes include any internal gaps; the selected leaf confirms actual coverage. A directory gap proves no coverage without another read. + +Build leaves by encoded byte size, then build directories over contiguous groups until each family has one root. Additional directory levels allow the directory itself to exceed one KV value without requiring a whole-directory preload. Lookup chooses at most one child at each level by binary search. It never probes multiple prefixes or scans neighboring leaves. + +Use a versioned binary encoding with explicit record counts, address-family widths and network-byte-order addresses. Locations are stored with the intervals, avoiding a second location-record read. The exact byte schema, directory fanout and default object size must be recorded after the prototype; this draft does not claim they are settled. + +### Limits and validation + +All accesses go through `KvHandle`. Build to an explicit target backend value budget, including serialization overhead. The portable 25 MiB ceiling is not a promise every backend accepts that size. For example, the current native DynamoDB adapter caps values at 384 KiB and permits operators to lower that cap. + +The reader validates schema, digest, encoded lengths, counts, ordering, interval bounds, child bounds and geographic values before using an object. Child keys must remain within the selected generation and address family, and children must fit the parent's declared envelope. Expected digests bind decoded descendants to the selected manifest. Hashes identify bytes, not publisher authenticity. + +The implementation must specify per-object bytes, traversal work and memory budgets. Derive defaults from the supported backend limits and measured index shape, rather than inventing an arbitrary fixed tree depth. Reject an incompatible dataset explicitly; never skip validation or truncate data to fit. + +## 5. Async resolution and request lifecycle + +The base's `GeoProvider` is synchronous and assertion-only. KV resolution requires a new async execution boundary and a typed reader-IP input; it cannot be implemented by blocking on KV inside that existing trait. + +Retain the public synchronous supplied-provider contract for existing consumers. Add an object-safe asynchronous provider contract using the repository's non-`Send` future convention. Existing synchronous providers are wrapped at composition and return immediately. Native hosting retains the object ownership bounds it needs. The new provider owns its configured `KvHandle`; request input does not expose the request or a service registry. + +Give the new async contract discriminated assertion and reader-IP inputs, with source admission owned by adapters. Preserve the existing synchronous `GeoInput` assertion struct; the composition wrapper converts only the assertion variant into it. Add `NoMatch` and `MissingReaderIp` outcomes and reader-IP provenance. Existing country/subdivision value types and `RequestContext::geo()` remain the consumer interface. New public variants need release notes and exhaustive-match migration examples. + +Managed request sequence: + +1. Perform existing ingress validation, forwarding normalization and admission. Rejected or aborted ingress makes no Geo lookup. +2. Establish the selected input contract from adapter-private facts. Missing usable reader input produces `MissingReaderIp` without provider calls. +3. Await the selected provider once, with the request's remaining execution/cancellation budget. Traverse index objects and return one resolution. +4. Attach an owned immutable outcome before request preparers, application middleware and handlers. Response finalization never resolves again. + +A logical provider invocation can perform multiple KV reads. Without cache hits, read count is manifest acquisition plus one read per directory level and one leaf. The manifest may be retained for the configured instance lifetime. Concurrent cold requests may perform duplicate reads without changing correctness; request coalescing is not a first-version requirement. + +Geo code adds no retry loop. Backend retry and timeout behavior must be documented separately. Adapter integration supplies runtime-specific deadline/cancellation mechanics; portable lookup does not acquire a timer dependency. Cancellation releases request ownership without detached lookup work. A dropped future does not promise the underlying platform has aborted its physical I/O. + +| Condition | Outcome | +| --- | --- | +| Geo not selected | `NoSelection` | +| Selected, no admitted usable reader IP | `MissingReaderIp` | +| Validated directory or leaf proves address gap | `NoMatch` | +| Matched country, absent subdivision | Located, country only | +| Matched country and subdivision | Located, estimated subdivision | +| Expected object absent, invalid, unsupported or digest mismatch | Provider failure | +| KV error or Geo lookup deadline exhausted | Provider failure | + +Provider failure does not choose CloudFront, another dataset, or an older cache generation. Outcomes do not automatically choose HTTP status or permission policy. + +## 6. Configuration and adapter portability + +Configuration selects a logical KV store ID, dataset generation, expected manifest digest and reader-IP expectation. Resolve the logical ID through existing bindings; the Geo selection contains no physical table name, namespace credentials or filesystem path. Fail configuration if the ID is undeclared, the selection is unsupported, or the configured backend cannot serve the dataset's object budget. + +Use an explicit portable Geo selection that every managed adapter can consume. The exact Rust and manifest spelling remains a review decision. Selecting Geo must not silently create a new default store or reuse an application's default store by accident. + +Native hosting prepares the manifest after KV handles are ready and before readiness. Request-scoped edge runtimes may acquire it during request initialization and optionally retain it in runtime-supported memory. They do not need a process-wide startup phase or full-data validation on every invocation. Unsupported schemas or missing manifests fail initialization; missing descendants remain request-time provider failures. + +Every adapter must establish an admitted client IP independently of geographic metadata: + +| Adapter | Required integration | +| --- | --- | +| Axum | Reuse checked peer and trusted-forwarding normalization; support direct-peer or forwarded-required expectation. | +| Fastly | Use platform client-address facts with documented provenance. | +| Cloudflare | Use the platform's documented client-address mechanism and prove its replacement/trust contract, rather than accepting an arbitrary copied header. | +| Spin | Use a verified runtime client-address mechanism or an explicit trusted-ingress integration; return missing input if neither is available. | + +These are acceptance requirements, not assertions that the base already implements them. Preserve existing mapped-IPv4 normalization. Never geolocate a reverse proxy merely because its socket peer is available under a forwarded-required selection. + +KV makes lookup portable, but does not create trustworthy reader identity. A runtime with KV but no trustworthy client address cannot produce reader-IP geography. Report that limitation explicitly rather than weakening admission. + +## 7. Cache behavior + +Caching is optional and bounded by bytes. Cache validated immutable objects using store identity, dataset generation and object digest. Eviction only causes another KV read. Fresh workers and caches disabled entirely must produce the same outcomes as warm workers. + +Use in-memory caching where the runtime permits it, without requiring cross-request or cross-instance persistence. Do not cache reader IPs or add an IP-to-location cache. A missing expected object is a publication/storage failure, not a negative geolocation cache entry. + +Retiring a dataset includes stopping or reconfiguring instances that retain its objects in memory. A version pin prevents accidental mixing; it does not establish freshness or permission to keep using old data. + +## 8. Publication and lifecycle + +A complete offline build precedes publication. One publishing operation owns a generation; concurrent publishers use separate generation targets. Upload objects under their immutable keys, read them back and verify their expected bytes or digests, then publish the manifest last. A retry of the same build writes the same bytes. Never overwrite a published generation with different data. + +The portable KV API has no compare-and-swap or multi-key transaction. Immutable publication is therefore an operator/tooling ownership rule, not a storage-enforced lock. Deployment configuration pins the manifest digest. There is no mutable `active` pointer in the first version. + +A manifest written last is not a global readiness barrier on eventually consistent KV. Validate propagation through the selected provider's supported mechanisms before rollout. A successful uploader-region read-back does not prove all serving locations can read every descendant. If an object is temporarily unavailable, readers return provider failure without switching generations. Document the backend-specific rollout check and residual uncertainty. + +A stopped upload leaves an unselected partial generation. Re-running the same inventory converges; cleanup of abandoned generations uses the retained inventory outside request handling. Retire selected generations only after readers stop referencing them, subject to the dataset's mandatory update and deletion terms. Do not rely on KV TTL as proof of immediate deletion. + +The publishing workflow needs two outputs: an artifact containing the exact key/value inventory, and a way to upload and verify it for the selected backend. Runtime `KvHandle` portability does not imply a workstation can instantiate every edge SDK's store binding. Reuse existing provider tooling where available; otherwise add an explicit uploader or document an operator-supported upload workflow. CLI syntax and initial backend uploader coverage must be agreed before implementation is considered end-to-end complete. + +Keep dataset acquisition and MaxMind credentials outside these runtime APIs. Attribution and redistribution/deletion obligations apply to the derived KV index, retained export artifacts, caches and backups as well as the original CSV archive. + +## 9. Diagnostics + +Expose bounded categories for configuration, initialization and lookup failures. Record schema and loaded dataset identity in startup/initialization diagnostics, not per-IP labels. Useful metrics include lookup latency, KV reads, cache hits, bytes decoded and outcome category. + +Do not retain reader IPs, raw forwarding headers, credentials or raw KV errors in Geo outcomes or default diagnostics. KV keys and values need the existing storage logging discipline; index keys encode dataset structure, not individual request addresses. Dependency logging is a separate operator concern. + +## 10. Acceptance and delivery + +Deliver in checkable units: + +1. **Format prototype.** Build synthetic City/Country fixtures and a real permitted GeoLite release. Record encoded size, object count, depth, cold-read count, import cost and maximum decoded-object memory. Freeze schema and defaults only after these measurements. +2. **Importer and lookup.** Prove deterministic bytes and compare index results with an independent direct CSV-range lookup, including generated/random addresses, family boundaries, gaps, country-only records and subdivisions. Reject malformed references, overlaps and corrupt objects. Exercise actual `KvHandle` reads with caching off and on. +3. **Async contract and native integration.** Preserve existing CloudFront/supplied-provider behavior. Test checked-reader input, source rejection, request admission, deadlines, cancellation and immutable outcomes through managed Axum dispatch. No detached work, blocking KV bridge or response-finalization lookup. +4. **Edge adapter integration.** Run managed request tests for Fastly, Cloudflare and Spin, including forged address inputs and runtimes without usable reader facts. Compile all WASM targets without native dependency leakage. Generic data access alone is not adapter acceptance. +5. **Publication and deployed checks.** Exercise interrupted uploads, identical re-publication, read-back mismatch, incomplete visibility, pinned versions, missing descendants and retirement. Verify real provider upload/binding/read paths separately from mocks, with operator authorization for live resources. + +Performance acceptance must include cold and warm lookup latency, reads and bytes per request, memory, and KV cost at the intended traffic level. Test representative native remote KV and edge KV. The current DynamoDB binding uses one partition key per namespace, so workload capacity and hot-partition behavior need explicit evaluation; index sharding alone does not change that physical partitioning. + +The spec is ready for implementation planning when the byte format/object budgets, configuration spelling, reader-IP support matrix, publishing coverage and acceptable lookup budgets have been reviewed. Until then it is a design draft, not a production-readiness claim. + +## 11. Prototype findings + +The [standalone prototype](../../../examples/geo-kv-prototype/README.md) and [measurement record](../plans/2026-11-05-kv-geo-prototype-results.md) exercise the downloaded City CSV through `KvHandle` using a local artifact-store harness. A 64 KiB object budget produced about 40.3 MiB across 649 objects, with at most two object reads per lookup after manifest acquisition. This is not deployed-provider performance evidence or a frozen wire format. + +One production decision is now concrete: the CSV contains `XK`, which core rejects. The format experiment preserves it in a source DTO and reports the mismatch without changing core policy. Resolve how a complete dataset containing unsupported source codes maps into Geo outcomes before implementing the import validation policy in section 3. It must not silently become an address gap or an accepted core country. + +## 12. Scope boundaries and references + +Included: City/Country CSV import, versioned KV index, portable async lookup, optional object caching, explicit selection, checked reader inputs, adapter integration, publication/retirement contract and focused proofs. + +Deferred: remote geolocation APIs, automatic source fallback, automatic download/update scheduling, live version switching, richer city/coordinate output, jurisdiction catalogues, permission policy and mandatory MMDB support. Application response-cache variation remains an application responsibility. + +- [GeoLite formats and account requirements](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data/). +- [CSV fields and join semantics](https://dev.maxmind.com/geoip/docs/databases/city-and-country/). +- [Account downloads](https://www.maxmind.com/en/accounts/current/geoip/downloads), choose City CSV for subdivisions. +- [Dummy City CSV sample](https://dev.maxmind.com/examples/GeoIP2-City-CSV_Example.zip). +- [KV interface and provider consistency](../../guide/kv.md). +- [Native DynamoDB limits and semantics](../../guide/dynamodb-kv.md). +- [Native startup lifecycle](2026-10-05-axum-production-startup-lifecycle-design.md). diff --git a/examples/geo-kv-prototype/Cargo.lock b/examples/geo-kv-prototype/Cargo.lock new file mode 100644 index 000000000..ce02cb521 --- /dev/null +++ b/examples/geo-kv-prototype/Cargo.lock @@ -0,0 +1,1246 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +dependencies = [ + "adler2", + "alloc-no-stdlib", + "alloc-stdlib", + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "brotli", + "brotli-decompressor", + "bytes", + "compression-codecs", + "compression-core", + "crc32fast", + "edgezero-macros", + "flate2", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit", + "miniz_oxide", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2", + "simd-adler32", + "thiserror", + "toml", + "tower-service", + "tracing", + "url", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.3", + "toml", + "validator", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "geo-kv-prototype" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "csv", + "edgezero-core", + "futures", + "ipnet", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.3", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/geo-kv-prototype/Cargo.toml b/examples/geo-kv-prototype/Cargo.toml new file mode 100644 index 000000000..13ee9b21d --- /dev/null +++ b/examples/geo-kv-prototype/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "geo-kv-prototype" +version = "0.0.0" +edition = "2024" +publish = false + +# A format experiment, not a new production workspace member. +[workspace] + +[dependencies] +anyhow = "1" +async-trait = "0.1" +bytes = "=1.12.1" +csv = "=1.4.0" +edgezero-core = { path = "../../crates/edgezero-core", default-features = false } +futures = "0.3" +ipnet = { version = "=2.12.0", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "=1.0.150" +sha2 = "0.10" diff --git a/examples/geo-kv-prototype/README.md b/examples/geo-kv-prototype/README.md new file mode 100644 index 000000000..776a3c5d6 --- /dev/null +++ b/examples/geo-kv-prototype/README.md @@ -0,0 +1,79 @@ +# KV Geo format prototype + +A standalone Rust experiment for [portable KV Geo](../../docs/superpowers/specs/2026-11-05-kv-geo-design.md). It imports GeoLite City/Country CSV, exports a compact IP index, and checks asynchronous point lookups through the real EdgeZero `KvHandle` against the original unmerged CIDRs. + +This is the format/lookup prototype, not production Geo integration. It does not change `GeoProvider`, request dispatch, adapters, store bindings or CLI commands. The crate has its own workspace and lockfile so CSV/import dependencies do not enter production builds. + +## Run + +From the repository root: + +```sh +cargo test --locked --manifest-path examples/geo-kv-prototype/Cargo.toml +cargo build --locked --release --manifest-path examples/geo-kv-prototype/Cargo.toml +``` + +The binary is `examples/geo-kv-prototype/target/release/geo-kv-prototype` unless `CARGO_TARGET_DIR` selects another directory. The commands below assume `BIN` points to that binary. + +Extract the City CSV archive outside the repository. The source directory must contain the English locations file and both Blocks files. For Country CSV, use the corresponding Country filenames. + +```sh +SOURCE="$HOME/.cache/edgezero/geo-kv-prototype/source/GeoLite2-City-CSV_20261103" +OUT="$HOME/.cache/edgezero/geo-kv-prototype/artifacts-64k" + +"$BIN" build "$SOURCE" "$OUT" 65536 +"$BIN" verify "$SOURCE" "$OUT" 2048 4194304 +"$BIN" lookup "$OUT" 1.0.1.1 ::1 +``` + +- `build` imports, merges adjacent equal locations, builds byte-bounded objects, exports them through `KvHandle`, reads every object back, and emits a pinned receipt, inventory and build report. Use a separate output directory for each object-size experiment. An identical rerun must reproduce the same receipt and object bytes. +- `verify` compares 2,048 points per family against an independent `ipnet::contains` lookup over source CIDRs. Points include family endpoints, stratified CIDR endpoints and neighbors, and deterministic random addresses. It measures cache-disabled, cache-enabled first-pass and cache-enabled repeated-pass lookups. The last two share a bounded cache, not a full dataset preload. The manifest read is measured separately from per-query object reads. +- `lookup` runs in a fresh process without reading CSV or an inventory. Results follow input order, without echoing addresses. A JSON `null` means no geographic coverage; a missing required object is an error. + +Generated CSV, index files and machine-readable reports stay outside Git. Publication artifacts contain derived MaxMind data and retain its licensing, attribution, update and deletion obligations. + +## Data and policy are separate + +The downloaded release includes `XK`. EdgeZero's existing `CountryCode` rejects that code. The prototype's six-byte location is a source-format DTO, not a `GeoLocation` or production `GeoResolution`. + +The prototype validates country syntax, preserves source codes, reports unsupported-code network counts and exposes `core_country_supported` in explicit lookup output. It does not add `XK` to EdgeZero's country allowlist, silently discard those intervals, or map them to another country. Production policy needs a decision before integrating this format with Geo outcomes. + +## Experimental encoding + +```mermaid +flowchart TD + Receipt[Pinned manifest key and SHA-256] --> Manifest[Family roots and object byte budget] + Manifest --> Directory[Sorted child envelopes and hashes] + Directory --> Leaf[Sorted address intervals and source locations] + Leaf --> Location[Country and optional subdivision] +``` + +Each binary object has a 12-byte header: + +| Bytes | Meaning | +| ----- | ----------------------------------- | +| 0..4 | Magic `EZP1` | +| 4 | Address family, 4 or 6 | +| 5 | Kind, 0 for leaf or 1 for directory | +| 6..8 | Big-endian level, 0 for leaf | +| 8..12 | Big-endian record count | + +Leaf records contain inclusive start and end addresses, then a six-byte location. IPv4 addresses occupy four bytes each; IPv6 addresses occupy sixteen. Location bytes are uppercase country code, subdivision length, and three zero-padded subdivision bytes. Leaf records are therefore 14 or 38 bytes. + +Directory records contain inclusive child-envelope bounds and a 32-byte child SHA-256. They occupy 40 or 64 bytes. Child level decreases by one. Child keys derive from generation, family and digest; no arbitrary child key is decoded from dataset bytes. Directories are grouped until each family has one root. No key listing or CIDR-prefix probing occurs. + +The JSON manifest binds source-file hashes, edition, object budget and both roots. `receipt.json` pins its exact key and digest. The experimental generation hashes schema number, object budget and source metadata. Changes to this encoding/build algorithm require a new format version; p1 is not frozen for consumers. + +Objects are validated before use or caching. The reader checks content digest, family, level, lengths, ordering, envelopes and source-code syntax. The cache retains validated object bytes and searches records without materializing all intervals. Its configured budget counts retained payload bytes, not allocator/hash-map overhead. A fetched object and validation scratch memory exist in addition to retained cache payload. + +## Measurement boundaries + +`store.rs` is a local file-backed artifact transport implementing `KvStore`. Its synchronous filesystem operations are intentionally a prototype harness, not a production async backend. The lookup algorithm itself only uses `KvHandle` and requires no filesystem API. Replacing this harness with actual remote KV and integrating cancellation belongs to the next stage. + +Local timings include file reads, SHA-256, validation and search. OS page cache is uncontrolled. Cache-disabled means the resolver object cache is disabled, not that disk or OS caches are cold. No simulated network delay is added. The sample mix is a correctness workload, not representative user traffic or a KV cost forecast. + +Linux `VmHWM` supplies whole-process peak RSS. Import/verify processes retain source CIDRs for the oracle, so their RSS is not serving memory. Use a fresh `lookup` process to measure a small serving workload without that retained dataset. + +The artifact writer demonstrates deterministic export/read-back, not crash-safe publication, distributed propagation, concurrent writer exclusion or a backend's upload API. Production retry, publication and retirement behavior remains specified separately. + +See [measured results](../../docs/superpowers/plans/2026-11-05-kv-geo-prototype-results.md). diff --git a/examples/geo-kv-prototype/src/import.rs b/examples/geo-kv-prototype/src/import.rs new file mode 100644 index 000000000..156b93b46 --- /dev/null +++ b/examples/geo-kv-prototype/src/import.rs @@ -0,0 +1,337 @@ +//! CSV import and independent, unmerged CIDR oracle for the format experiment. +use std::{collections::HashMap, fs::File, io::Read, net::IpAddr, path::Path}; + +use anyhow::{Context, Result, ensure}; +use ipnet::IpNet; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::index::{Location, Range, Source, address, hex}; + +#[derive(Debug, Deserialize)] +struct LocationRow { + geoname_id: u32, + country_iso_code: String, + #[serde(default)] + subdivision_1_iso_code: String, +} + +#[derive(Debug, Deserialize)] +struct BlockRow { + network: IpNet, + geoname_id: Option, +} + +#[derive(Clone, Debug)] +pub struct SourceRange { + pub network: IpNet, + pub location: Option, +} + +impl SourceRange { + pub fn start(&self) -> u128 { + address(self.network.network()).1 + } + pub fn end(&self) -> u128 { + address(self.network.broadcast()).1 + } +} + +#[derive(Debug, Serialize)] +pub struct FamilyStats { + pub source_networks: usize, + pub without_country: usize, + pub country_only: usize, + pub merged_intervals: usize, + pub unsupported_core_country_networks: std::collections::BTreeMap, +} + +pub struct Imported { + pub source: Source, + pub locations: usize, + pub raw4: Vec, + pub raw6: Vec, +} + +fn locations(reader: impl Read) -> Result>> { + let mut out = HashMap::new(); + for row in csv::Reader::from_reader(reader).deserialize::() { + let row = row?; + let location = if row.country_iso_code.is_empty() { + ensure!( + row.subdivision_1_iso_code.is_empty(), + "subdivision without country for geoname {}", + row.geoname_id + ); + None + } else { + Some( + Location::new(&row.country_iso_code, &row.subdivision_1_iso_code).with_context( + || format!("invalid location codes for geoname {}", row.geoname_id), + )?, + ) + }; + ensure!( + out.insert(row.geoname_id, location).is_none(), + "duplicate geoname identity {}", + row.geoname_id + ); + } + Ok(out) +} + +fn blocks( + reader: impl Read, + locations: &HashMap>, + family: u8, +) -> Result> { + let mut out = Vec::new(); + for row in csv::Reader::from_reader(reader).deserialize::() { + let row = row?; + ensure!( + address(row.network.addr()).0 == family, + "wrong CSV address family" + ); + ensure!( + row.network.addr() == row.network.network(), + "CIDR has host bits" + ); + let location = row + .geoname_id + .map(|id| { + locations + .get(&id) + .copied() + .with_context(|| format!("missing referenced geoname {id}")) + }) + .transpose()? + .flatten(); + out.push(SourceRange { + network: row.network, + location, + }); + } + out.sort_unstable_by_key(SourceRange::start); + for pair in out.windows(2) { + ensure!( + pair[0].end() < pair[1].start(), + "overlapping source networks" + ); + } + Ok(out) +} + +struct HashedReader { + file: File, + hash: Sha256, +} + +impl HashedReader { + fn open(path: &Path) -> Result { + Ok(Self { + file: File::open(path)?, + hash: Sha256::new(), + }) + } + fn finish(self) -> String { + hex(&self.hash.finalize()) + } +} + +impl Read for HashedReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let n = self.file.read(buffer)?; + self.hash.update(&buffer[..n]); + Ok(n) + } +} + +pub fn load(directory: &Path) -> Result { + let edition = if directory.join("GeoLite2-City-Locations-en.csv").is_file() { + "GeoLite2-City" + } else { + "GeoLite2-Country" + }; + let files: Vec = ["Locations-en.csv", "Blocks-IPv4.csv", "Blocks-IPv6.csv"] + .iter() + .map(|suffix| format!("{edition}-{suffix}")) + .collect(); + // Hash the same byte stream CSV consumes, rather than re-reading mutable files. + let mut reader = HashedReader::open(&directory.join(&files[0]))?; + let locations = locations(&mut reader)?; + let mut hashes = vec![reader.finish()]; + let mut reader = HashedReader::open(&directory.join(&files[1]))?; + let raw4 = blocks(&mut reader, &locations, 4)?; + hashes.push(reader.finish()); + let mut reader = HashedReader::open(&directory.join(&files[2]))?; + let raw6 = blocks(&mut reader, &locations, 6)?; + hashes.push(reader.finish()); + let files = files.into_iter().zip(hashes).collect(); + Ok(Imported { + source: Source { + edition: edition.into(), + files, + }, + locations: locations.len(), + raw4, + raw6, + }) +} + +pub fn project(raw: &[SourceRange]) -> (Vec, FamilyStats) { + let mut out: Vec = Vec::new(); + let mut stats = FamilyStats { + source_networks: raw.len(), + without_country: 0, + country_only: 0, + merged_intervals: 0, + unsupported_core_country_networks: std::collections::BTreeMap::new(), + }; + for row in raw { + let Some(location) = row.location else { + stats.without_country += 1; + continue; + }; + if !location.core_country_supported() { + let code = std::str::from_utf8(&location.0[..2]).expect("validated source country"); + *stats + .unsupported_core_country_networks + .entry(code.into()) + .or_default() += 1; + } + if location.0[2] == 0 { + stats.country_only += 1; + } + let start = row.start(); + let end = row.end(); + if let Some(previous) = out.last_mut() + && previous.location == location + && previous.end.checked_add(1) == Some(start) + { + previous.end = end; + continue; + } + out.push(Range { + start, + end, + location, + }); + } + stats.merged_intervals = out.len(); + (out, stats) +} + +// Uses the original CIDRs and ipnet.contains, not the encoded/merged interval search. +pub fn oracle(raw: &[SourceRange], ip: IpAddr) -> Option { + let value = address(ip).1; + let i = raw.partition_point(|r| r.start() <= value); + if i == 0 { + return None; + } + raw[i - 1] + .network + .contains(&ip) + .then_some(raw[i - 1].location) + .flatten() +} + +pub fn samples(raw: &[SourceRange], family: u8, count: usize) -> Vec { + let max = if family == 4 { + u32::MAX.into() + } else { + u128::MAX + }; + let mut values = vec![0, max]; + let boundary_rows = (count / 8).min(raw.len()); + for i in 0..boundary_rows { + let row = &raw[i * raw.len() / boundary_rows]; + values.extend([row.start(), row.end()]); + if let Some(before) = row.start().checked_sub(1) { + values.push(before); + } + if let Some(after) = row.end().checked_add(1).filter(|n| *n <= max) { + values.push(after); + } + } + let mut state = 0x6a09_e667_f3bc_c909_u64; + let mut random = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + while values.len() < count { + let n = (u128::from(random()) << 64) | u128::from(random()); + values.push(n & max); + } + values + .into_iter() + .map(|n| { + if family == 4 { + IpAddr::from((n as u32).to_be_bytes()) + } else { + IpAddr::from(n.to_be_bytes()) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + const LOCATIONS: &str = "geoname_id,country_iso_code,subdivision_1_iso_code,country_name\n1,US,CA,\"United, States\"\n2,GB,,United Kingdom\n3,,,\n"; + + #[test] + fn csv_join_country_only_and_no_registered_fallback() { + let loc = locations(LOCATIONS.as_bytes()).unwrap(); + let raw = blocks("network,geoname_id,registered_country_geoname_id\n1.0.0.0/24,,1\n1.0.1.0/24,1,2\n1.0.2.0/24,2,1\n1.0.3.0/24,3,1\n".as_bytes(), &loc, 4).unwrap(); + assert_eq!(raw[0].location, None); + assert_eq!(raw[1].location, Some(Location::new("US", "CA").unwrap())); + assert_eq!(raw[2].location, Some(Location::new("GB", "").unwrap())); + assert_eq!(raw[3].location, None); + assert_eq!(oracle(&raw, "1.0.1.255".parse().unwrap()), raw[1].location); + } + + #[test] + fn source_codes_are_preserved_without_changing_core_policy() { + let loc = locations("geoname_id,country_iso_code\n1,XK\n".as_bytes()).unwrap(); + let raw = blocks("network,geoname_id\n0.0.0.0/0,1\n".as_bytes(), &loc, 4).unwrap(); + let (ranges, stats) = project(&raw); + assert!(!ranges[0].location.core_country_supported()); + assert_eq!(stats.unsupported_core_country_networks.get("XK"), Some(&1)); + } + + #[test] + fn invalid_csv_data_is_rejected() { + assert!( + locations("geoname_id,country_iso_code,subdivision_1_iso_code\n1,,CA\n".as_bytes()) + .is_err() + ); + assert!(locations("geoname_id,country_iso_code\n1,US\n1,GB\n".as_bytes()).is_err()); + let loc = locations(LOCATIONS.as_bytes()).unwrap(); + for (csv, family) in [ + ("network,geoname_id\n1.0.0.0/24,999\n", 4), + ("network,geoname_id\n::/0,1\n", 4), + ("network,geoname_id\n1.0.0.1/24,1\n", 4), + ("network,geoname_id\n1.0.0.0/24,1\n1.0.0.0/25,2\n", 4), + ] { + assert!(blocks(csv.as_bytes(), &loc, family).is_err()); + } + } + + #[test] + fn projection_merges_only_adjacent_equal_locations_and_preserves_gaps() { + let loc = locations(LOCATIONS.as_bytes()).unwrap(); + let raw = blocks("network,geoname_id\n0.0.0.0/32,1\n0.0.0.1/32,1\n0.0.0.2/32,\n0.0.0.3/32,1\n255.255.255.255/32,2\n".as_bytes(), &loc, 4).unwrap(); + let (ranges, stats) = project(&raw); + assert_eq!(stats.without_country, 1); + assert_eq!(ranges.len(), 3); + assert_eq!((ranges[0].start, ranges[0].end), (0, 1)); + assert_eq!( + (ranges[2].start, ranges[2].end), + (u32::MAX.into(), u32::MAX.into()) + ); + let raw = blocks("network,geoname_id\n::/0,2\n".as_bytes(), &loc, 6).unwrap(); + assert_eq!(project(&raw).0[0].end, u128::MAX); + } +} diff --git a/examples/geo-kv-prototype/src/index.rs b/examples/geo-kv-prototype/src/index.rs new file mode 100644 index 000000000..169698f7c --- /dev/null +++ b/examples/geo-kv-prototype/src/index.rs @@ -0,0 +1,767 @@ +//! Experimental p1 encoding. No filesystem or runtime-specific API in lookup. +use std::{ + collections::{HashMap, HashSet, VecDeque}, + net::IpAddr, +}; + +use anyhow::{Context, Result, bail, ensure}; +use bytes::Bytes; +use edgezero_core::{ + geo::{CountryCode, SubdivisionCode}, + key_value_store::KvHandle, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const HEADER: usize = 12; +const MAGIC: &[u8; 4] = b"EZP1"; + +pub fn digest(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Location(pub [u8; 6]); + +impl Location { + pub fn new(country: &str, subdivision: &str) -> Result { + // Source-format DTO, not GeoLocation. Preserve XK and report the domain mismatch. + ensure!( + country.len() == 2 && country.bytes().all(|b| b.is_ascii_alphabetic()), + "invalid source country syntax" + ); + let country = country.to_ascii_uppercase(); + let subdivision = if subdivision.is_empty() { + None + } else { + Some(SubdivisionCode::new(subdivision)?) + }; + let mut bytes = [0; 6]; + bytes[..2].copy_from_slice(country.as_bytes()); + if let Some(subdivision) = subdivision { + let s = subdivision.as_str().as_bytes(); + bytes[2] = s.len() as u8; + bytes[3..3 + s.len()].copy_from_slice(s); + } + Ok(Self(bytes)) + } + + fn decode(bytes: &[u8]) -> Result { + let raw: [u8; 6] = bytes.try_into()?; + let len = raw[2] as usize; + ensure!( + len <= 3 && raw[3 + len..].iter().all(|b| *b == 0), + "invalid location encoding" + ); + let canonical = Self::new( + std::str::from_utf8(&raw[..2])?, + std::str::from_utf8(&raw[3..3 + len])?, + )?; + ensure!(canonical.0 == raw, "noncanonical location"); + Ok(canonical) + } + + pub fn core_country_supported(self) -> bool { + CountryCode::new(std::str::from_utf8(&self.0[..2]).expect("validated source code")).is_ok() + } + + pub fn json(self) -> serde_json::Value { + let country = std::str::from_utf8(&self.0[..2]).expect("validated country"); + let subdivision = if self.0[2] == 0 { + None + } else { + Some( + std::str::from_utf8(&self.0[3..3 + self.0[2] as usize]) + .expect("validated subdivision"), + ) + }; + serde_json::json!({"country": country, "subdivision": subdivision, "core_country_supported": self.core_country_supported()}) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Range { + pub start: u128, + pub end: u128, + pub location: Location, +} + +pub fn width(family: u8) -> Result { + match family { + 4 => Ok(4), + 6 => Ok(16), + _ => bail!("invalid address family"), + } +} + +pub fn address(ip: IpAddr) -> (u8, u128) { + match ip { + IpAddr::V4(ip) => (4, u32::from(ip).into()), + IpAddr::V6(ip) => (6, u128::from(ip)), + } +} + +fn max_address(family: u8) -> u128 { + if family == 4 { + u32::MAX.into() + } else { + u128::MAX + } +} +fn read_address(bytes: &[u8]) -> u128 { + let mut raw = [0; 16]; + raw[16 - bytes.len()..].copy_from_slice(bytes); + u128::from_be_bytes(raw) +} +fn write_address(out: &mut Vec, value: u128, width: usize) { + out.extend_from_slice(&value.to_be_bytes()[16 - width..]); +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct NodeRef { + pub start: u128, + pub end: u128, + pub hash: [u8; 32], + pub level: u16, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Source { + pub edition: String, + pub files: Vec<(String, String)>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Manifest { + pub schema: u32, + pub generation: String, + pub object_bytes: usize, + pub source: Source, + pub ipv4: Option, + pub ipv6: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Receipt { + pub manifest_key: String, + pub manifest_hash: [u8; 32], +} + +#[derive(Debug, Serialize)] +pub struct InventoryEntry { + pub key: String, + pub sha256: String, + pub bytes: usize, +} + +pub fn node_key(generation: &str, family: u8, hash: &[u8; 32]) -> String { + format!("geo/p1/{generation}/v{family}/{}", hex(hash)) +} + +fn header(family: u8, level: u16, count: usize) -> Result> { + let mut bytes = MAGIC.to_vec(); + bytes.extend_from_slice(&[family, u8::from(level > 0)]); + bytes.extend_from_slice(&level.to_be_bytes()); + bytes.extend_from_slice(&u32::try_from(count)?.to_be_bytes()); + Ok(bytes) +} + +async fn publish_node( + kv: &KvHandle, + generation: &str, + family: u8, + reference: &NodeRef, + bytes: Vec, + inventory: &mut Vec, +) -> Result<()> { + let key = node_key(generation, family, &reference.hash); + inventory.push(InventoryEntry { + key: key.clone(), + sha256: hex(&reference.hash), + bytes: bytes.len(), + }); + kv.put_bytes(&key, bytes.into()).await?; + Ok(()) +} + +async fn build_family( + kv: &KvHandle, + generation: &str, + family: u8, + ranges: &[Range], + object_bytes: usize, + inventory: &mut Vec, +) -> Result> { + let w = width(family)?; + let leaf_capacity = (object_bytes - HEADER) / (2 * w + 6); + let directory_capacity = (object_bytes - HEADER) / (2 * w + 32); + ensure!( + leaf_capacity > 0 && directory_capacity >= 2, + "object budget cannot fit a branching directory" + ); + let mut previous_end = None; + for range in ranges { + ensure!( + range.start <= range.end && range.end <= max_address(family), + "invalid range" + ); + ensure!( + previous_end.is_none_or(|end| range.start > end), + "overlapping or unordered ranges" + ); + Location::decode(&range.location.0)?; + previous_end = Some(range.end); + } + let mut children = Vec::new(); + for chunk in ranges.chunks(leaf_capacity) { + let mut bytes = header(family, 0, chunk.len())?; + for range in chunk { + write_address(&mut bytes, range.start, w); + write_address(&mut bytes, range.end, w); + bytes.extend_from_slice(&range.location.0); + } + let reference = NodeRef { + start: chunk[0].start, + end: chunk[chunk.len() - 1].end, + hash: digest(&bytes), + level: 0, + }; + publish_node(kv, generation, family, &reference, bytes, inventory).await?; + children.push(reference); + } + while children.len() > 1 { + let mut parents = Vec::new(); + for chunk in children.chunks(directory_capacity) { + let level = chunk[0] + .level + .checked_add(1) + .context("tree depth overflow")?; + let mut bytes = header(family, level, chunk.len())?; + for child in chunk { + write_address(&mut bytes, child.start, w); + write_address(&mut bytes, child.end, w); + bytes.extend_from_slice(&child.hash); + } + let reference = NodeRef { + start: chunk[0].start, + end: chunk[chunk.len() - 1].end, + hash: digest(&bytes), + level, + }; + publish_node(kv, generation, family, &reference, bytes, inventory).await?; + parents.push(reference); + } + children = parents; + } + Ok(children.pop()) +} + +pub async fn build( + kv: &KvHandle, + source: Source, + v4: &[Range], + v6: &[Range], + object_bytes: usize, +) -> Result<(Receipt, Manifest, Vec)> { + // The minimum follows the encoding: header plus two IPv6 directory records. + ensure!( + (HEADER + 2 * (32 + 32)..=KvHandle::MAX_VALUE_SIZE).contains(&object_bytes), + "invalid object byte budget" + ); + let generation = hex(&digest(&serde_json::to_vec(&( + 1_u32, + object_bytes, + &source, + ))?)); + let mut inventory = Vec::new(); + let ipv4 = build_family(kv, &generation, 4, v4, object_bytes, &mut inventory).await?; + let ipv6 = build_family(kv, &generation, 6, v6, object_bytes, &mut inventory).await?; + let manifest = Manifest { + schema: 1, + generation: generation.clone(), + object_bytes, + source, + ipv4, + ipv6, + }; + let bytes = serde_json::to_vec(&manifest)?; + ensure!( + bytes.len() <= object_bytes, + "manifest exceeds target value budget" + ); + let receipt = Receipt { + manifest_key: format!("geo/p1/{generation}/manifest"), + manifest_hash: digest(&bytes), + }; + inventory.push(InventoryEntry { + key: receipt.manifest_key.clone(), + sha256: hex(&receipt.manifest_hash), + bytes: bytes.len(), + }); + kv.put_bytes(&receipt.manifest_key, bytes.into()).await?; + Ok((receipt, manifest, inventory)) +} + +#[derive(Clone)] +struct Object { + bytes: Bytes, + width: usize, + record_bytes: usize, + count: usize, +} + +impl Object { + fn record(&self, i: usize) -> &[u8] { + &self.bytes[HEADER + i * self.record_bytes..HEADER + (i + 1) * self.record_bytes] + } + + fn bounds(&self, i: usize) -> (u128, u128) { + let r = self.record(i); + ( + read_address(&r[..self.width]), + read_address(&r[self.width..2 * self.width]), + ) + } + + fn decode(bytes: Bytes, family: u8, reference: &NodeRef, max_bytes: usize) -> Result { + ensure!( + bytes.len() <= max_bytes && bytes.len() >= HEADER, + "invalid object length" + ); + ensure!(digest(&bytes) == reference.hash, "object digest mismatch"); + ensure!( + &bytes[..4] == MAGIC && bytes[4] == family, + "invalid object header" + ); + let level = u16::from_be_bytes(bytes[6..8].try_into()?); + ensure!( + level == reference.level && bytes[5] == u8::from(level > 0), + "invalid object level" + ); + let w = width(family)?; + let count = u32::from_be_bytes(bytes[8..12].try_into()?) as usize; + let record_bytes = 2 * w + if level == 0 { 6 } else { 32 }; + ensure!( + count > 0 + && count + .checked_mul(record_bytes) + .and_then(|n| n.checked_add(HEADER)) + == Some(bytes.len()), + "invalid object records" + ); + let object = Self { + bytes, + width: w, + record_bytes, + count, + }; + let mut previous_end = None; + let mut locations = HashSet::new(); + for i in 0..count { + let (start, end) = object.bounds(i); + ensure!( + start <= end && previous_end.is_none_or(|e| start > e), + "invalid record ordering" + ); + if level == 0 { + let raw: [u8; 6] = object.record(i)[2 * w..].try_into()?; + if locations.insert(raw) { + Location::decode(&raw)?; + } + } + previous_end = Some(end); + } + ensure!( + object.bounds(0).0 == reference.start && object.bounds(count - 1).1 == reference.end, + "child envelope mismatch" + ); + Ok(object) + } + + fn find(&self, ip: u128) -> Option { + let mut lo = 0; + let mut hi = self.count; + while lo < hi { + let mid = lo + (hi - lo) / 2; + if self.bounds(mid).0 <= ip { + lo = mid + 1; + } else { + hi = mid; + } + } + (lo > 0 && ip <= self.bounds(lo - 1).1).then(|| lo - 1) + } +} + +#[derive(Default, Debug, Serialize)] +pub struct CacheStats { + pub hits: u64, + pub peak_payload_bytes: usize, + pub max_object_bytes: usize, +} + +struct Cache { + budget: usize, + bytes: usize, + values: HashMap<[u8; 32], Object>, + order: VecDeque<[u8; 32]>, + stats: CacheStats, +} + +impl Cache { + fn new(budget: usize) -> Self { + Self { + budget, + bytes: 0, + values: HashMap::new(), + order: VecDeque::new(), + stats: CacheStats::default(), + } + } + + fn get(&mut self, hash: &[u8; 32]) -> Option { + let object = self.values.get(hash)?.clone(); + self.order.retain(|h| h != hash); + self.order.push_back(*hash); + self.stats.hits += 1; + Some(object) + } + + fn insert(&mut self, hash: [u8; 32], object: Object) { + let size = object.bytes.len(); + self.stats.max_object_bytes = self.stats.max_object_bytes.max(size); + if size > self.budget { + return; + } + while self.bytes + size > self.budget { + let hash = self.order.pop_front().expect("cache accounting"); + self.bytes -= self.values.remove(&hash).expect("cache entry").bytes.len(); + } + self.bytes += size; + self.values.insert(hash, object); + self.order.push_back(hash); + self.stats.peak_payload_bytes = self.stats.peak_payload_bytes.max(self.bytes); + } +} + +pub struct Resolver { + kv: KvHandle, + pub manifest: Manifest, + cache: Cache, +} + +impl Resolver { + pub async fn open(kv: KvHandle, receipt: &Receipt, cache_bytes: usize) -> Result { + let bytes = kv + .get_bytes(&receipt.manifest_key) + .await? + .context("missing manifest")?; + ensure!( + digest(&bytes) == receipt.manifest_hash, + "manifest digest mismatch" + ); + let manifest: Manifest = serde_json::from_slice(&bytes)?; + ensure!( + manifest.schema == 1 + && manifest.generation.len() == 64 + && manifest.generation.bytes().all(|b| b.is_ascii_hexdigit()), + "invalid manifest identity" + ); + ensure!( + receipt.manifest_key == format!("geo/p1/{}/manifest", manifest.generation), + "manifest key mismatch" + ); + ensure!( + (HEADER + 128..=KvHandle::MAX_VALUE_SIZE).contains(&manifest.object_bytes), + "invalid manifest object budget" + ); + for (family, root) in [(4, &manifest.ipv4), (6, &manifest.ipv6)] { + if let Some(root) = root { + ensure!( + root.start <= root.end && root.end <= max_address(family), + "invalid family root" + ); + } + } + Ok(Self { + kv, + manifest, + cache: Cache::new(cache_bytes), + }) + } + + pub fn cache_stats(&self) -> &CacheStats { + &self.cache.stats + } + + pub async fn lookup(&mut self, ip: IpAddr) -> Result> { + let (family, ip) = address(ip); + let Some(mut reference) = (if family == 4 { + &self.manifest.ipv4 + } else { + &self.manifest.ipv6 + }) + .clone() else { + return Ok(None); + }; + loop { + if ip < reference.start || ip > reference.end { + return Ok(None); + } + let object = if let Some(object) = self.cache.get(&reference.hash) { + object + } else { + let key = node_key(&self.manifest.generation, family, &reference.hash); + let bytes = self + .kv + .get_bytes(&key) + .await? + .context("missing required index object")?; + let object = Object::decode(bytes, family, &reference, self.manifest.object_bytes)?; + self.cache.insert(reference.hash, object.clone()); + object + }; + // Cached content still has to agree with this parent's envelope and level. + ensure!( + object.bounds(0).0 == reference.start + && object.bounds(object.count - 1).1 == reference.end + && u16::from_be_bytes(object.bytes[6..8].try_into()?) == reference.level, + "cached child descriptor mismatch" + ); + let Some(i) = object.find(ip) else { + return Ok(None); + }; + if reference.level == 0 { + return Ok(Some(Location( + object.record(i)[2 * object.width..].try_into()?, + ))); + } + let (start, end) = object.bounds(i); + reference = NodeRef { + start, + end, + hash: object.record(i)[2 * object.width..].try_into()?, + level: reference.level - 1, + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; + use futures::executor::block_on; + use std::{ + sync::{Arc, Mutex}, + time::Duration, + }; + + #[derive(Default)] + struct MemoryStore(Mutex>); + #[async_trait(?Send)] + impl KvStore for MemoryStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self.0.lock().unwrap().get(key).cloned()) + } + async fn put_bytes(&self, key: &str, bytes: Bytes) -> Result<(), KvError> { + self.0.lock().unwrap().insert(key.into(), bytes); + Ok(()) + } + async fn delete(&self, _: &str) -> Result<(), KvError> { + panic!("lookup must not delete") + } + async fn list_keys_page( + &self, + _: &str, + _: Option<&str>, + _: usize, + ) -> Result { + panic!("lookup must not list") + } + async fn put_bytes_with_ttl(&self, _: &str, _: Bytes, _: Duration) -> Result<(), KvError> { + panic!("no TTL") + } + } + fn source() -> Source { + Source { + edition: "synthetic".into(), + files: vec![], + } + } + fn ranges() -> Vec { + (0..10_000) + .map(|i| Range { + start: i * 4, + end: i * 4 + 1, + location: Location::new("US", if i % 2 == 0 { "CA" } else { "" }).unwrap(), + }) + .collect() + } + + #[test] + fn round_trip_multilevel_cache_and_determinism() { + block_on(async { + let raw = Arc::new(MemoryStore::default()); + let kv = KvHandle::new(raw.clone()); + let ranges = ranges(); + let (receipt, manifest, _) = + build(&kv, source(), &ranges, &ranges, 1024).await.unwrap(); + assert!(manifest.ipv6.as_ref().unwrap().level >= 3); + let before = raw.0.lock().unwrap().clone(); + let (again, _, _) = build(&kv, source(), &ranges, &ranges, 1024).await.unwrap(); + assert_eq!(receipt, again); + assert_eq!(before, *raw.0.lock().unwrap()); + for budget in [0, 1024, 1024 * 1024] { + let mut resolver = Resolver::open(kv.clone(), &receipt, budget).await.unwrap(); + for family in [4, 6] { + for n in 0..410_u128 { + let ip = if family == 4 { + IpAddr::from((n as u32).to_be_bytes()) + } else { + IpAddr::from(n.to_be_bytes()) + }; + let expected = ranges + .iter() + .find(|r| r.start <= n && n <= r.end) + .map(|r| r.location); + assert_eq!(resolver.lookup(ip).await.unwrap(), expected); + } + } + assert!(resolver.cache_stats().peak_payload_bytes <= budget); + } + }); + } + + #[test] + fn full_family_and_empty_family() { + block_on(async { + for family in [4, 6] { + let kv = KvHandle::new(Arc::new(MemoryStore::default())); + let ranges = [Range { + start: 0, + end: max_address(family), + location: Location::new("GB", "ENG").unwrap(), + }]; + let (receipt, _, _) = build( + &kv, + source(), + if family == 4 { &ranges } else { &[] }, + if family == 6 { &ranges } else { &[] }, + 1024, + ) + .await + .unwrap(); + let mut resolver = Resolver::open(kv, &receipt, 0).await.unwrap(); + for n in [0, max_address(family)] { + let ip = if family == 4 { + IpAddr::from((n as u32).to_be_bytes()) + } else { + IpAddr::from(n.to_be_bytes()) + }; + assert_eq!(resolver.lookup(ip).await.unwrap(), Some(ranges[0].location)); + } + let empty = if family == 4 { "::1" } else { "1.0.0.1" }; + assert_eq!(resolver.lookup(empty.parse().unwrap()).await.unwrap(), None); + } + }); + } + + #[test] + fn source_country_roundtrip_does_not_claim_domain_acceptance() { + block_on(async { + let kv = KvHandle::new(Arc::new(MemoryStore::default())); + let ranges = [Range { + start: 0, + end: 1, + location: Location::new("XK", "").unwrap(), + }]; + let (receipt, _, _) = build(&kv, source(), &ranges, &[], 1024).await.unwrap(); + let mut resolver = Resolver::open(kv, &receipt, 0).await.unwrap(); + let location = resolver + .lookup("0.0.0.1".parse().unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!(location, ranges[0].location); + assert!(!location.core_country_supported()); + }); + } + + #[test] + fn missing_and_corrupt_objects_are_errors_not_misses() { + block_on(async { + let raw = Arc::new(MemoryStore::default()); + let kv = KvHandle::new(raw.clone()); + let (receipt, manifest, _) = build(&kv, source(), &ranges(), &[], 1024).await.unwrap(); + let key = node_key( + &manifest.generation, + 4, + &manifest.ipv4.as_ref().unwrap().hash, + ); + let old = raw.0.lock().unwrap().remove(&key).unwrap(); + let mut resolver = Resolver::open(kv.clone(), &receipt, 0).await.unwrap(); + assert!( + resolver + .lookup("0.0.0.0".parse().unwrap()) + .await + .unwrap_err() + .to_string() + .contains("missing required") + ); + let mut corrupt = old.to_vec(); + corrupt[0] ^= 1; + raw.0.lock().unwrap().insert(key, corrupt.into()); + assert!( + resolver + .lookup("0.0.0.0".parse().unwrap()) + .await + .unwrap_err() + .to_string() + .contains("digest mismatch") + ); + let mut bad_receipt = receipt.clone(); + bad_receipt.manifest_hash[0] ^= 1; + assert!(Resolver::open(kv, &bad_receipt, 0).await.is_err()); + }); + } + + #[test] + fn malformed_records_with_matching_digest_are_rejected() { + let reference = |b: &[u8]| NodeRef { + start: 0, + end: 4, + hash: digest(b), + level: 0, + }; + let mut bytes = header(4, 0, 2).unwrap(); + for (start, end) in [(0, 2), (2, 4)] { + write_address(&mut bytes, start, 4); + write_address(&mut bytes, end, 4); + bytes.extend_from_slice(&Location::new("US", "CA").unwrap().0); + } + assert!(Object::decode(bytes.clone().into(), 4, &reference(&bytes), 140).is_err()); + let mut bytes = header(4, 0, 1).unwrap(); + write_address(&mut bytes, 0, 4); + write_address(&mut bytes, 4, 4); + bytes.extend_from_slice(&[b'U', b'S', 4, b'C', b'A', 0]); + assert!(Object::decode(bytes.clone().into(), 4, &reference(&bytes), 140).is_err()); + bytes.pop(); + assert!(Object::decode(bytes.clone().into(), 4, &reference(&bytes), 140).is_err()); + } + + #[test] + fn rejects_overlaps_and_too_small_objects() { + block_on(async { + let kv = KvHandle::new(Arc::new(MemoryStore::default())); + let mut ranges = ranges(); + ranges[1].start = 1; + assert!(build(&kv, source(), &ranges, &[], 140).await.is_err()); + assert!(build(&kv, source(), &[], &[], 139).await.is_err()); + }); + } +} diff --git a/examples/geo-kv-prototype/src/main.rs b/examples/geo-kv-prototype/src/main.rs new file mode 100644 index 000000000..13688c3e8 --- /dev/null +++ b/examples/geo-kv-prototype/src/main.rs @@ -0,0 +1,237 @@ +mod import; +mod index; +mod store; + +use std::{fs, net::IpAddr, path::Path, sync::Arc, time::Instant}; + +use anyhow::{Context, Result, bail, ensure}; +use edgezero_core::key_value_store::KvHandle; +use futures::executor::block_on; +use serde_json::json; + +use index::{Receipt, Resolver, digest, hex}; +use store::ArtifactStore; + +fn peak_rss_bytes() -> Option { + // Linux process high-water mark, including importer/oracle/backend/cache. + fs::read_to_string("/proc/self/status") + .ok()? + .lines() + .find_map(|line| { + line.strip_prefix("VmHWM:")? + .split_whitespace() + .next()? + .parse::() + .ok() + .map(|n| n * 1024) + }) +} + +fn write_json(path: &Path, value: &impl serde::Serialize) -> Result<()> { + fs::write(path, serde_json::to_vec_pretty(value)?)?; + Ok(()) +} + +fn read_receipt(out: &Path) -> Result { + Ok(serde_json::from_slice(&fs::read( + out.join("receipt.json"), + )?)?) +} + +async fn build(source: &Path, out: &Path, object_bytes: usize) -> Result<()> { + let started = Instant::now(); + let imported = import::load(source)?; + let import_seconds = started.elapsed().as_secs_f64(); + let (v4, stats4) = import::project(&imported.raw4); + let (v6, stats6) = import::project(&imported.raw6); + let projection_seconds = started.elapsed().as_secs_f64() - import_seconds; + fs::create_dir_all(out)?; + let store = Arc::new(ArtifactStore::new(out)); + let kv = KvHandle::new(store.clone()); + let build_started = Instant::now(); + let (receipt, manifest, inventory) = + index::build(&kv, imported.source, &v4, &v6, object_bytes).await?; + let build_seconds = build_started.elapsed().as_secs_f64(); + if out.join("receipt.json").exists() { + ensure!( + read_receipt(out)? == receipt, + "output already selects another generation; use a different output directory" + ); + } + let verify_started = Instant::now(); + for item in &inventory { + let bytes = kv + .get_bytes(&item.key) + .await? + .context("published object missing")?; + ensure!( + bytes.len() == item.bytes && hex(&digest(&bytes)) == item.sha256, + "published object read-back mismatch" + ); + } + let report = json!({ + "prototype": "p1, not a production format", + "transport": "local artifact files through KvHandle; no remote KV measurements", + "source": manifest.source, + "manifest_key": receipt.manifest_key, + "manifest_sha256": hex(&receipt.manifest_hash), + "locations": imported.locations, + "ipv4": stats4, + "ipv6": stats6, + "object_budget_bytes": object_bytes, + "objects_including_manifest": inventory.len(), + "encoded_bytes_including_manifest": inventory.iter().map(|i| i.bytes).sum::(), + "largest_object_bytes": inventory.iter().map(|i| i.bytes).max(), + "ipv4_lookup_objects": manifest.ipv4.as_ref().map(|r| usize::from(r.level) + 1), + "ipv6_lookup_objects": manifest.ipv6.as_ref().map(|r| usize::from(r.level) + 1), + "import_seconds_including_source_hashing": import_seconds, + "projection_seconds": projection_seconds, + "build_and_write_seconds": build_seconds, + "readback_seconds": verify_started.elapsed().as_secs_f64(), + "total_seconds": started.elapsed().as_secs_f64(), + "process_peak_rss_bytes": peak_rss_bytes(), + "store": store.stats(), + }); + write_json(&out.join("receipt.json"), &receipt)?; + write_json(&out.join("inventory.json"), &inventory)?; + write_json(&out.join("build-report.json"), &report)?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +async fn measure( + name: &str, + resolver: &mut Resolver, + store: &ArtifactStore, + queries: &[(IpAddr, Option)], +) -> Result { + store.reset_stats(); + let mut times = Vec::with_capacity(queries.len()); + let mut read_histogram = std::collections::BTreeMap::new(); + let mut located = 0; + let hits_before = resolver.cache_stats().hits; + for (ip, expected) in queries { + let before = store.stats().reads; + let started = Instant::now(); + let actual = resolver.lookup(*ip).await?; + times.push(started.elapsed().as_secs_f64() * 1_000_000.0); + ensure!(actual == *expected, "CSV oracle mismatch"); + located += usize::from(actual.is_some()); + *read_histogram + .entry(store.stats().reads - before) + .or_insert(0_usize) += 1; + } + times.sort_by(f64::total_cmp); + let stats = store.stats(); + Ok(json!({ + "pass": name, "queries": queries.len(), "located": located, + "no_match": queries.len() - located, "oracle_mismatches": 0, + "mean_microseconds": times.iter().sum::() / times.len() as f64, + "p50_microseconds": times[times.len() / 2], + "p95_microseconds": times[(times.len() - 1) * 95 / 100], + "max_microseconds": times[times.len() - 1], + "reads_per_query_histogram": read_histogram, + "reads_per_query_mean": stats.reads as f64 / queries.len() as f64, + "bytes_per_query_mean": stats.read_bytes as f64 / queries.len() as f64, + "cache_hits": resolver.cache_stats().hits - hits_before, + "peak_cached_payload_bytes": resolver.cache_stats().peak_payload_bytes, + "max_decoded_object_bytes": resolver.cache_stats().max_object_bytes, + "store": stats, + })) +} + +async fn verify(source: &Path, out: &Path, count: usize, cache_bytes: usize) -> Result<()> { + ensure!( + count >= 2, + "at least two samples per family are needed for endpoints" + ); + let imported = import::load(source)?; + let mut queries = Vec::new(); + for (family, raw) in [(4, &imported.raw4), (6, &imported.raw6)] { + for ip in import::samples(raw, family, count) { + queries.push((ip, import::oracle(raw, ip))); + } + } + let receipt = read_receipt(out)?; + let store = Arc::new(ArtifactStore::new(out)); + let kv = KvHandle::new(store.clone()); + let mut disabled = Resolver::open(kv.clone(), &receipt, 0).await?; + ensure!( + disabled.manifest.source == imported.source, + "oracle source differs from indexed source" + ); + let mut reports = vec![measure("cache_disabled", &mut disabled, &store, &queries).await?]; + let mut enabled = Resolver::open(kv, &receipt, cache_bytes).await?; + reports.push(measure("cache_enabled_first_pass", &mut enabled, &store, &queries).await?); + reports.push(measure("cache_enabled_repeat", &mut enabled, &store, &queries).await?); + let report = json!({ + "transport": "local file artifact KV; OS page cache uncontrolled; not network latency", + "manifest_sha256": hex(&receipt.manifest_hash), + "sample_method": "per family: zero/max, stratified source CIDR endpoints and neighbors, deterministic uniform random remainder", + "samples_per_family": count, + "manifest_reads_per_resolver_initialization": 1, + "cache_payload_budget_bytes": cache_bytes, + "passes": reports, + "process_peak_rss_bytes_including_csv_oracle": peak_rss_bytes(), + }); + write_json(&out.join("verification-report.json"), &report)?; + println!("{}", serde_json::to_string_pretty(&report)?); + Ok(()) +} + +async fn lookup(out: &Path, ips: &[String]) -> Result<()> { + ensure!(!ips.is_empty(), "provide at least one IP address"); + let receipt = read_receipt(out)?; + let store = Arc::new(ArtifactStore::new(out)); + let mut resolver = + Resolver::open(KvHandle::new(store.clone()), &receipt, 4 * 1024 * 1024).await?; + let mut results = Vec::new(); + for text in ips { + let ip: IpAddr = text.parse()?; + results.push(resolver.lookup(ip).await?.map(index::Location::json)); + } + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "results_in_input_order": results, + "store": store.stats(), "cache": resolver.cache_stats(), + "process_peak_rss_bytes_without_csv_import": peak_rss_bytes(), + }))? + ); + Ok(()) +} + +fn main() -> Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + block_on(async { + match args.first().map(String::as_str) { + Some("build") if (3..=4).contains(&args.len()) => { + build( + Path::new(&args[1]), + Path::new(&args[2]), + args.get(3) + .map(|s| s.parse()) + .transpose()? + .unwrap_or(256 * 1024), + ) + .await + } + Some("verify") if (3..=5).contains(&args.len()) => { + verify( + Path::new(&args[1]), + Path::new(&args[2]), + args.get(3).map(|s| s.parse()).transpose()?.unwrap_or(2048), + args.get(4) + .map(|s| s.parse()) + .transpose()? + .unwrap_or(4 * 1024 * 1024), + ) + .await + } + Some("lookup") if args.len() >= 3 => lookup(Path::new(&args[1]), &args[2..]).await, + _ => bail!( + "usage: geo-kv-prototype build [object-bytes]\n geo-kv-prototype verify [samples-per-family] [cache-bytes]\n geo-kv-prototype lookup [ip ...]" + ), + } + }) +} diff --git a/examples/geo-kv-prototype/src/store.rs b/examples/geo-kv-prototype/src/store.rs new file mode 100644 index 000000000..67ef9d1e9 --- /dev/null +++ b/examples/geo-kv-prototype/src/store.rs @@ -0,0 +1,129 @@ +//! Local artifact transport only. The resolver itself knows only KvHandle. +//! Synchronous filesystem calls here are NOT a production async KV backend. +use std::{ + fs, + path::{Component, Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::Duration, +}; + +use async_trait::async_trait; +use bytes::Bytes; +use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; +use serde::Serialize; + +#[derive(Default)] +pub struct ArtifactStore { + root: PathBuf, + reads: AtomicU64, + read_bytes: AtomicU64, + writes: AtomicU64, + write_bytes: AtomicU64, +} + +#[derive(Debug, Default, Clone, Serialize)] +pub struct StoreStats { + pub reads: u64, + pub read_bytes: u64, + pub writes: u64, + pub write_bytes: u64, +} + +impl ArtifactStore { + pub fn new(root: impl Into) -> Self { + Self { + root: root.into(), + ..Self::default() + } + } + + fn path(&self, key: &str) -> Result { + if !key.starts_with("geo/p1/") + || Path::new(key) + .components() + .any(|p| !matches!(p, Component::Normal(_))) + { + return Err(KvError::Validation("invalid artifact key".into())); + } + Ok(self.root.join(key)) + } + + pub fn stats(&self) -> StoreStats { + StoreStats { + reads: self.reads.load(Ordering::Relaxed), + read_bytes: self.read_bytes.load(Ordering::Relaxed), + writes: self.writes.load(Ordering::Relaxed), + write_bytes: self.write_bytes.load(Ordering::Relaxed), + } + } + + pub fn reset_stats(&self) { + self.reads.store(0, Ordering::Relaxed); + self.read_bytes.store(0, Ordering::Relaxed); + self.writes.store(0, Ordering::Relaxed); + self.write_bytes.store(0, Ordering::Relaxed); + } +} + +fn unsupported(operation: &str) -> KvError { + KvError::Unsupported { + operation: operation.into(), + } +} + +#[async_trait(?Send)] +impl KvStore for ArtifactStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.reads.fetch_add(1, Ordering::Relaxed); + let value = match fs::read(self.path(key)?) { + Ok(value) => value, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(KvError::Internal(e.into())), + }; + self.read_bytes + .fetch_add(value.len() as u64, Ordering::Relaxed); + Ok(Some(value.into())) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + let path = self.path(key)?; + fs::create_dir_all( + path.parent() + .ok_or_else(|| KvError::Validation("missing parent".into()))?, + ) + .map_err(|e| KvError::Internal(e.into()))?; + // A rerun may repeat the same immutable publication, never replace different bytes. + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut file) => { + use std::io::Write; + file.write_all(&value) + .map_err(|e| KvError::Internal(e.into()))?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + let old = fs::read(path).map_err(|e| KvError::Internal(e.into()))?; + if old != value { + return Err(KvError::Validation("immutable artifact differs".into())); + } + } + Err(e) => return Err(KvError::Internal(e.into())), + } + self.writes.fetch_add(1, Ordering::Relaxed); + self.write_bytes + .fetch_add(value.len() as u64, Ordering::Relaxed); + Ok(()) + } + + async fn delete(&self, _: &str) -> Result<(), KvError> { + Err(unsupported("delete")) + } + async fn list_keys_page(&self, _: &str, _: Option<&str>, _: usize) -> Result { + Err(unsupported("list_keys_page")) + } + async fn put_bytes_with_ttl(&self, _: &str, _: Bytes, _: Duration) -> Result<(), KvError> { + Err(unsupported("put_bytes_with_ttl")) + } +}