Help us shape the future of Go! Take the 2026 Go Developer Survey

kurn

module
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: AGPL-3.0

README

kurn

CI

Create a search artifact, then serve it beside your application.

kurn turns an application-owned short-string feed—names, products, identifiers, domains, or denylists—into a reusable read-only search version. Carry that artifact with kurnd and each instance can answer fuzzy, exact, and filtered lookups without contacting the source or rebuilding the index.

The source database remains authoritative; an artifact is a deployable search version, not a backup. Each collection stays in one node's memory, so size it for your workload and use independent replicas for availability or throughput. kurn does not shard data or coordinate a search cluster.

Quickstart: build once, serve many

Turn the supplied short-string feed into a reusable, read-only search version (Go 1.26+), verify it, and start a daemon from that artifact:

mkdir -p ./out
go build -o ./out/kurn ./cmd/kurn
go build -o ./out/kurnd ./cmd/kurnd
./out/kurn build -mapping ./examples/prepared-artifact/ngram-mapping.json \
  -in ./examples/prepared-artifact/feed.ndjson \
  -artifact ./out/people.kurn -name people
./out/kurn verify -semantic ./out/people.kurn
./out/kurnd -artifact ./out/people.kurn -addr 127.0.0.1:8080

From another shell, a typo-tolerant query with an exact payload filter finds the supplied Ada Lovelace row (entry_id p1):

curl -s -X POST 127.0.0.1:8080/v1/query \
  -d '{"q":"lovelce ada","lists":["people"],"filter":{"country":"GB"}}'
# ... "entry_id":"p1" ... "key":"Ada Lovelace" ...

The database or source feed remains authoritative. An artifact is a deployable search version, not a database backup or snapshot policy. It travels with a target-compatible kurnd; serving needs no feed, mapping, database contact, or index rebuild. Full verification is the startup default. The optional trusted policy makes an explicit assertion about prior verification and trusted delivery/storage, with weaker content assurance.

This model fits application-owned short-string collections that fit in one node's memory: names, products, identifiers, domains, and denylists using fuzzy or exact matching and optional typed filters. Heap loading has no configured hard memory cap; size the process from your own artifacts and workload. Run independent replicas where you need availability or throughput. It is not a blanket replacement for a search cluster.

Continue with the prepared-artifact operating guide or its bounded lifecycle example.

Mutable-store alternative

Use this mode when the daemon itself must accept list creation and entry writes. These mutation endpoints do not apply to artifact mode, whose store is read-only.

Build and run the mutable daemon:

go build -o kurnd ./cmd/kurnd
./kurnd -data ./data     # creates the data dir if missing; listens on :8080

Create a fuzzy person-name list, add entries, then query with a typo'd, reordered name:

curl -s -X PUT localhost:8080/v1/lists/people \
  -d '{"analyzer":{"preset":"person-name"},"match":{"mode":"ngram"}}'
# {"list":"people"}

curl -s -X POST localhost:8080/v1/lists/people/entries \
  -d '[{"id":"p1","keys":["Elena Vasquez","Vasquez, Elena M."],"payload":{"listed":"2024-01-01"}},
       {"id":"p2","keys":["Marcus Chen"]}]'
# {"dropped_keys":0,"keyless_entries":0,"upserted":2}

curl -s -X POST localhost:8080/v1/query \
  -d '{"q":"vasquez elna","lists":["people"]}'
# {"candidates":[{"list":"people","entry_id":"p1","score":100,"key":"Elena Vasquez",
#   "payload":{"listed":"2024-01-01"}}],
#  "versions":{"people":"empty@0+j174.d2e39af703dbcc62beb868feb274b716b34db53d
#   e46eaf5474e4f619142499a1+c8062333a3e9ae6e80ff226e920b72403218d1e2e867bf30b
#   2e82f8c3d8bc4563"},"took_us":44}

The minimal {"mode":"ngram"} above uses the defaults only. The benchmarked configuration additionally set "strip_spaces":true—recommended for fuzzy name lists because it makes matching robust to token fusion and splits (see Configuration below).

Bulk-load via NDJSON (one entry per line) with ?replace=true, which swaps the whole list content atomically, then compact:

cat > people.ndjson <<'EOF'
{"id":"p1","keys":["Elena Vasquez","Vasquez, Elena M."],"payload":{"listed":"2024-01-01"}}
{"id":"p2","keys":["Marcus Chen"]}
{"id":"p3","keys":["Ingrid Åström"]}
EOF
curl -s -X POST 'localhost:8080/v1/lists/people/entries?replace=true' \
  -H 'Content-Type: application/x-ndjson' --data-binary @people.ndjson
# {"dropped_keys":0,"keyless_entries":0,"replaced":3}

curl -s -X POST localhost:8080/v1/lists/people/compact
# {"name":"people","entries":3,"overlay":0,"tombstones":0,
#  "version":"eeaf668a86283960cd42649855356395c1cc1808cbd12f7acd4c5c1845a2132c
#   @3+j0+c8062333a3e9ae6e80ff226e920b72403218d1e2e867bf30b2e82f8c3d8bc4563",
#  "mode":"ngram","dropped_keys":0,"keyless_entries":0}

curl -s -X POST localhost:8080/v1/query -d '{"q":"astrom ingrid","lists":["people"]}'
# {"candidates":[{"list":"people","entry_id":"p3","score":100,"key":"Ingrid Åström"}],
#  "versions":{"people":"eeaf668a8628…@3+j0+c8062333a3e9…"},"took_us":43}

Domain blocklist (exact mode, parent-suffix fallback): listing a domain covers all its subdomains — 100 = exact hit, 90 = parent match:

curl -s -X PUT localhost:8080/v1/lists/blocked-domains \
  -d '{"analyzer":{"preset":"domain"},"match":{"mode":"exact","fallback":"parent_domain"}}'
printf '{"id":"d1","keys":["tempmail.com"]}\n' | \
  curl -s -X POST 'localhost:8080/v1/lists/blocked-domains/entries?replace=true' \
    -H 'Content-Type: application/x-ndjson' --data-binary @-
curl -s -X POST localhost:8080/v1/query -d '{"q":"smtp.tempmail.com","lists":["blocked-domains"]}'
# {"candidates":[{"list":"blocked-domains","entry_id":"d1","score":90,"key":"tempmail.com"}],...}
# querying "tempmail.com" itself scores 100

The commands in this mutable section persist per-list base snapshots and an append-only journal in the data directory. Restarting the daemon restores the lists.

Technical model

kurn is an in-memory search engine for fast fuzzy and exact retrieval over application-owned collections. Product names, customers, domains, denylists, or any other short-string collection can share one engine, with declared analyzers and optional exact typed filters over payload fields. It runs as a Go library inside your service or as a sidecar (kurnd) beside it, so there is no search cluster to maintain. Every answer carries the content-addressed version of the data and resolved configuration it used — candidates and version come from one atomic snapshot — so the same query against the same version, on the same kurn version, returns the same candidates byte for byte on any machine when the version is based on identities Kurn recomputed. An explicit trusted-artifact start labels its version basis as declared instead; that stamp alone does not establish byte equality.

The operating model is deliberately compact: application-owned short-string collections stay resident in memory on one node. Run independent replicas for availability or throughput; kurn does not shard collections or coordinate a search cluster. N-gram results are ordered by IDF-weighted string similarity, while exact-mode results represent normalized whole-key or configured parent-domain matches.

A common shape: your database stays the system of record, a query exports the rows worth matching, kurn build turns them into a content-addressed bundle, and every instance answers lookups from its own copy. The database then sees one query per refresh instead of one per lookup — independent of how much traffic you serve or how many instances you run.

kurn is built on measured algorithm choices: the character 2+3-gram roaring-bitmap inverted index, the IDF-scored pigeonhole candidate scan, and build/query analyzer symmetry, implemented domain-blind and driven entirely by per-list config. Measured results and methodology are in bench/README.md.

Configuration

A list is declared once at PUT /v1/lists/{name} with an analyzer and a match config. Index-time fields are frozen at creation; to change them, recreate the list.

Analyzer

One normalization pipeline applied identically at index and query time. Either a preset or an explicit ordered steps list:

step effect
lowercase Unicode lowercasing
fold_diacritics strip combining marks: Zürich → Zurich (see Limitations)
strip_punctuation drop punctuation, keep word-inner hyphens: O'Neil, Vega-Ruiz → ONeil Vega-Ruiz
strip_words:<w1,w2,…> drop the listed whole tokens (post-lowercase titles etc.)
sort_tokens sort tokens, making matching token-order-blind
trim collapse runs of whitespace
idna domain normalization: strip trailing dots, IDN → punycode (münchen.example → xn--mnchen-3ya.example) via x/net/idna; lenient mapping (blocklists contain technically-invalid real-world domains); unmappable input degrades to the dot-stripped string, never errors

