Skip to content

Proved ranked read on a never-populated timeRange bucket fails proof generation instead of proving empty #4592

Description

@PastaPastaPasta

Summary

On v4.2-dev at dev.8 (post-#4578), a proved ranked read pinned to a timeRange bucket that no document has ever landed in fails proof generation instead of proving an empty ranking:

query: storage: grovedb: invalid path: a single-path axis read must produce exactly one axis descent at the queried path, but the walk produced 0 — the path does not name an indexed tree carrying that axis

The same query on a bucket that holds ≥1 document serves and verifies correctly. The unproved path is not affected in the same way (it returns an empty page).

For a trending contract this is the steady state at the start of every window: the first read of "top posts today" after 00:00 UTC (before anyone has liked anything) is exactly a never-populated bucket, and it surfaces as an internal error rather than an empty leaderboard.

Reproduction

Contract (registers on dev.8):

"like": {
  "type": "object", "indexOnly": true, "documentsMutable": false, "canBeDeleted": true,
  "properties": { "postId": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "contentMediaType": "application/x.dash.dpp.identifier", "position": 0 } },
  "required": ["$createdAt", "postId"],
  "indices": [
    { "name": "byDayPost", "properties": [{ "$createdAt": "asc" }, { "postId": "asc" }], "terminal": "$ownerId",
      "countable": "countable", "rangeCountable": true, "rankedCountable": true,
      "timeRange": { "on": "$createdAt", "range": 86400, "step": 86400 } }
  ]
}

Query (wasm-sdk / js-evo-sdk):

await sdk.documents.ranked({
  dataContractId, documentTypeName: 'like',
  groupBy: 'postId', aggregate: { type: 'count' }, direction: 'desc', limit: 5,
  timeRange: [{ field: '$createdAt', selector: 'newest' }],
});
  1. Register the contract; write nothing. Run the query → the error above (server-side, Internal error), on every node. Same for selector: 'oldest' and for byStart naming any bucket with no entries.
  2. Write one like (so the newest bucket has one entry). Run the same query → [[<postId>, 1]], proved and verified. ✔
  3. The non-bucketed twin (byPost without timeRange, same doctype) on an empty index returns an empty page and proves it — the ranking_an_empty_index_reads_empty_and_proves_empty case. The bucketed index is the odd one out.

Observed live on the moutai devnet (13 nodes on 4.2.0-dev.8) against contract DNNibJtgEEkQkLfDZXh9xkbfdVHWu6CtJcgcpicMAuHZ — both the like axes and a separate beat doctype with a 2-grid $createdAt (k=1 daily + k=4 rolling) behave identically.

Where it comes from

grovedb operations/proof/generate.rs — the guard added for single-path axis reads:

A single-path axis read has exactly one answer — the axis descent at the queried path. The generic walk cannot produce one when the target is missing or is not an indexed tree; it returns Ok with an ordinary (or empty) layer instead … Fail generation here instead.

The ranked prover (execute_top_k_with_proof_branch) builds PathQuery::new_axis_top_k(path, …) where path includes the resolved bucket start as a level segment (path.rs: "the pinned value under a bucketed level is the resolved bucket start"). Buckets are created lazily by the first write that lands in them (add_indices_for_top_index_level… fans out time_range_index_keys and batch_insert_empty_tree_if_not_exists), so a never-written bucket has no subtree at that path — the walk produces 0 descents and the guard refuses.

For a non-bucketed ranked index the terminal property-name tree exists from contract registration (that's what makes the empty-index case provable), which is why only the bucketed shape trips this.

Why it matters / expected behaviour

An absent bucket is a perfectly ordinary, provable statement ("no documents in this window") — the same way an absent pinned prefix would be. Expected: the ranked prover returns a proof of emptiness for a bucket path that does not exist (a Merk absence proof of the bucket key at the grid level, which the verifier already re-derives from the signed time), the way #4574's doc promises for byStart: "an empty (or not-yet-started) window is a provable empty answer".

Two candidate fixes, from smallest to most principled:

  1. In the ranked prover, when the resolved bucket path is absent, route through the branched axis form with one branch (the guard explicitly excludes branched reads: "an absent branch key legitimately produces no descent, and its absence is what the branching-level Merk proof authenticates"), so absence is proved at the bucket level.
  2. Or have the bucket-level pin be proved as a key absence in the generic walk and let the verifier accept a zero-descent axis read whose enclosing layer proves the key absent.

Client-side we currently map this specific error string to "empty ranking", which is fine for a devnet but is not something a verifier-trusting client should have to do.

Environment

  • platform v4.2.0-dev.8 (7298474), grovedb 97250247
  • moutai devnet, 13/13 masternodes on dev.8; also reproduced against the JS SDK 4.2.0-dev.8 from node

Related: #4578 (ranked below timeRange), #4574 (byStart), the ranking_an_empty_index_reads_empty_and_proves_empty test in rs-drive/src/query/drive_document_ranked_query/tests.rs.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions