[Bug]: Every streamed reply update re-parses the full reply JSON just to read its timestamps#25157
Area
apps/server
Steps to reproduce
- Ask any agent for a long reply (100 KB or more).
- Each streamed update writes
message.updatedandturn-item.updatedthroughProjectionStoreV2.apply.
Expected behavior
The projection write encodes the payload once and indexes it by the timestamps it already has.
Actual behavior
In ProjectionStore.ts, the message.updated and turn-item.updated cases encode the payload to JSON, then call parseEncodedPayload(payloadJson), which is a full JSON.parse of the entire streamed text. They do this only to read createdAt/updatedAt back via stringField.
Main cause: each update round-trips the whole reply through JSON.parse on the event loop, inside the write transaction. The cost grows with reply length and is paid on every streamed chunk. The timestamps are already on event.payload, and encoding them directly with Schema.DateTimeUtcFromString produces the same strings.
Impact
This adds server CPU per streamed chunk while the write transaction is held. The per-update cost grows with the length of the reply.
Measurements. This uses file-backed SQLite (SqlitePersistence.layerFromPath) on Linux with Node 26.12.1 at upstream 3d0049993 (re-checked on 1a13127c1). Each figure is the median of 200 message.updated + turn-item.updated writes of the same reply. Each column is two separate runs.
| Reply size | main | timestamps encoded directly |
|---|---|---|
| 10 KB | 0.92 / 0.92 ms | 0.38 / 0.44 ms |
| 100 KB | 1.16 / 1.15 ms | 0.96 / 0.99 ms |
| 500 KB | 4.43 / 4.38 ms | 3.40 / 3.43 ms |
That saves roughly 0.2–1 ms per streamed update. At 10 KB the saving is larger than JSON.parse alone accounts for, so treat that row with some caution.
Related, smaller (could be split out)
subscribeOrchestrationV2Thread in ws.ts calls projectDomainEventForWire on events from streamStoredEventsFrom. That stream is already projected by EventSink's bounded path (project: projectDomainEventForWire). The second pass is cheap for assistant text, about 0.16 µs measured. It is not idempotent for multi-byte text past MAX_DETAIL_STRING_BYTES, though. A 12,000×漢 subagent prompt projects to 10,955 characters once and 10,956 twice, which adds a second newline before "… output truncated for transport". As a result, live subscribers can see slightly different text than the snapshot shows.
Version or commit
3d0049993 (re-checked on 1a13127c1)
Fix
A focused PR follows that encodes the two timestamps directly. The ws.ts note above is left out of it.
Written by Opus 5.5 (high) on behalf of Emanuel.