Repository navigation
Add bytes fields, and move documents and hits to MessagePack - #9
Merged
Merged
Conversation
Adds a `bytes` field type — the byte-array analogue of `string`, for a binary
id or key (a UUID, a hash, a packed struct) where a String would force you to
hex- or base64-encode it yourself:
.addBytesField("key", stored: true, indexed: true)
try index.add(["key": key, "body": "hello"] as [String: Any])
try index.search(.term("key", key))
try writer.deleteDocuments(field: "key", equals: key) // upsert
hit.data("key") // -> Data?
Values carry any byte pattern — NULs, invalid UTF-8, all 256 byte values.
tantivy has supported this since forever (`add_bytes_field`,
`Term::from_field_bytes`); it was simply never wired through the FFI.
Byte values cannot ride the JSON boundary the wrapper used: a JSON string must
be valid UTF-8 and the C strings carrying it are NUL-terminated. The first
implementation passed them as raw pointers alongside the JSON. That worked, but
it added an entire side-channel — a blob on results, a pointer array on
documents and queries, and an unconstrained lifetime whose safety the compiler
could not check. Benchmarking the alternative made the side-channel unnecessary
and turned out to matter far more than the feature that prompted it.
Documents and hits are now MessagePack, which has a native byte-string type:
search, 1000 hits decode 19.59 ms -> 0.92 ms end to end 21.61 -> 1.65 ms
writes, prose encode 1.64 us -> 0.70 us/doc ingest 4.33 -> 1.78 us/doc
Neither win is really about JSON being verbose — MessagePack is only 1.05x
smaller on prose. Two measurements drove it:
- On reads, 83% of decode time was `Codable`, not JSON parsing. FieldValue
discovered a value's type by attempting each decode in turn, so every
string threw four DecodingErrors before succeeding. Decode was 87-92% of
total search time; it is now 46-56%.
- On writes, JSONSerialization cost 3-6x what serde_json spent parsing the
same content back, and the payload was copied twice more on its way to a
NUL-terminated C string.
Documents are a MessagePack map of {field: [values]} decoded against the
schema, so each value is read as the field's declared type rather than guessed
from the encoding. That also retired the NaN pre-scan the JSON path needed
(a non-finite number reaching JSONSerialization raises an *uncatchable*
NSException); the encoder now checks inline as it writes.
Query trees stay JSON — they are a few hundred bytes sent once per search, and
serde_json::Value's random access suits a recursive tree with optional fields
far better than a streaming reader would. Their byte values are
{"$bytes":"<base64>"}, tagged rather than bare so a `Data` aimed at a `string`
field is reported instead of quietly searching for its base64 text.
Also removes JSONSerialization from the library entirely. Query trees and the
schema spec are written straight from their typed Swift representations, with
no intermediate [String: Any]. That deleted a latent trap in SchemaBuilder:
let data = (try? JSONSerialization.data(withJSONObject: spec)) ?? Data("{\"fields\":[]}".utf8)
build() cannot throw, so a serialization failure silently produced a schema
with *no fields*, surfacing as "unknown field" at every later addDocument,
arbitrarily far from the cause. There is now nothing left that can fail. The
duplicate string escaper in IndexWriter is gone, and both payloads are
byte-stable across runs (JSONSerialization emitted keys in hash order).
Two behaviour changes worth knowing:
- A whole-valued f64 keeps its type. `4.0` decoded as .int(4) through JSON
because decimal text cannot distinguish it; MessagePack tags the value.
- Mixed-type arrays are rejected by the schema-driven decode in Rust rather
than a Swift-side check, so the error arrives one layer later.
addDocument(_ value: some Encodable) still goes through JSON, since that is
what JSONEncoder produces; Data properties are base64-encoded and decoded back
by the engine. Correct, but it costs an encode and a decode — Document and the
[String: Any] overload avoid both.
Coverage 85.0% -> 89.3% of regions. The new tests worth calling out are the
MessagePack boundary cases: the format widens its length prefixes at 2^5, 2^8
and 2^16, so str32/bin32/array32/map32 and the 64-bit integer forms would
otherwise never execute, and a mistake in one is silent corruption rather than
a crash. Tested at the codec level on both sides with the tag byte asserted,
because map32 needs a 65,536-field document (tantivy's hard ceiling) and
array32 needs 65,536 values in one field.
The benchmarks behind the numbers ship in the test target, skipped unless
enabled:
TANTIVY_BENCH=1 swift test -c release --filter PathBenchmark
One measurement is left unresolved and labelled as such in the output: feeding
pre-encoded input saves more than the encoding accounts for, reproducibly. The
likeliest cause is merge scheduling, since each timed iteration adds to the
same index. It may mean more is available on writes than the 2.3x measured.
BREAKING: the C ABI changed. tantivy_result_json/_blob/_blob_len ->
tantivy_result_bytes/_len; CBytes and CBytesField removed;
tantivy_writer_add_json_bytes -> tantivy_writer_add_msgpack; byte-argument
parameters dropped from four functions; searches return MessagePack. A new
xcframework release is required before consumers can build.
Co-authored-by: Jason <jason@processor.ai>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
bytesfield type, then replaces the JSON document and hit encodingswith MessagePack after benchmarking showed the boundary — not the engine — was
where search time was going.
The feature
A
bytesfield is the byte-array analogue ofstring: indexed, the whole valueis one term matched byte-for-byte. It's for a binary id or key — a UUID, a hash,
a packed struct — where a
Stringforces you to hex- or base64-encode ityourself.
Values carry any byte pattern — NULs, invalid UTF-8, all 256 byte values. tantivy
has supported this all along (
add_bytes_field,Term::from_field_bytes); it wasnever wired through the FFI.
Why this turned into an encoding change
Byte values cannot ride the JSON boundary the wrapper used: a JSON string must be
valid UTF-8, and the C strings carrying it are NUL-terminated. The first
implementation sent them as raw pointers alongside the JSON — a blob on results, a
pointer array on documents and queries, and an unconstrained lifetime whose safety
the compiler could not check.
Benchmarking the alternative made that side-channel unnecessary, and turned out to
matter considerably more than the feature that prompted it.
Neither win is really about JSON being verbose — MessagePack is only 1.05× smaller
on prose. Two measurements drove it:
Codable, not JSON parsing.FieldValuediscovered a value's type by attempting each decode in turn, so every string
threw four
DecodingErrors before succeeding. Decode was 87–92% of totalsearch time; it is now 46–56%.
JSONSerializationcost 3–6× whatserde_jsonspent parsing the samecontent back, and the payload was copied twice more on its way to a
NUL-terminated C string.
What changed
Documents and hits are MessagePack. Documents are a map of
{field: [values]}decoded against the schema, so each value is read as the field's declared type
rather than guessed from its encoding. Hits are read straight into
SearchHitswith no intermediate representation and no
Codable. Byte values need noside-channel — MessagePack has a native byte-string type.
Query trees stay JSON. They're a few hundred bytes sent once per search, and
serde_json::Value's random access suits a recursive tree with optional fields farbetter than a streaming reader would. Byte values there are
{"$bytes":"<base64>"}— tagged rather than bare, so aDataaimed at astringfield is reported instead of quietly searching for its base64 text.
JSONSerializationis gone from the library. Query trees and the schema specare written from their typed Swift representations with no intermediate
[String: Any]. That deleted a latent trap inSchemaBuilder:build()can't throw, so a serialization failure silently produced a schema withno fields — surfacing as "unknown field" at every later
addDocument,arbitrarily far from the cause. Nothing left can fail. The duplicate string escaper
in
IndexWriteris gone too, and both payloads are now byte-stable across runs.Reviewer notes
Two behaviour changes:
f64keeps its type.4.0decoded as.int(4)through JSONbecause decimal text can't distinguish it; MessagePack tags the value.
a Swift-side check, so the error arrives one layer later.
addDocument(_ value: some Encodable)still goes through JSON, since that's whatJSONEncoderproduces;Dataproperties are base64-encoded and decoded back by theengine. Correct, but it costs an encode and a decode —
Documentand the[String: Any]overload avoid both.One measurement is unresolved and labelled as such in the benchmark output:
feeding pre-encoded input saves more than the encoding accounts for, reproducibly.
Likeliest cause is merge scheduling, since each timed iteration adds to the same
index. It may mean more is available on writes than the 2.3× measured.
Testing
345 Swift tests, 12 Rust tests. Coverage 85.0% → 89.3% of regions.
The ones worth calling out are the MessagePack boundary cases. The format widens its
length prefixes at 2^5, 2^8 and 2^16, so
str32/bin32/array32/map32and the64-bit integer forms would otherwise never execute — and a mistake in one is silent
corruption, not a crash. Tested at the codec level on both sides with the tag byte
asserted, because
map32needs a 65,536-field document (tantivy's hard ceiling) andarray32needs 65,536 values in one field.Benchmarks ship in the test target, skipped unless enabled:
TANTIVY_BENCH=1 swift test -c release --filter PathBenchmarkA new xcframework release is required before consumers can build.
tantivy_result_json/_blob/_blob_len→tantivy_result_bytes/_lenCBytesandCBytesFieldremovedtantivy_writer_add_json_bytes→tantivy_writer_add_msgpackrust/Cargo.tomlgainsbase64(already in the tree via tantivy) and theformattingfeature ontime.