JSON Merge vs JSON Patch

I am trying to serialize plr state in a way that is effective for quick restore if needed. We have other talks on this, but decided to go down the simpler path: call serialize, then use a JSON merge/patch for updates.

I took a script and had claude add serialization steps:

  ┌───────────────────────┬───────┬─────────┬───────┬────────┐
  │        Metric         │  Min  │   Max   │ Mean  │ Median │
  ├───────────────────────┼───────┼─────────┼───────┼────────┤
  │ serialize_all_state() │ 2.9ms │ 110ms   │ 3.9ms │ 3.1ms  │
  ├───────────────────────┼───────┼─────────┼───────┼────────┤
  │ json.dumps()          │ 3.5ms │ 5.0ms   │ 3.8ms │ 3.7ms  │
  ├───────────────────────┼───────┼─────────┼───────┼────────┤
  │ Full JSON size        │ 795KB │ 1,041KB │ 822KB │ 824KB  │
  ├───────────────────────┼───────┼─────────┼───────┼────────┤
  │ Merge Patch size      │ 2B    │ 220KB   │ 2.4KB │ 2B     │
  ├───────────────────────┼───────┼─────────┼───────┼────────┤
  │ Merge Patch time      │ 0.5ms │ 4.8ms   │ 0.7ms │ 0.7ms  │
  ├───────────────────────┼───────┼─────────┼───────┼────────┤
  │ JSON Patch size       │ 2B    │ 220KB   │ 3.0KB │ 2B     │
  ├───────────────────────┼───────┼─────────┼───────┼────────┤
  │ JSON Patch time       │ 51ms  │ 62ms    │ 52ms  │ 52ms   │
  └───────────────────────┴───────┴─────────┴───────┴────────┘

so far so good. JSON dump is ~4ms, of a ~1mb file. The fucked serialize_all_state() is mostly because of the gc. However, the JSON Patch is ~10x to ~100x slower than the JSON merge! What is the difference?

The key difference is that JSON merge does not handle nulls - and plr requires nulls in the TipTracker to correctly serialize! Here is a direct benchmarked difference:


  Benchmark comparison
  ┌─────────────┬──────────────────────────────┬────────────────────────────┐
  │   Metric    │       JSON Merge Patch       │         JSON Patch         │
  ├─────────────┼──────────────────────────────┼────────────────────────────┤
  │ Generation  │ 0.7ms median                 │ 52ms median                │
  ├─────────────┼──────────────────────────────┼────────────────────────────┤
  │ Application │ ~instant                     │ 16ms median, 200ms+ spikes │
  ├─────────────┼──────────────────────────────┼────────────────────────────┤
  │ Patch size  │ 2B median, 4KB typical       │ 2B median, 5KB typical     │
  ├─────────────┼──────────────────────────────┼────────────────────────────┤
  │ Correctness │ Corrupts on null transitions │ Correct                    │
  └─────────────┴──────────────────────────────┴────────────────────────────┘
  JSON Merge Patch is ~75x faster to generate and near-instant to apply, but requires the serializer fix above. JSON Patch is correct today but the Python jsonpatch library has
  surprising overhead — even applying a 2-byte no-op patch can spike to 200ms because it deep-traverses the full ~820KB state.

For the particular problem, it is pretty easy to explain:

LiquidHandler.serialize_state() at liquid_handler.py:186 serializes the head (pipetting channels). Each channel holds a TipTracker whose serialize() (tip_tracker.py:130)
  returns:

  // tip loaded on channel
  {"tip": {"maximal_volume": 60, ...}, "tip_state": {"has_tip": true, ...}, "pending_tip": null}

  // no tip on channel
  {"tip": null, "tip_state": null, "pending_tip": null}

  When tips are discarded, the merge patch for the transition is:
  {"tip": null, "tip_state": null}

  But a JSON Merge Patch consumer interprets null as "delete key", producing:
  {"pending_tip": null}  // tip and tip_state keys are gone entirely

  Instead of the correct:
  {"tip": null, "tip_state": null, "pending_tip": null}

  This happens on every discard_tips operation — the benchmark confirmed 10+ corruptions before we stopped counting. The arm_state field in LiquidHandler.serialize_state() has
  the same issue (transitions to null after drop_resource).

The key difference that would need to happen would be something like this:

#Omit keys instead of setting them to null:
  # tip_tracker.py:130
  def serialize(self) -> dict:
      result = {}
      if self._tip is not None:
          result["tip"] = self._tip.serialize()
          result["tip_state"] = self._tip.tracker.serialize()
      if self._pending_tip is not None:
          result["pending_tip"] = self._pending_tip.serialize()
      return result
# Same for liquid_handler.py:186 — omit head96_state and arm_state when None.

# This works because JSON Merge Patch correctly handles key presence/absence: a key appearing means "add/replace", a key disappearing (via null in patch) means "delete." The ambiguity only exists when null is a value.

Basically, if something is None, just don’t add it into the serialization. This allows you to use the 75x faster JSON Merge. If you want to store state, you’d just heartbeat occasionally (full merges) while having an append-log of each change over time. This is a breaking change to the way serialization works - the serialization format is different - is that reasonable?

Thoughts?

Keoni

1 Like

speed is good

I had claude implement an initial version RFC 7386 JSON Merge Patch serialization with compact format by rickwierenga · Pull Request #885 · PyLabRobot/pylabrobot · GitHub

thanks for the investigation and suggestion

this will also be useful for the visualizer as we continue to expand it

for reference:

cleaned it up: RFC 7386 JSON Merge Patch serialization with compact format by rickwierenga · Pull Request #885 · PyLabRobot/pylabrobot · GitHub

@koeng does this work for you?

I can test later!

1 Like