Repository navigation
feat: Add protobuf diff and replay support with optional copy-builder tracking - #969
Conversation
Signed-off-by: Derek Riley <derek.riley@swirldslabs.com>
Signed-off-by: Derek Riley <derek.riley@swirldslabs.com>
Integration Test Report 618 files + 190 618 suites +190 53m 29s ⏱️ + 33m 41s Results for commit eb0555b. ± Comparison against base commit b1adbdf. ♻️ This comment has been updated with latest results. |
anthony-swirldslabs
left a comment
There was a problem hiding this comment.
The code looks good overall. I left a few comments.
Please let @jasperpotts check on the new public APIs that you're adding to models.
Thanks!
Review: feat: Add protobuf diff and replay support with optional copy-builder tracking (#969)Overall the PR implements the "Block State Changes → Diffs" design faithfully:
Two places improve on the design:
Design doc note: oneof switchesThe design's apply rule 3 says switching a oneof case needs no Major feedback1. The default configuration isn't exercised end to endTracking is off by default, but 2. Unknown fields throw
3. Behaviour change in a public API
4. Tracking memory costThe design calls for tracking on every message, with an opt-out. The PR makes it opt-in and global. For Hiero, enabling it adds roughly 12–16 bytes per instance to every message, including 5. No benchmarkThe design's estimates (30–100 ns to detect changes, 0.1–0.3 µs per splice) are meant to be confirmed with JMH, but the PR adds no JMH benchmark. Suggestion A: opt in to tracking per message instead of a global switchReplace the global // <<<pbj.copy_builder_tracking = true>>>
message Account { ... }Why:
Consequences:
Suggestion B: generate diff tests in
|
|
Addressed the points as follows:
|
Review (round 2): feat: Add protobuf diff and replay support with optional copy-builder tracking (#969)Reviewed at The code side of the first-round feedback is well addressed. The remaining items are test coverage of the real HAPI state types, documentation, and the PR description. First-round feedback
Root selectionDiff APIs are now opt-in per message too, not only tracking. Roots can be selected four ways:
Only roots get the diff API and a This is a good design:
Issues to fix before merge1. Coverage of real state types regressedThe old reflective round-trip test in Fix: in diffMessageContainers = setOf(
"proto.DiffCatalog",
"com.hedera.hapi.block.stream.output.MapChangeValue",
"com.hedera.hapi.block.stream.output.SingletonUpdateChange",
)The generated 2. No documentationNothing in
3. The PR description is staleIt still says every generated model gets the diff methods and describes only the global tracking switch. Please update it to cover:
Minor
Outside this PR: design doc updates
|
|
I like the idea of just getting the list of models that need diffs from: |
Signed-off-by: Derek Riley <derek.riley@swirldslabs.com>
|
Round 2 is addressed in 8d2163f: 1. Coverage of real state types: The integration suite now uses the real catalog. It generates from the HAPI 0.78.1 protos ( The previous upgrade attempts failed because newer protos import with paths relative to the proto root, which needs that root as the source directory. The v0.55.0 javadoc workarounds are gone. The suite now runs 163,990 tests, all passing (about 115k before). 2. Documentation: There's a new "Partial Updates (Diffs)" section in
3. PR description: Rewritten. It now covers root selection, Two benchmark results are worth flagging:
Minor:
Design doc: I'll update apply rule 3 (a oneof switch must clear the old alternative), the Scope section (roots are opt-in through the catalogs), and the CN pseudo-code ( Yeah I liked deriving roots from a catalog so we don't need to remember to add new models to PBJ compilation on the Hiero side when a new value is added to state. I went with |
|
Mostly good, I think there are 2 points still worth addressing: 1) Minor but sounds like a good ideaHAPI source change: switching the integration tests from a hedera-protobufs v0.55.0 git clone to the hedera-protobuf-java-api:0.78.1 artifact is a sensible upgrade. It removes the v0.55 javadoc workarounds and the "can't upgrade past v0.57" problem. The 4 GB forked compileJava is reasonable for about 4,400 generated sources. 2) Remaining issue: StateValue makes small, ubiquitous types into tracked rootsStateValue lists the value type of every state, and several of those are small types used everywhere: proto.AccountID (the aliases map), proto.Timestamp, ScheduleID, NodeId, EntityNumber and ProtoBytes. I checked the generated code, and AccountID and Timestamp are now full diff roots. The PR description and docs recommend StateValue together with generateCopyBuilderTracking = true. In that configuration every AccountID and Timestamp instance in the node carries the tracking fields: one object reference plus one long. That includes the ones nested inside transactions, receipts and accounts, because tracking is per class. Avoiding exactly The PR's own benchmark shows tracking buys almost nothing for small types: SMALL is 1.2 ns tracked vs 2.1 ns for a full comparison. Suggested fix: generate tracking only for roots with more than a few mask entries, for example more than 8. Small roots keep the diff APIs and use the full comparison. This is the design doc's own idea ("PBJ can omit the mask for messages with only a few fields… decided by field count, not a per-type list"), and it needs no per-type annotations in Hiero. The Either way, one docs sentence is currently misleading for Hiero and should be reworded: "Tracking adds per-instance state to the root only. Nested types such as IDs and timestamps don't change." |
Signed-off-by: Derek Riley <derek.riley@swirldslabs.com>
|
2. Small roots carrying tracking: Agreed, and fixed with the size limit you suggested:
With I replaced the misleading docs sentence with a description of the rule. The docs now also say that tracking is per class and adds state to every instance of a tracked root, including nested ones. The A new compiler test covers the limit: exactly 8 entries vs 9, a oneof group counting as one entry, a small annotated root still being tracked, and a large root opted out with Both full builds pass: |
anthony-swirldslabs
left a comment
There was a problem hiding this comment.
LGTM. Thank you!
andrewb1269
left a comment
There was a problem hiding this comment.
Approve pbj-integration-tests/build.gradle.kts
Description:
Adds generated protobuf diff and replay support for state updates. A producer sends only the top-level fields that changed, plus explicit clears, and a consumer rebuilds the complete value from either models or encoded bytes.
Nested messages, repeated fields and maps are replaced as whole fields. This PR intentionally targets
release/0.15.Related issue(s):
Fixes #
Model selection
Diff support is opt-in. Compiler options choose which messages are diff roots and get the diff APIs, so the rest of the model library doesn't carry extra generated code or tracking fields.
Select specific models
pbj { diffMessages = setOf("proto.Account", "proto.Token") generateCopyBuilderTracking = true }Names are fully qualified protobuf message names, not generated Java names.
Select models through a catalog
pbj { diffMessageContainers = setOf("com.hedera.hapi.platform.state.StateValue") generateCopyBuilderTracking = true }diffMessageContainersmakes every message type referenced by the container's direct fields a root, including oneof members and map values. The container itself is not selected.Hiero uses
StateValue, the catalog of values actually stored in the virtual map, so every state type is a root, including types added later. This is preferred over the block-stream output catalogs (MapChangeValue,SingletonUpdateChange), which don't necessarily match what is stored at the virtual map layer.The two selection sets are additive and default to empty. An unknown name fails compilation.
Generation is scoped as follows:
DiffCodec, and optional copy-builder tracking (only on roots with more than 8 mask entries, unless annotated).$equalsForDiff(). They are found automatically through message fields, lists, maps and oneofs.Message comments are also supported:
// <<<pbj.diff = true>>>makes the message a root without turning on tracking.// <<<pbj.copy_builder_tracking = true>>>makes the message a root and turns on tracking, whatever its size.// <<<pbj.copy_builder_tracking = false>>>turns tracking off for a root, even when the project flag is on. On its own it doesn't select a root.Generated model APIs
Each root gets these methods (
Modelstands for the generated class name):diffreturns a mask of the changed fields. Every candidate field is checked by value.partialkeeps the masked fields and resets the others to their defaults.clearedFieldslists the fields to remove, including a previous oneof alternative. The overload that takespriorproduces the exact list; the other clears every inactive oneof alternative.worthPartialchecks whether the omitted fields save at leastminSavingbytes after the envelope overhead. It measures cheaper fields first and stops as soon as the threshold is reached.applyPartialrebuilds the complete model. It rejects invalid clears and fields that are both replaced and cleared.Each mask bit stands for one ordinary field or one oneof group, in declaration order. Models with more than 64 such entries use
long[]masks instead oflong. Clear lists are always sorted protobuf field numbers.Optional copy-builder tracking
generateCopyBuilderTrackingdefaults to false. When it is on, it applies only to roots with more than 8 mask entries (LookupHelper.MAX_UNTRACKED_DIFF_ENTRIES).SMALLbenchmark: 1.2 ns tracked vs 2.1 ns full comparison).pbj.copy_builder_tracking = trueor= falsecomment always wins over the size limit.Tracking is a property of the generated class, so every instance of a tracked root carries an origin reference and a candidate mask, including instances nested inside other messages. The limit keeps small, widely used types out of tracking. With
StateValue,AccountID,Timestamp,ScheduleID,NodeId,EntityNumberandProtoBytesare roots but untracked; 11 of the 56 HAPI roots are tracked, includingAccount,Token,Topic,ScheduleandNode. Hiero needs no per-type annotations.A tracked model implements
CopyBuilderTracked<Model>, with accessors for its origin, its candidate fields and its mask, plus$untracked(). A chain of copy builders keeps the original untracked instance and accumulates the fields touched by setters.The fast path applies only when that origin is exactly
prior; otherwisediffcompares every field. Candidates are always checked by value, so unchanged or restored values never appear in the final diff.Tracking is excluded from equality, hashing, text output and codecs. Turning tracking off doesn't change diff results; it only makes
diffslower. Wide masks are copied defensively, and ordinary builders don't allocate candidate arrays.Discoverable codec support
Each root's protobuf codec implements
DiffCodec<T>. Callers holding only a registeredCodec<T>can discover diff support without referencing generated model methods.DiffCodecprovidescanDiff,diff,partial,clearedFields,worthPartialandapplyPartial. Masks are opaqueObjects, so the same calls work forlongandlong[]masks.canDiff(value)returnsfalse, without throwing, fornulland for values with top-level unknown fields. Callers should check both values and send a full value if either check fails or the codec has no diff support. The direct model methods throwUnsupportedOperationExceptionfor top-level unknown fields.Without tracking,
diffcompares every field. Without the diff APIs, the producer has to send full values. Consumers can still replay incoming partials through validated bytes without the diff APIs.Producers that must emit identical update bytes need the same root selection and emission thresholds.
Comparison and byte replay
Roots and the messages nested in them get
$equalsForDiff(), which treats values as equal only when they encode identically, including raw NaN payloads and oneof presence. Ordinaryequals()is unchanged. Singularfloat/doublefields compare raw bits, so an unchanged NaN doesn't count as a change.Imported models without
$equalsForDiff()are compared conservatively: anything that isn't a generated message, a collection or a known scalar counts as changed.PartialApplierreplays updates on encoded bytes without needing the schema:splicereplaces complete field runs, removes cleared fields and leaves all other bytes untouched. Inputs must have ascending field order and canonical varints, as PBJ produces. Values from other encoders, such as client-submitted transactions, must first be normalized through their PBJ codec. Schema-specific validation remains the caller's responsibility.Switching or unsetting a oneof must explicitly clear the previous alternative. A schema-free splice can't tell that two field numbers belong to the same oneof, and
applyPartialenforces the rule. Producers should useclearedFields(prior, next, mask).Breaking change
Generated enum
toProtoOrdinal(null)now returns protobuf ordinal 0 instead of throwingIllegalArgumentException. This is needed for unset enums, but callers that relied on the exception will see different behaviour.Integration test HAPI version
pbj-integration-testsnow generates from the HAPI protos incom.hedera.hashgraph:hedera-protobuf-java-api:0.78.1, instead of ahedera-protobufsv0.55.0 git clone.hedera-protobufsstops at v0.77.3 and doesn't containStateValue. The proto sources now live in hiero-consensus-node.StateValueas its diff catalog, the same configuration as Hiero. That makes 56 HAPI state types roots, plus 12 local fixtures, all covered by the generated round-trip tests.mirror/**andsdk/**are excluded, the same as in the CN build.compileJavais forked with a 4 GB heap, because the full HAPI generates about 4,400 sources.Testing
Coverage includes:
testPartialRoundTrip: linear pairs from the test arguments, from the default value to each value, and from each value back to the default. Each pair checksspliceagainsttoBytes(next),applyPartial, bothclearedFieldsoverloads,worthPartialagainst the actual saving at several thresholds, and parsing the partial with stock protobuf.testCopyBuilderTracking(tracked roots only): deterministic checks per setter for the change bit, unchanged values, origin across a chain, and$untracked()equality, hash and bytes.= false.worthPartialstopping early.Objects,Arrays,Long,DiffSupport,PartialApplierandCopyBuilderTracked, plus fields namedbuilder. Generated diff code fully qualifies runtime and JDK types.Local validation:
pbj-integration-tests./gradlew build: 163,990 tests and the fuzz test pass, with no failures or skips (up from about 115k with HAPI v0.55.0).pbj-core./gradlew build qualityGatepasses.Benchmarks
PartialDiffBench, JMH 1.37, JDK 25.0.2 (Temurin), Apple M4 Max, 2 forks × (3 × 1 s warmup + 5 × 1 s measurement),-prof gc. Each cell shows average time ± 99.9% error and allocation per operation (gc.alloc.rate.norm). Setup and serialization are excluded.Model shapes:
SMALL:CacheableAccountIDwith one changed scalar and a bytes payload of the given size.EVERYTHING:Everythingwith one changed scalar and a bytes payload.WIDE:WideTrackedModel, more than 64 fields, so it uses along[]mask. Onlyvalue1and a string payload are set.WIDE_POPULATED:WIDEwith all 70valueNfields populated.Observations:
priorandnext, which is the normal copy-builder case. Wide models allocate only thelong[]mask: 32 B tracked, 64 B full comparison.worthPartialdoesn't allocate and usually stops early. The exception isWIDEwith a large string: the omitted string must be measured to reach the threshold, so the cost grows with its UTF-8 length (1.9 µs at 16 KiB). When cheaper omitted fields cover the threshold, as inWIDE_POPULATED, it stays at about 13 ns.splicetime and allocation depend on the output size and the number of field runs.Checklist
docs/usage-guide.md.