Presets are exactly these step lists:

preset steps
person-name lowercase, fold_diacritics, strip_punctuation, strip_words:mr,mrs,ms,dr,prof, sort_tokens
free-text lowercase, fold_diacritics, strip_punctuation
identifier lowercase, trim
domain lowercase, trim, idna
Match
field when meaning
mode index-time "ngram" (fuzzy) or "exact" (normalized whole-key membership)
fallback query-time "parent_domain" (exact mode only): when the full analyzed query misses, probe parent suffixes one label at a time (never bare TLDs), stopping at the first level with hits; exact hits score 100, parent-suffix hits score 90
grams index-time ngram sizes, default [2,3]
strip_spaces index-time remove spaces before gramming (recommended for names; makes matching robust to token fusion/splits)
exact_companion index-time ngram mode only: also build an exact index over the same analyzed keys, beside the ngram index, so the list answers exact_any_of lookups (whole-key equality under the analyzer, score 100) without an n-gram scan. Part of the resolved configuration and its digest; a prepared artifact stores it as its exact-companion section (format 1.1), a data-dir list rebuilds it from the entries at open
threshold query-time default minimum score fraction, overridable per query. Defaults to 0.6 in ngram mode; exact-mode lists get NO default (0 = return all match levels), and threshold > 0.9 suppresses parent-suffix matches (score 90)
topk query-time default per-list candidate cap, overridable per query. Defaults to 100 in ngram mode; exact-mode lists get NO default (0 = unlimited). The server's global post-merge top-K of 100 still applies when the query omits topk
List-level options
field when meaning
filterable query-time up to 8 declared filter fields, each a logical name mapped to a payload dot-path (path bound 256 runes); names are the vocabulary queries may then filter by ("filter":{"name":"value"}). Declaring fields changes the resolved-config digest (and so version stamps) but never the index — no rebuild
golden readiness optional probe set: [{"q","expect_id","min_score"} | {"q","absent":true}]. /readyz runs each through the normal query path — expect_id must appear among the candidates (with score ≥ min_score when set), absent must return none. A failing golden takes the node out of rotation with the reason in the body
overlay_auto_compact mutation-time when > 0, the daemon folds the overlay into a new base in the background once it reaches this many entries (0 = disabled, the default). Append-mode mutations rebuild the whole overlay each time, so an ungoverned overlay is the first wall a write-heavy list hits. Caveat: compaction recomputes segment-local IDF over the folded corpus, so ngram scores can shift slightly at each trigger (see score semantics)

Version config digests intentionally hash the resolved configuration's literal representation. Semantically equivalent spelling can therefore produce a different +c value—for example, reordering words inside one strip_words:mr,dr step. This is safe, but callers should compare served version strings rather than attempting to canonicalize configuration JSON.

Score semantics (score is 0–100): IDF-weighted coverage of the query's grams that are known to the index — grams absent from the indexed corpus are excluded from the denominator. This is the reference-faithful behavior: on large corpora almost every gram is known and the score behaves as expected, but on tiny corpora it inflates (an unknown query token simply doesn't count against the match — note the score:100 on the typo'd quickstart query over a 2-entry list).

HTTP API

method & path description notable semantics
GET /healthz health + admission metrics query_queue_depth / query_inflight_bytes when admission control is on
GET /livez liveness 200 once the process serves — never depends on data state
GET /readyz readiness 200 iff every list dir opened cleanly AND every configured golden probe passes; 503 body enumerates each failure (list, probe, reason). Cached ~5s, invalidated instantly by any mutation
GET /metrics Prometheus text exposition per-list query counters, hit rate, latency histogram (100µs–10s log buckets), entry/overlay/tombstone/build-loss gauges (incl. kurn_list_unindexed_entries), mutation counters, admission queue depth
GET /v1/lists stats for all lists —
PUT /v1/lists/{list} create a list with config replaces any existing list of that name, wiping its data
GET /v1/lists/{list} one list's stats entries/overlay/tombstones/version/mode, plus the build-loss counters dropped_keys/keyless_entries, and unindexed_entries when non-zero (see below)
POST /v1/lists/{list}/entries upsert entries body is a JSON array, or NDJSON when Content-Type: application/x-ndjson (streamed, 1 MiB per-line bound, no whole-body bound). ?replace=true atomically swaps the whole list content instead of upserting; the parameter is strict — only true/false, anything else is a 400. Bulk loads should use replace: append mode rebuilds the overlay per batch and journals every entry; NDJSON append failures mid-stream are partial (response reports applied). An append stream is pinned to the list generation it started on: if a PUT/reload replaces the list mid-upload, the stream stops with 409 rather than letting the remainder land in the new list
DELETE /v1/lists/{list}/entries/{id} delete one entry idempotent tombstone
POST /v1/lists/{list}/reload publish a shipped bundle: re-open the list from disk atomic swap; failure keeps previous content serving (409); response = fresh stats + golden-probe results
POST /v1/lists/{list}/compact fold journal/overlay into a fresh base run after large append sessions
POST /v1/query query one or more lists {"q","lists",["threshold"],["topk"],["filter"]}; 1–100 list entries, threshold in [0, 1], and topk in [1, 1000] when present (400 otherwise). Results from all named lists are merged score-descending and cut globally. Optional filter maps up to eight declared logical names to exact JSON strings, booleans, numbers, or {"in":[...]} sets (up to 64 alternatives); names are ANDed, IN values are ORed, and types never coerce. Every name must be declared filterable by every requested list or the whole query fails with 400. Repeated names/members, nulls, nested values, empty sets, unknown/repeated operators, oversize expressions, and malformed stored payload fail closed. Filtering runs before the per-list top-K cut and never changes scores; payload arrays auto-descend, while a missing path or reached value of another type is a non-match. A non-empty successful response echoes the canonical applied expression (sorted names/sets, deduplicated IN, fixed-decimal numbers) and includes per-list filter_stats; clients must compare the echo with their locally canonicalized expression, not raw request bytes, and tolerate omission from older nodes. Library: ParseTypedFilter, PrepareTypedFilteredQuery, QueryTypedFilteredCtx; the legacy string-map wrappers remain compatible.
POST /v1/batch-query run up to 100 query checks in one request {"checks":[{"q","lists",["threshold"],["topk"]},...]} (1–100 checks); results[i] is either a full single-query envelope or {"error":"..."} for that check, order preserved

Exact any-of lookups. A check may replace q with "exact_any_of":["term", …] (1–64 terms, each 1–512 runes): every term is analyzed under the list's analyzer and looked up as a whole key in the list's exact index, which an exact-mode list has by nature and an ngram list has when configured with exact_companion (400 otherwise). Every live entry holding any term as a key is a candidate at score 100, once, ordered by entry_id, with key the entry's first key that analyzes to a looked-up term; topk, filter and the admission gate apply, threshold does not (400 if present). Terms that analyze to nothing are dropped and duplicates merged. Typical use: a caller's own typo variants of an identifier, sent as one bounded lookup instead of one n-gram check per variant. Library: PrepareExactAnyOf, PrepareTypedFilteredExactAnyOf.

Batch checks share one round trip; per-check errors are inline, so one bad check never fails the batch.

All responses are JSON; errors are {"error":"message"} with a matching status code.

Domain blocklists: use analyzer preset domain with "fallback": "parent_domain" — score 100 is an exact hit, 90 a parent-suffix match, and key names the listed domain either way. List the bare parent domain, not wildcard rows: a listed *.tempmail.com matches only the literal starred query, while a listed tempmail.com covers every subdomain via fallback.

Three ways a list can hold entries nothing can find, all reported rather than left to be discovered by a query that should have matched: dropped_keys counts keys the analyzer collapsed to empty, keyless_entries counts entries whose keys ALL collapsed, and unindexed_entries counts entries an installed base.idx does not reach — which happens when a bundle ships an index covering fewer entries than its base.jsonl. The engine tolerates that last case on purpose, so a crash between writing the two leaves a recoverable list rather than a fatal one, but the entries past the index's reach match nothing until the list is compacted or rebuilt. The first two are an analyzer question; the third means a stale artifact. unindexed_entries is omitted from JSON when zero and is a gauge on /metrics worth alerting on.

Scores are only comparable within a list: 90 means "parent-suffix match" on an exact-mode list but "90% fuzzy similarity" on an ngram list — aggregators merging candidates across lists must not conflate the two.

Library usage

The engine is usable directly, without the server:

For a complete compile-checked program using fuzzy and exact lists, a typed filter, answer-version capture, and clean store shutdown, see examples/embed.

package main

import (
	"fmt"
	"log"

	"github.com/kurn-dev/kurn/engine"
)

func main() {
	// In-memory list. For a durable on-disk store, use engine.Open(dir)
	// and st.CreateList / st.Replace / st.List instead.
	l, err := engine.NewList("people", engine.ListConfig{
		Analyzer: engine.AnalyzerConfig{Preset: "person-name"},
		Match:    engine.MatchConfig{Mode: "ngram"}, // grams 2+3, threshold 0.6, topk 100
	})
	if err != nil {
		log.Fatal(err)
	}
	if err := l.Replace([]engine.Entry{
		{ID: "p1", Keys: []string{"Elena Vasquez", "Vasquez, Elena M."}},
		{ID: "p2", Keys: []string{"Marcus Chen"}},
	}); err != nil {
		log.Fatal(err)
	}
	for _, c := range l.Query("vasquez elna", engine.QueryOpts{}) {
		fmt.Printf("%s score=%.1f key=%q\n", c.EntryID, c.Score, c.Key)
	}
	// p1 score=100.0 key="Elena Vasquez"
}

List.Query is lock-free and safe under concurrent mutation; mutations (Replace/Upsert/Delete/Compact) serialize internally.

Performance

Synthetic figures come from cmd/kurn bench; the five-public-list row is a deployment-shaped loopback HTTP session over bundles built with the committed mappings. Latency depends on corpus size, so each figure names its corpus and machine; full tables, methodology and caveats are in bench/README.md.

Fuzzy (ngram) lists, person-name preset, grams 2+3, threshold 0.6:

corpus machine p50 throughput
148,837 entries (five public lists) 4 vCPU server 0.64 ms 5,034 q/s, 8 clients
10M synthetic keys M1 Pro laptop 13.2 ms 71 q/s, single-threaded

The first row is the median of three runs in one session; p50 moves about ±15% between runs on shared vCPUs.

  • Memory: ~114 B/key end-to-end (≈69 B/key postings, the rest the ID→ordinal map; HeapAlloc-delta estimates). Process RSS is larger, since entries and payloads are resident too: the five public lists index in about 22 MB and settle at about 128 MB resident.
  • Recall: 0.940 overall truth-ID recall at threshold 0.6, and 0.975 at 0.45 for roughly 3× the latency — measured on synthetic perturbations, which are an easier corpus than real-world name data, so the shape (per-class floors, threshold trade-off) is the transferable claim rather than the level. On real ground truth (OFAC SDN aliases, indexed with the publisher-provided aliases as keys): 1.000.

Exact lists, identifier preset, 2M keys: 133 B/key, p99 4 µs, 925k queries/s single-threaded.

Mutable-store cold open: both mutable base.idx modes reopen from the serialized index artifact (base.idx) instead of rebuilding — 0.91 s at 1M keys, against 5.5 s for a full rebuild. The artifact is a pure cache: any load failure, including an analyzer change detected through its recorded digest, falls back to a rebuild. Recovery time is open time.

Calibrating your own corpus

kurn calibrate sweeps caller-chosen fuzzy thresholds over one immutable list snapshot and a labelled application corpus. It reports truth-ID recall and misses beside returned-candidate counts and truth ranks, exposing the trade-off at each threshold. Threshold selection belongs to the evaluator's loss function. The command can also write a privacy-safe kurn-calibration/v1 artifact with hashed inputs and deterministic decision facts separated from timing and approximate-memory observations.

The complete neutral walkthrough and CI-smoked fixtures are in docs/calibration. Calibration provides decision evidence for n-gram lists only; exact matching has no fuzzy-threshold sweep.

Ingestion: mapping, dry-run, bundles

Any feed — a database export, a CSV, an XML publication, an NDJSON dump — loads through a declarative mapping instead of custom code: dot-paths, per-instance equals-only filters, multi-path joins. The examples in docs/examples/ map several structurally difficult public feeds; the same file shape maps a SELECT from your own tables:

# 1. Gate: what would this feed become? (CI-able: exits 1 over the rate)
kurn ingest -dry-run -mapping ofac.mapping.json -in sdn.xml -max-empty-rate 0.05

# 2. Build the publishable bundle offline (never in the serving process);
#    -prev emits delta.jsonl for downstream re-checking
kurn build -mapping ofac.mapping.json -in sdn.xml -out bundle/ -prev old-bundle/ -source "OFAC SDN 2026-07-29"

# 3. Publish: ship bundle files into the list dir, then
curl -s -X POST localhost:8080/v1/lists/sanctions/reload
# -> fresh stats + golden-probe results; on failure the old content keeps serving

The bundle manifest's full sha256 is the base half of the version stamp the node serves. The manifest's 12-hex version_id remains a convenient display prefix; semantic publish/query identity is the complete configuration-bearing node stamp. Ship order, manifest schema, and rollback are specified in docs/platform-contract.md.

Multi-tenancy

-tenants-file tenants.json turns one kurnd into a shared multi-tenant node. Tenancy is key-scoped: the tenant is resolved from the presented API key and routed to its own store under <data>/tenants/{id} — /v1/* paths are unchanged, and a tenant cannot even name another tenant's namespace. Without the flag, nothing changes (single-tenant, exactly as above).

{
  "acme": {
    "key_digests": ["<sha256 hex of the key>"],   // kurn keygen prints pairs
    "quotas": {"max_lists": 10, "max_total_keys": 5000000,
               "scratch_budget_mb": 256, "rate_per_sec": 200}
  }
}
  • The file carries digests, not keys — a leaked tenants file exposes no usable credential. kurn keygen mints a key + digest pair.
  • SIGHUP re-reads the file (systemd ExecReload shape): tenants and keys are added/removed atomically, surviving tenants keep their live stores, and an invalid file keeps the previous registry serving (logged). Rotation is overlap-based: ship old+new digests, migrate, remove the old.
  • A removed tenant stops resolving immediately; its data stays on disk (deletion is a deliberate manual act).
  • /readyz failures and /metrics series carry a tenant label; /livez, /readyz, /metrics, /healthz remain keyless.
  • Those four endpoints must not be reachable by tenants or from an untrusted network. Keyless plus tenant-labelled means they enumerate the roster: /metrics publishes every tenant's name, list count, live key count, query and mutation totals, queue depth and 429s — the billing contract, so one tenant could read another's volumes — and a /readyz failure names the tenant, the list, and the golden probe's query string. Bind them to a management interface or filter them at the proxy. This is a deployment requirement rather than a default because the endpoints exist to be scraped by a control plane.
  • Capacity quotas are enforced: max_lists at list creation (PUT-replace of an existing list is not a new list) and max_total_keys — live raw keys summed across the tenant's lists — at entry admission, with 403s naming the quota and the numbers. Checks are read-then-act: a tenant can overshoot by at most one in-flight batch per concurrent writer (the control plane reconciles from metering). Zero/absent quotas are unlimited.
  • The memory governor is two-level: a query acquires its tenant's scratch_budget_mb slice BEFORE the global -query-mem-budget-mb, so one tenant's scan storm (worst case: explicit threshold: 0) queues against its own budget before it can contend with anyone else's; tenant and global exhaustion both 503 (the body names which level).
  • rate_per_sec is a per-tenant token bucket (burst 2×rate) on the query path only: over-rate queries get 429 + Retry-After immediately — throttling is a fast no, never a queue. In a batch, each check spends one token and over-rate checks fail inline. Per-tenant queue depth, in-flight bytes, and 429 counts are on /metrics.
  • Rate limiting covers QUERIES only — mutation throughput is bounded by capacity quotas and journal I/O, not a token bucket; a platform should alert on kurn_tenant_mutations_total spikes rather than expect 429s on writes.
  • Metering is the metrics surface: kurn_tenant_queries_total, kurn_tenant_mutations_total, kurn_tenant_keys, kurn_tenant_lists (plus the governor/429 series) are the per-tenant billing contract — a control plane scrapes and aggregates them; kurnd persists nothing.
  • Every acknowledged mutation emits one JSON audit line (stdlib slog, stdout by default — separable from kurnd's stderr logging): tenant, op, list, entry count, and the new content-addressed version stamp; NDJSON partial successes are marked partial. Queries are metered by counters, never logged per request. Shipping/retention is the deployment's job.

Operations

Mutable-store kurnd is crash-only: there is no graceful shutdown and none is needed — kill -9 is a legal stop. Prepared-artifact mode is read-only, has no journal, and drains accepted HTTP requests on SIGTERM or SIGINT before closing. Mutable recovery is the startup path itself: the base loads via the base.idx artifact fast path and the journal replays on top. Mutable-store RTO = Open time: measured for base.idx at 1M ngram keys (BenchmarkStoreOpen, this machine), 0.91 s with the artifact vs 5.2 s rebuilding from base.jsonl (the fallback after e.g. an analyzer change). The -journal-fsync knob picks the power-loss window (see Limitations).

Degraded starts never block the daemon; they surface three ways — startup log lines, /readyz failures, and this table:

state meaning repair
list dir skipped interrupted create/replace, missing/corrupt config, or a stray dir fresh PUT /v1/lists/{name} (re-declare + reload), or remove the directory
journal quarantined journal could not be applied (moved to journal.jsonl.quarantined) list serves at base state; inspect/repair the quarantined file, or accept the loss and delete it
golden probe failing data or matching regressed on a known query /readyz body names list, probe, and reason

Deployment shape (Stage 1): two nodes behind a TCP/HTTP load balancer, each with its own data dir; publish lists to both (for mutable stores and full-verified prepared artifacts, same recomputed content versions support the identical-answer check; trusted prepared responses instead label a declared version basis that does not by itself prove equality). Compare versions together with version_bases. Terminate TLS at the LB or the nodes; when -api-keys-file is set the keys are bearer credentials, so never run them over plaintext. A Dockerfile ships in the repo root. Readiness-gate the LB on /readyz, alert on /metrics.

Limitations

  • Auth is deliberately minimal: optional API keys (-api-keys-file, off by default; gates /v1/*, probes and metrics stay open) or the key-scoped tenants file — TLS is the deployment's job; terminate it in front of kurnd whenever keys are in use (they are bearer credentials).
  • Journal fsync is off by default: with the default -journal-fsync=none, appends are atomic and replay on open (process- crash-safe), but an acknowledged write can be lost on power loss / kernel crash. -journal-fsync=every closes that window at ~3.9 ms per mutation on laptop hardware (vs ~0.1 ms); interval group-commits concurrent mutations through one fsync per window (default 2 ms). Base/config writes are always fsynced, directory entry included.
  • Query admission is memory-based: in-flight queries are bounded by a scratch-memory budget (-query-mem-budget-mb, default 1024). Each query is charged a conservative ceiling — ~8 bytes × list ordinals for the scan accumulators plus a bounded per-hit term, computed from the exact snapshot the query then executes — with FIFO queuing and a 503 after -query-queue-timeout; a single query needing more than the whole budget is refused with a 503 naming the remedy. The budget counts memory, not CPU — an explicit no-floor query (threshold: 0) is the most CPU-expensive shape, and concurrency capping plus client-disconnect cancellation are what bound it.
  • Scores are segment-local: base and overlay carry separate IDF stats, so a candidate's score can shift slightly when compaction folds the overlay into the base. Ordering by score within one segment is stable.
  • Version stamps are content-addressed for daemon/store-managed lists: <hash>@<baseEntries>+j<journalBytes>[.<journalHash>]+c<configHash> — full sha256 of base.jsonl (or empty); the journal's exact byte content (omitted for an empty journal, byte count as replay depth); and the RESOLVED list configuration, because byte-identical data queried under a different mode, analyzer, or default answers differently. Full hashes, not prefixes: the stamp is evidence, and truncated digests invite deliberate collisions. The same disk state always yields the same version across restarts, and equal recomputed-basis versions identify equal data under an equal configuration. Trusted prepared artifacts report declared-base-id; equality of those declarations alone is not proof of equal bytes. Only lists managed DIRECTLY through the library (engine.NewList, no Store) still stamp process-local gen… counters, which do not survive restarts.
  • fold_diacritics only strips combining marks: foldables that don't decompose to base + mark — ø, Ł, ß, æ, Đ — do not fold.

Development

go test ./... -race          # full test suite
go run ./cmd/kurn bench -n 100000 -sample 200 -seed 42 -threshold 0.6   # synthetic bench

kurn also has gen (corpus export: entries NDJSON + query corpus CSV, readable back via bench -entries/-corpus) and query (one-off queries against a data dir).

path contents
engine/ store, lists, snapshots, journal, compaction
engine/analyzer/ normalization pipeline + presets
engine/ngram/ char-ngram roaring index, IDF pigeonhole lookup
engine/exact/ exact-match index
engine/artifact/ serialized index cache (fast reopen)
server/ HTTP handlers (stdlib only)
cmd/kurnd/ the daemon
cmd/kurn/ dev/bench CLI
bench/ synthetic corpus generator + bench harness, measured results
docs/ the node ⇄ control-plane contract, list-mapping examples

License

kurn is licensed under the GNU AGPL-3.0. Using kurnd as a service — including inside your company, modified or not — is free; the AGPL asks only that modifications to kurn itself be shared if you serve users over a network with them. Embedding the engine library in a closed-source product needs a commercial license instead — mail ops@kurn.it.

Security

See SECURITY.md. Report vulnerabilities to ops@kurn.it — not in public issues.

Directories

Path Synopsis
Package bench generates synthetic corpora and measures kurn recall, latency, and memory against pinned record runs.
Package bench generates synthetic corpora and measures kurn recall, latency, and memory against pinned record runs.
ofac command
Command ofac extracts the OFAC SDN list into the bench harness's file shapes (a re-runnable ground-truth experiment): entries NDJSON for `kurn bench -entries` and an alias query corpus CSV for `-corpus`.
Command ofac extracts the OFAC SDN list into the bench harness's file shapes (a re-runnable ground-truth experiment): entries NDJSON for `kurn bench -entries` and an alias query corpus CSV for `-corpus`.
cmd
kurn command
kurn build — offline bundle build: feed + mapping → publishable bundle (config.json, base.jsonl, base.idx, manifest.json, delta.jsonl with -prev).
kurn build — offline bundle build: feed + mapping → publishable bundle (config.json, base.jsonl, base.idx, manifest.json, delta.jsonl with -prev).
kurnd command
kurnd serves a kurn data directory over HTTP.
kurnd serves a kurn data directory over HTTP.
Package engine is the kurn lookup engine: domain-blind lists of entries with fast fuzzy (char-ngram) or exact matching.
Package engine is the kurn lookup engine: domain-blind lists of entries with fast fuzzy (char-ngram) or exact matching.
analyzer
Package analyzer is the single canonicalization pipeline shared by the index builder and the query path.
Package analyzer is the single canonicalization pipeline shared by the index builder and the query path.
artifact
Package artifact persists base indexes — ngram (KURNIDX1: magic header, JSON meta, then length-prefixed gram/bitmap pairs) and exact (KURNEXA1, see exact.go).
Package artifact persists base indexes — ngram (KURNIDX1: magic header, JSON meta, then length-prefixed gram/bitmap pairs) and exact (KURNEXA1, see exact.go).
exact
Package exact is the normalized-exact index: analyzed key -> entry ordinals.
Package exact is the normalized-exact index: analyzed key -> entry ordinals.
ngram
Package ngram is a character-n-gram index over roaring-bitmap postings with an IDF-weighted pigeonhole lookup — the execution path that won the measured evaluation (naive map-counting, dense WAND, and DAAT/Block-Max- WAND all lost; only this path is kept).
Package ngram is a character-n-gram index over roaring-bitmap postings with an IDF-weighted pigeonhole lookup — the execution path that won the measured evaluation (naive map-counting, dense WAND, and DAAT/Block-Max- WAND all lost; only this path is kept).
prepared
Package prepared is the prepared artifact format: one immutable file holding a single list's resolved configuration, build provenance, records and base index, so a server can load a list without parsing base.jsonl or rebuilding its index.
Package prepared is the prepared artifact format: one immutable file holding a single list's resolved configuration, build provenance, records and base index, so a server can load a list without parsing base.jsonl or rebuilding its index.
prepared/builder
Package builder builds prepared artifacts from engine entries with the engine's own configuration resolution and index code.
Package builder builds prepared artifacts from engine entries with the engine's own configuration resolution and index code.
examples
embed command
Command embed shows Kurn running as a Go library inside an application.
Command embed shows Kurn running as a Go library inside an application.
Offline bundle build: stream the mapping into an engine store — the SAME code path the server uses, so the bundle is exactly what a node would have written itself — and emit the publishable bundle: config.json + base.jsonl + base.idx + manifest.json (+ delta.jsonl against a previous bundle).
Offline bundle build: stream the mapping into an engine store — the SAME code path the server uses, so the bundle is exactly what a node would have written itself — and emit the publishable bundle: config.json + base.jsonl + base.idx + manifest.json (+ delta.jsonl against a previous bundle).
Query admission control: lookups allocate dense ngram scan buffers, query- gram scratch, and bounded hit materialization (including exact-mode hits).
Query admission control: lookups allocate dense ngram scan buffers, query- gram scratch, and bounded hit materialization (including exact-mode hits).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL