Skip to content

Add effect/encoding/Thrift - #10652

Open
fubhy wants to merge 16 commits into
mainfrom
feat/encoding-thrift
Open

fubhy wants to merge 16 commits into
mainfrom
feat/encoding-thrift

Conversation

@fubhy

@fubhy fubhy commented Nov 15, 2026 •

Copy link
Copy Markdown
Member

Stack

Arrow chain (each PR is based on the one before it):

  1. Add effect/encoding/FlatBuffers #10643 effect/encoding/FlatBuffers (base: main)
  2. Add effect/arrow #10649 effect/arrow (base: feat/encoding-flatbuffers)
  3. Add effect/dataframe #10641 effect/dataframe (base: feat/arrow-support)

Independent foundation, based on main and not part of the chain: this PR effect/encoding/Thrift, for the planned Parquet reader. A later Parquet to ArrowTable layer joins Parquet and Arrow.

Arrow Flight, Protobuf / gRPC / ConnectRPC and Thrift's binary protocol / RPC envelopes are out of scope.

Why

A planned effect/encoding/Parquet reads Parquet's footer (FileMetaData), page headers, column and offset indexes and bloom filter headers, all of which are Thrift compact-protocol structs. A later Parquet writer writes them. Footers of large files hold thousands of row groups × columns, so reading them is on the hot path of opening a file. Other Thrift users (Thrift RPC payloads, other formats built on Thrift) would need the same layer.

Requirement

  • Read compact-protocol bytes without an IDL, the way generated code does: read a field header, switch on its id, read the value or skip it (unknown fields, forward compatibility).
  • Fast on big footers: no allocation per field, binary values as views of the input.
  • i64 both as offsets/counts (safe integers) and as full 64-bit values.
  • Never read past the input, never allocate from an untrusted length, and fail only with a typed error on malformed or hostile input.
  • Write bytes identical to the official Thrift library's.

Alternatives

  • The thrift npm package: a runtime dependency; allocates an object per field header and reads i64 as node-int64; about 7× slower than this module's generic tree reader on the benchmark below.
  • hyparquet's reader: decodes everything into field_N objects with i64 always bigint; no writer, no limits, no sets/maps/uuid; about 4× slower than a typed reader on this module.
  • IDL codegen (.thrift → Effect Schema): possible later, on top of this layer.
  • Binary protocol / RPC message envelope: left out of v1; Type uses Thrift's protocol-neutral TType codes so a binary protocol can share them.

Summary

effect/encoding/Thrift
  makeReader(bytes, { maxDepth?, maxBinaryLength?, maxContainerSize? }) -> Reader   throws ThriftError
  decode(bytes, read, options) -> Result<A, ThriftError>   rethrows Usage
  Reader  position (settable), fieldId, elementType, keyType, valueType
          readStructBegin/End, readFieldBegin -> Type, readListBegin/readSetBegin/readMapBegin -> size
          readBool/I8/I16/I32/Double, readI64 -> bigint, readI64Number -> number (Range if unsafe)
          readBinary -> view, readString, readUuid, skip(type), readValue(type) -> Value
  makeWriter({ initialSize? }) -> Writer   (mirrors Reader; write* / writeValue / finish)
  Type     Stop, Bool, I8, Double, I16, I32, I64, Binary, Struct, Map, Set, List, Uuid (TType codes)
  Value    tagged tree of any value, round-trips
const r = Thrift.makeReader(footer)
r.readStructBegin()
for (let t = r.readFieldBegin(); t !== Thrift.Type.Stop; t = r.readFieldBegin()) {
  switch (r.fieldId) {
    case 3: numRows = r.readI64Number(); break
    case 6: createdBy = r.readString(); break
    default: r.skip(t)
  }
}
r.readStructEnd()
  • Method names and semantics follow Thrift's TProtocol: a bool field's value lives in its header, so readBool() right after a Bool header reads no byte; readStructBegin/End track the field-id deltas.
  • Limits: a length or container size larger than the remaining input fails (Truncated) before allocating; varints are capped at 5/10 bytes; nesting is capped by maxDepth (default 64, at most 1000 so the recursive skip and readValue stay within the stack).
  • ThriftError kinds: Truncated, Malformed, InvalidUtf8, Range, Limit, Usage. The reader throws; decode returns a Result and rethrows Usage (caller bugs: invalid options, out-of-order calls, an invalid position, skip/readValue of a type no input can produce), as FlatBuffers (Add effect/encoding/FlatBuffers #10643) does.
  • Options are validated: limits must be non-negative integers and initialSize a non-negative integer, else Usage. Reader options are ReaderOptions.
  • Out-of-range i16 values and field ids fail with Malformed instead of wrapping (Python keeps them; Java/C++ wrap).
  • Binary and uuid values, also inside a Value, are views of the input: don't mutate the input while you use them.
  • Experimental, @since 4.1.5.

Evidence

The oracle is the official Python library (thrift 0.25.0, TCompactProtocol), via test/fixtures/thrift/generate.py, which reproduces gold.json byte for byte (61 KB: 47 hand-written cases, one or more per compact-protocol rule, 20 seeded random structs and 7 structs cut from apache/parquet-testing).

  • Our writer produces the same bytes as Python for every gold case, and every case reads back to the same tree. A typed footer reader in the test checks row counts, column names and offsets against what pyarrow reports.
  • In the other direction, a one-off check had Python read 2000 random trees written by this module (4.9 MB): all read back to the same tree and re-encoded to identical bytes.
  • Hostile input: every truncation of a gold case fails with Truncated; mutation fuzzing of the corpus (in the test file) and a 600k-input one-off fuzz only ever produced ThriftError.
pnpm vitest --run test/encoding/Thrift.test.ts   56 passed

The original PR's benchmark (11.8 MB FileMetaData, typed reader 144 ms vs hyparquet 584 ms and the thrift npm package 1753 ms) has not been re-run during the takeover. A micro-benchmark on a 1.7 KB footer shows no change from the original implementation when reading or skipping it. readString is about 3.5× faster on short ASCII strings since it moved to the shared internal/utf8 decoder.

Status

The takeover's assessment, fixes, focused tests, independent review and simplification pass are done.

Merge Danger

Door: two-way

A new experimental module; nothing existing changes except one line in the effect/encoding barrel.

Blast Radius: none

No existing exports change. The fixture gold.json adds 61 KB to the repo (excluded from dprint).

Closes EFF-3816

Reads and writes the Apache Thrift compact protocol without an IDL.
Reader is a TProtocol-shaped cursor: readFieldBegin/readListBegin/
readMapBegin, scalar reads, skip(type), and readValue into a typed
Value tree. Every read is bounds-checked; lengths and container sizes
larger than the remaining input fail before allocating, nesting is
limited by maxDepth, and decode returns a Result with a ThriftError.
Binary values are views of the input. i64 reads as bigint, or as a
checked number with readI64Number. Writer mirrors the reader.

The fixtures hold 349 value trees with the bytes the official Python
thrift 0.25.0 TCompactProtocol writes for them, plus seven structs cut
from apache/parquet-testing files (footers, page headers, bloom filter
header, offset and column index). The tests check that our bytes are
identical, read every case back, and fuzz corrupted input.
@changeset-bot

changeset-bot Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 81b9a19

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 35 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-amazon-bedrock Patch
@effect/ai-anthropic Patch
@effect/ai-cloudflare Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/ai-typesafe Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-cloudflare Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/graphql-generator Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-janitor effect-janitor Bot added the 4.0 label Nov 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 40.09 KB 40.09 KB 0.00 KB (0.00%)
basic.ts 7.31 KB 7.31 KB 0.00 KB (0.00%)
batching.ts 11.21 KB 11.21 KB 0.00 KB (0.00%)
brand.ts 7.00 KB 7.00 KB 0.00 KB (0.00%)
cache.ts 11.73 KB 11.73 KB 0.00 KB (0.00%)
config.ts 23.09 KB 23.09 KB 0.00 KB (0.00%)
differ.ts 22.20 KB 22.20 KB 0.00 KB (0.00%)
http-client.ts 27.45 KB 27.45 KB 0.00 KB (0.00%)
http-router.ts 37.55 KB 37.55 KB 0.00 KB (0.00%)
logger.ts 12.19 KB 12.19 KB 0.00 KB (0.00%)
metric.ts 9.65 KB 9.65 KB 0.00 KB (0.00%)
optic.ts 7.21 KB 7.21 KB 0.00 KB (0.00%)
pubsub.ts 16.21 KB 16.21 KB 0.00 KB (0.00%)
queue.ts 13.09 KB 13.09 KB 0.00 KB (0.00%)
schedule.ts 11.99 KB 11.99 KB 0.00 KB (0.00%)
schema-bigdecimal.ts 14.29 KB 14.29 KB 0.00 KB (0.00%)
schema-binary.ts 42.45 KB 42.45 KB 0.00 KB (0.00%)
schema-class.ts 21.96 KB 21.96 KB 0.00 KB (0.00%)
schema-codegen.ts 30.70 KB 30.70 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 30.56 KB 30.56 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 28.26 KB 28.26 KB 0.00 KB (0.00%)
schema-string-transformation.ts 15.54 KB 15.54 KB 0.00 KB (0.00%)
schema-string.ts 12.50 KB 12.50 KB 0.00 KB (0.00%)
schema-template-literal.ts 16.43 KB 16.43 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 39.63 KB 39.63 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 21.11 KB 21.11 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 21.28 KB 21.28 KB 0.00 KB (0.00%)
schema-toFormatter.ts 21.37 KB 21.37 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 26.42 KB 26.42 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 21.45 KB 21.45 KB 0.00 KB (0.00%)
schema.ts 21.05 KB 21.05 KB 0.00 KB (0.00%)
stm.ts 14.40 KB 14.40 KB 0.00 KB (0.00%)
stream.ts 10.56 KB 10.56 KB 0.00 KB (0.00%)

Share one integer range check, decode strings with internal/utf8, drop the INVALID sentinel, the Reader's struct counter and the i64 bound constants, and build Writer container labels only on failure. Section banners go and the Reader and Writer docs no longer repeat their members.
Compare gold cases as Thrift.Value instead of converting back to JSON, use effect/encoding/Hex, and remove an identity helper and duplicated instructions from generate.py.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants