Skip to content
Merged
36 changes: 36 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,42 @@ concurrency:
cancel-in-progress: true

jobs:
# The one thing in this workflow that IS a gate. `NominalResolutionCostSpec`'s
# three ratio assertions — 2n/n and 4n/n over the schema's field count, and
# snake/identity — are armed only by `-Deo.costGate=true`, and this is the lane
# that arms them (issue #103). They are a clock, and the push/PR test matrix is
# three JDK jobs on shared runners where project policy says a nanosecond is
# +/-15-50%, so leaving them there would gate every diff in the repo on someone
# else's noisy neighbour. The spec's cost table prints on every lane either way.
#
# A SEPARATE JOB, not a step in `report`, for two reasons: a timing flake must
# not abort the coverage and mutation sweep and cost this tag its QA artifacts,
# and a runner doing nothing else is the only place a ratio of wall-clock
# numbers means anything. It also never sees scoverage-instrumented bytecode,
# which would make any timing meaningless.
cost:
name: Nominal resolution cost gate
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
SBT_OPTS: "-Xmx4g -Xss8m -Dsbt.color=false"
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup sbt
uses: sbt/setup-sbt@v1

- name: Setup Java (temurin@25)
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 25
cache: sbt

- name: Cost ratios (armed)
run: sbt -batch -Deo.costGate=true "avroIntegration/testOnly *NominalResolutionCostSpec"

report:
name: Coverage + mutation
runs-on: ubuntu-22.04
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
name that misses at runtime. `AvroPrism` / `AvroTraversal`, `JsonPrism` / `JsonTraversal`
(eo-circe) and `JsoniterPrism` / `JsoniterTraversal` (eo-jsoniter) all share the parser, so all
six surfaces are covered.
- **Schema-field name resolution is linear in the record's field count, not quadratic** (#103): the
nominal rung above called a per-case-field lookup that, on an exact-name MISS, linear-scanned
every schema field with no early exit — it has to see a second match to call a name ambiguous. So
a drilled hop cost `arity x fields.size x nameLength`, and it was charged to exactly the codecs
the rung exists for, since a name transform (`withSnakeCaseFieldNames`, a custom
`transformFieldNames`, a `vulcan.Codec` rename map) guarantees the miss; an identity-named codec
answers from Avro's own name hash and never scanned. Measured on a 66-field snake_cased record:
**486 us -> 8.8 us per resolution**, with the n -> 2n cost ratio falling from 3.97 (quadratic) to
2.2 (linear). Resolution now builds one normalised-name index per record schema in a single pass
and caches it under the schema's REFERENCE identity behind weak keys, so a k-hop chain into one
record builds it once and a `def`-shaped optic — which re-resolves per operation — stops paying
per operation. The doctrine is untouched: `NominalResolutionParitySpec` diffs every verdict of the
new rung against the frozen pre-index implementation over 151,515 synthetic
(schema x case-name-list) pairs plus every fixture behind the #95 suites, and the diff is empty.
The exact-name fast path is unchanged and still allocation-free — an identity-named codec
neither builds nor consults an index, and its per-resolution allocation is byte-identical before
and after (24 / 64 / 152 / 280 B at n=2 / 12 / 33 / 66, all of it the `out` array). The index is
resolved ONCE per resolution and threaded through the case fields, not re-fetched per field:
per-field it would be a `ConcurrentHashMap` probe and a throwaway lookup key each, which is the
same multiplier this issue removes, merely one level down. Two costs are traded for the time,
both on the transformed path only and both measured with
`com.sun.management.ThreadMXBean.getThreadAllocatedBytes`: the normalised key is a materialised
`String`, so a resolution that misses the exact-name hash now allocates **~104 B per case field**
where the pairwise cursor walk it replaces allocated nothing (24 -> 256 B at n=2, 280 ->
7,168 B at n=66 per resolution); and building the index makes the FIRST resolution against a 1-2
field record **slower** (n=1 ~+250 ns, n=2 ~+100 ns, once per schema), with the crossover at n=3
and a 1.5x / 4.6x / 24x cold win at n=4 / 12 / 66.
- **The cached name index is handed out as an unmodifiable view** (#103): `private[avro]` is a
Scala-only fence and the cached index is a process-wide singleton per schema, so returning the
live `HashMap` meant one in-module `put` could silently corrupt field resolution for that schema
for the life of the JVM. The wrapper, not the backing map, is what the cache stores, so the wrap
costs one allocation per schema rather than one per lookup and repeated lookups still hand back
the same instance.

### Changed

Expand Down
Loading