Skip to content

bug: a U64 parameter above the I64 range is refused #918

Closed

Description

@azimafroozeh

Actual result

A query comparing a U64 property with a U64 parameter bound to 18446744073709551615 fails with params rejected: param 'v': value 18446744073709551615 exceeds current engine range for U64 (max 9223372036854775807).

Expected result

The parameter binds and the query returns the one row whose size is 18446744073709551615.

GQT reproduction or limitation

# issue: none
# red_on: dfc73f46

--- runner
timeout_ms: 10000
environments:
  - target: omnigraph-engine
    storage: local-filesystem

--- schema
node Item {
    size: U64
}

--- seed
{"type": "Item", "data": {"size": 18446744073709551615}}

--- query
query by_size($v: U64) {
    match {
        $i: Item
        $i.size = $v
    }
    return { $i.size as size }
}

--- params
{"v": 18446744073709551615}

--- expect unordered
{"size": 18446744073709551615}

--- expect shape
size: U64?

Version

omnigraph 0.13.0, main @ dfc73f4, built from source

Execution path

Embedded Rust API

Environment

macOS on arm64, local filesystem storage

Reproduction command and output

Additional logs

Activity

  1. self-assigned this
    on Oct 9, 2026
  2. added
    acceptedTriaged and validated; open for a PR
    P-highHigh priority
    and removed
    needs-triageAwaiting maintainer triage
    on Oct 9, 2026
  3. ragnorc commented on Oct 17, 2026

    @ragnorc
    Contributor

    Reproduction and fix assessment

    Summary

    A query parameter declared U64 cannot hold a value above i64::MAX (9223372036854775807). The engine refuses the value when it binds the parameters, before planning. The storage, the comparison typing and the execution path all support the full U64 range. Only the parameter carrier is too narrow. The carrier is Literal::Integer(i64), which all integer parameters share.

    Verdict: reproduced

    • Main: 0b5e17fa23d36483178240c28f39c5d3448394a1 (latest main, checked again before this comment). I used a clean git worktree at that commit.
    • Environment: Linux x86_64 cloud VM, rustc 1.97.1, --locked, debug test profile. The target is omnigraph-engine on local-filesystem, as the case declares.
    • Differences from the report: the OS and CPU are different (Linux x86_64, not macOS arm64). The main commit is newer (0b5e17fa, not dfc73f46). The case text is the same. I had to change one thing to run it, described in the next paragraph.

    The corpus runner refuses the case as invalid_case if the file name is issue_918_*.gqt and the header is # issue: none. That refusal does not prove anything. So I ran the reporter's text byte for byte under a neutral name, and a copy with # issue: 918 under the issue name:

    $ cargo test -p omnigraph-gqt --locked --test gq_logic_tests u64_param_above_i64
    ---- case::issue_918_u64_param_above_i64.gqt ----
    "step 1 (query): params rejected: param 'v': value 18446744073709551615 exceeds current engine range for U64 (max 9223372036854775807); ..."
    ---- case::reporter_u64_param_above_i64.gqt ----
    "step 1 (query): params rejected: param 'v': value 18446744073709551615 exceeds current engine range for U64 (max 9223372036854775807); ..."
    test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 376 filtered out
    

    The seed row with size = 18446744073709551615 loads with no error. The refusal comes at step 1, when the parameters bind. This is the failing test on latest main. A fix can use the same case as its regression test (with # issue: 918).

    To find the edges of the problem, I ran these probe cases on the same schema (node Item { key: String @key size: U64 }):

    Probe Result on 0b5e17fa
    Parameter 9223372036854775807 (i64::MAX), control passes
    Parameter 9223372036854775808 (i64::MAX + 1) params rejected: ... exceeds current engine range for U64
    Parameter as the decimal string "18446744073709551615" same refusal
    Seed u64::MAX, read it back with no parameter passes, and the value is exact
    insert Item { key: "e", size: $v } with $v = u64::MAX step 1 (mutate): params rejected: ...
    Query-text literal $i.size = 18446744073709551615 invalid_case: ... parse error: invalid integer: number too large to fit in target type

    The embedded Rust API has the same limit. impl ToParam for u64 refuses values above i64::MAX, and a unit test pins that refusal. query_input::tests::to_param_rejects_unsigned_values_outside_engine_range passes on main, so a fix must reverse that test.

    The decimal-string result is important. The stored-query catalog tells clients to send 64-bit integers as JSON strings (ParamKind::BigInt, omnigraph-api-types/src/lib.rs L1538-L1543), so that it does not lose precision past 2^53. The string form gets the same refusal, so a client has no way to send the upper half of the U64 range.

    Root cause

    1. The literal carrier is Literal::Integer(i64) (query/ast.rs L991-L1001). Every integer value goes through it: parameters, query literals and mutation values.
    2. The U64 binding branch parses a u64 correctly, then narrows it to i64 with i64::try_from (query_input.rs L424-L445). The narrowing gives the reported message. ToParam for u64 and usize narrow in the same way (L158-L182).
    3. The server, the CLI and GQT all call json_params_to_param_map with JsonParamMode::Standard (omnigraph-server/src/handlers.rs L3403, omnigraph-cli/src/helpers.rs L970, omnigraph-gqt-core/src/lib.rs L3012). So all transports have this defect. No production caller uses JsonParamMode::JavaScript.
    4. The code after the binding already handles the full range. typed_scalar builds ScalarValue::UInt64 from the declared type with u64::try_from (engine/typed_value.rs L234-L237). Scan pushdown uses the same function (engine/scan.rs L1158-L1165). The comparison typing reasons in i128 and has an exact domain for mixed signed/U64 comparisons (ir/coerce.rs L94-L107, L312-L346). The only defect is the width of the payload.
    5. The same carrier gives the related failures in the probes. The mutation builder narrows Literal::Integer to UInt64 (exec/mutation.rs L67-L72, L252-L262). The parser reads integer literals as i64 (query/parser.rs L1538-L1544).

    The user docs say "Parameters use their declared type, regardless of the supplied value" (docs/user/queries/index.md L66-L73). The i64 limit on U64 parameters is not documented. The message "current engine range" shows that the limit was deliberate, but the declared type does not show it.

    Recommended fix: one integer carrier for every declared integer domain

    Make the carrier wide enough for every declared integer type. Narrow the value only at a typed boundary, and only from the declared type (invariant 9).

    1. Change Literal::Integer(i64) to Literal::Integer(i128). This holds I32, I64, U32 and U64 with one spelling per value, so the by-value Eq/Hash in ast.rs stays sound. It also matches the compiler's existing exact domain (literal_round_trips uses i128, and exact_integer is Decimal128(38, 0)).
    2. In the U64 branch of json_value_to_literal_typed, return Literal::Integer(i128::from(value)). Remove the "current engine range" errors. Make ToParam for u64, usize and isize lossless.
    3. Narrow only in typed_scalar (engine) and literal_to_typed_array (mutations), with iN::try_from/uN::try_from from the declared type. These places already exist and already refuse out-of-range values.
    4. Keep the saved bound-plan format readable. LiteralMirror::Integer(i64) is serialized into replay envelopes (omnigraph-planner/src/mirror.rs L1297-L1308). Do not put a bare i128 in this adjacently tagged enum. The pinned serde 1.0.228 buffered Content has no I128 arm, so decoding can fail when serde buffers the content. Keep the integer arm for values in the i64 range, so existing saved plans still decode. Add an arm that holds wider values as a decimal string, or increase the envelope version. Add a round-trip test at u64::MAX and i64::MIN in omnigraph-planner/tests/integration/bound_plan.rs.
    5. The frozen v1 engine matches Literal exhaustively (reference-engine/src/projection.rs L157-L177, query.rs L4478-L4489, L4702-L4718). Every carrier change in this section needs a small v1 edit, an update to the pin in tests/frozen.rs, and a reviewer. In v1, narrow with i64::try_from and refuse other values. Do not add v1 behavior.
    6. Query-text literals (the last probe) use the same carrier, but they also need a language decision: what type does 18446744073709551615 get? A rule that agrees with the current narrowing is "I64 if the value fits, else U64; refuse values outside [i64::MIN, u64::MAX]". You can do this in the same change with its own case, or track it separately. It is not part of this issue as reported.

    Regression evidence for the fix:

    • Add crates/omnigraph-gqt/cases/issue_918_u64_param_above_i64.gqt with # issue: 918. Use the reporter's step, and add steps for these items:
      • 2^63 (the first value that fails now)
      • the decimal-string form
      • insert, and update ... where, with a U64 parameter
      • a [U64] list parameter with in/contains
      • a U64 parameter compared with an I64 property (the exact_integer cast)
      • an indexed U64 property (scan pushdown)
      • refusal of 18446744073709551616 and -1
    • Reverse to_param_rejects_unsigned_values_outside_engine_range. Its name must contain issue_918 for the Fix Regression Gate.
    • Add a changelog.d/<slug>.fixed.md note. Add one sentence to the parameter section of the query guide.
    • The change does not touch storage, so the DST cost golden does not change. ParamKind::BigInt is already a string on the wire, so openapi.json does not change either.

    Alternatives and tradeoffs

    Option Correctness Ongoing liability
    A. Widen the one carrier to i128 (recommended) Exact for all declared integer types. Narrowing happens in one place per typed boundary. About 37 files match Literal::Integer(, and most need only a type change. v1 needs an edit and a pin update. The mirror needs a compatible wire form. Literal can grow from 16-byte alignment, which does not matter because there are few literals per query.
    B. Add Literal::Unsigned(u64) Exact. One value gets two spellings (Integer(5) and Unsigned(5)). By-value Eq/Hash and plan identity then need a canonical-form rule that every producer must obey. Every consumer must handle two arms. The liability grows with each new integer type.
    C. Bind parameters as typed Arrow ScalarValues from the declared type The strongest typing. The largest change: the public params!/ToParam API, the replay envelope, v1 and GQT. Use this as a long-term direction if more numeric types come (for example Decimal or I128), not as a bug fix.
    D. Carry large U64 values as Float or String Wrong. Float rounds 2^64 - 1 to 2^64. String carries query meaning in strings, which the invariants' deny-list forbids. Do not use.

    Option A gives a small amount of efficiency (enum size) for clarity and robustness. That is the correct priority here, because the cost is per literal, not per row.

    Lance upstream assessment

    This is not a Lance defect, and no Lance change will fix it or affect it.

    • Lance stores UInt64 natively (Lance data types guide). The probes show this: a u64::MAX row loads and reads back exactly.
    • Lance's SQL-string filter planner once refused UInt64 literals above i64::MAX (lance#7910). lance#8178 fixed it on 2026-08-03. The pinned Lance 11.0.0 contains that fix: GitHub compare shows v11.0.0 307 commits ahead of the fix commit and 0 behind, and lance-datafusion-11.0.0/src/planner.rs L491-L493 parses i64 and then u64. Also, the OmniGraph v2 query pushdown builds typed DataFusion expressions (filter_expr), not SQL strings.
    • lance#8807 (open) is about integers above i64::MAX in Lance JSON-typed columns. OmniGraph U64 properties are Arrow UInt64 columns, so that issue does not apply.
    • The release notes for v12.0.0, v13.0.0 (latest stable) and v14.0.0-beta.19 contain no related item. A search of open Lance PRs for unsigned/UInt64 filter or literal work found nothing related. A Lance version bump does not change the recommendation.

    Related work

    Limitations

    • I ran on Linux x86_64, not on the reporter's macOS arm64. Nothing in the failing path depends on the platform.
    • I ran only the omnigraph-engine local-filesystem target. I did not run the HTTP server or the CLI end to end. Both call the same Standard-mode function (the links are in the root-cause section).
    • I did not write a prototype of the fix. No after-fix evidence exists yet.
  4. lab1207 commented on Oct 20, 2026

    @lab1207
    Contributor

    I have taken this one — a pull request is open: #1102

    It implements option A from the triage note: the literal carrier is widened to i128 and the value is narrowed at a typed boundary, from the declared type. The bind path no longer refuses an unsigned value above i64::MAX; the engine still refuses it where the target type cannot hold it, naming the value and the range.

    Two parts of the change to look at closely:

    • the saved-plan wire form gains one arm (WideInteger, the decimal text), because the pinned serde buffers carry no i128. The integer arm keeps its form and its meaning, so existing saved plans still decode;
    • the frozen v1 engine is touched in two places — narrow and refuse, no new v1 behaviour — and its pinned hashes are updated in the same pull request, as the note asks.

    I did not bump BOUND_PLAN_VERSION; if you prefer the bump it is a one-line change plus a note about regenerating saved plans. Query-text integer literals are unchanged, since that is a language decision rather than part of this issue.

    Coverage: a compiler unit test at the bind, a planner test that a saved plan carrying u64::MAX round-trips byte-exactly, and a GQT case in the repository's own format that reads and writes the value 18446744073709551615 end to end.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P-highHigh priorityacceptedTriaged and validated; open for a PRbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions