Skip to content

Bolt: inbound temporal PackStream structs (Date/Time/DateTime/…) not decoded → datetime query parameters silently dropped #4905

Closed

Description

@lvca

Summary

ArcadeDB's Bolt plugin does not decode incoming temporal PackStream structures (Date, Time, LocalTime, LocalDateTime, DateTime, DateTimeZoneId, Duration). When a Bolt client sends a native temporal value as a query parameter, PackStreamReader.readStructure() returns it as an opaque StructureValue (raw signature + fields) instead of a java.time value. The parameter therefore reaches the query engine as a meaningless object and is effectively dropped: a datetime-typed parameter can never bind, match, or be stored correctly.

The output/read direction works (properties are streamed back to the client, BoltStructureMapper renders temporals as ISO-8601 strings), so the gap is input-only and easy to miss.

Credit

Diagnosed by @agc-63 (Angel Garcia) while running the community ArcadeDB driver for getzep/graphiti end-to-end against a real ArcadeDB 26.7.1 instance. See the detailed write-up on getzep/graphiti#1310:
getzep/graphiti#1310 (comment)

He confirmed it by reading PackStreamReader.java directly and worked around it at the neo4j Python driver level (monkeypatching dehydrate_datetime to emit ISO strings). The proper fix belongs here in ArcadeDB.

Root cause

bolt/src/main/java/com/arcadedb/bolt/packstream/PackStreamReader.java — readStructure() collects the struct fields and returns a generic StructureValue; there is no branch that maps the Bolt temporal signatures back to java.time types. BoltStructureMapper only implements the reverse (Java → Bolt, as strings) direction.

Note ArcadeDB negotiates Bolt v4.4 max (BoltNetworkExecutor.SUPPORTED_VERSIONS = { v4.4, v4.0, v3.0 }), so clients use the legacy (pre-5.0) DateTime/DateTimeZoneId encoding where the seconds field is the local epoch-second (offset folded in), not the UTC epoch-second. The decoder must use the legacy semantics (and may defensively also handle the 5.0 UTC signatures 0x49/0x69).

The decode must happen on the parameter path, not in the generic readStructure(): the top-level Bolt ROUTE message signature (0x66) collides with the legacy DateTimeZoneId signature (0x66, 'f'), and message envelopes flow through the same reader. Temporal decoding is therefore applied when hydrating the RUN parameters map (where a 0x66 struct is unambiguously a temporal), leaving message parsing untouched.

Scope: this is 1 of 2 fixes for full parity

Decoding the wire is necessary but not sufficient for a query like WHERE e.valid_at <= $reference_time to work like Neo4j/FalkorDB. The Cypher engine also needs a follow-up so a native temporal parameter participates in temporal comparison:

  • ast/ParameterExpression.evaluate() returns the parameter value raw (no temporal normalization).
  • ast/ComparisonExpression only takes the temporal-comparison branch when both operands are CypherTemporalValue; there is no java.util.Date/java.time Comparable fallback.
  • ast/PropertyAccessExpression.convertFromStorage() wraps LocalDate/LocalDateTime (and parseable strings) into CypherTemporalValue, but not java.util.Date or ZonedDateTime.

Follow-up (separate change): normalize java.time/java.util.Date parameters into CypherTemporalValue for comparison, and extend convertFromStorage() to wrap java.util.Date/ZonedDateTime. That closes the loop so no client-side datetime(...) wrapping is needed.

Fix (this issue)

Decode inbound Bolt temporal structs into java.time types on the parameter path, mirroring the signatures BoltStructureMapper already emits outbound:

Bolt struct Signature Decoded to
Date 0x44 'D' LocalDate
Time 0x54 'T' OffsetTime
LocalTime 0x74 't' LocalTime
LocalDateTime 0x64 'd' LocalDateTime
DateTime (legacy) 0x46 'F' OffsetDateTime
DateTimeZoneId (legacy) 0x66 'f' ZonedDateTime
DateTime (UTC, 5.0+) 0x49 'I' OffsetDateTime
DateTimeZoneId (UTC, 5.0+) 0x69 'i' ZonedDateTime

These map onto ArcadeDB's DATETIME/DATE native types (Type.TYPES_BY_USERTYPE), so they are storable and comparable once the engine follow-up lands.

Test plan

  • Bolt-module unit regression test: round-trip each temporal struct through PackStreamWriter → PackStreamReader → decoder and assert the correct java.time value, plus nested-in-parameter-map hydration.
  • Follow-up: engine-level test that WHERE valid_at <= $param matches with a native temporal parameter.

Activity

  1. self-assigned this
    on Jul 3, 2026
  2. added theissue type on Jul 3, 2026
  3. added this to the 26.8.1 milestone on Jul 3, 2026
  4. lvca commented on Jul 3, 2026

    @lvca
    MemberAuthor

    Both parts implemented under this issue (branch pending review, not yet committed).

    Part 1 - Bolt wire decode (bolt module)

    • BoltStructureMapper.fromPackStreamValue() - recursively decodes inbound temporal PackStream structs into java.time values, handling the legacy (Bolt <=4.4) Date/Time/LocalTime/LocalDateTime/DateTime/DateTimeZoneId encoding plus the 5.0 UTC signatures defensively.
    • BoltMessage.parseRun() - hydrates the RUN parameters map through it. Done on the parameter path (not the generic readStructure()) to avoid the ROUTE message vs legacy DateTimeZoneId 0x66 signature collision.
    • Test: PackStreamTemporalInputTest (11 tests, wire round-trip per type + nested-in-parameter-map).

    Part 2 - engine temporal normalization (engine module) - needed so a native temporal parameter actually compares against a stored temporal without any datetime(...) wrapping:

    • TemporalUtil.fromCoreJavaType() - inverse of toCoreJavaType(); wraps native java.time/java.util.Date values into their CypherTemporalValue type (idempotent, non-temporals passed through).
    • ComparisonExpression - coerces both operands via fromCoreJavaType() before the temporal-comparison branch, so a native temporal parameter compares against a stored temporal instead of silently not matching. The storage/write path is untouched (it still receives raw java values), so SET serialization is unaffected.
    • PropertyAccessExpression.convertFromStorage() - generalized to wrap all native temporals (incl. java.util.Date, the default DATETIME storage type, and ZonedDateTime), not just LocalDate/LocalDateTime.
    • Test: OpenCypherTemporalParameterTest (3 tests) reproduces the graphiti WHERE e.valid_at <= $reference_time scenario with native ZonedDateTime/Date/OffsetDateTime/Instant parameters, over both native-datetime and ISO-string stored values.

    Verification: full bolt suite (223 tests) and full com.arcadedb.query.opencypher.** suite (6636 tests) pass, 0 failures/errors.

    Net effect for the getzep/graphiti driver: once on a build with these fixes, both the neo4j-driver dehydrate_datetime monkeypatch and the datetime(...) wrapping in graph_data_operations.py can be dropped - plain WHERE e.valid_at <= $reference_time works like the Neo4j/FalkorDB path.

  5. added 3 commits that reference this issue on Jul 3, 2026
  6. added 4 commits that reference this issue on Jul 3, 2026
  7. added 3 commits that reference this issue on Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions