Do not return duplicate token ids from the api server - #2101
Conversation
There was a problem hiding this comment.
The DISTINCT/count(DISTINCT) change is directionally correct (DISTINCT applies before ORDER BY/OFFSET/LIMIT in each branch), but there are two blocking issues the new test fails on the postgres backend, and the seam arithmetic now over-fetches in more cases plus test-coverage gaps. Details inline. Two items with no diff line to anchor on:
- Pre-existing, on the touched functions' first lines:
offset as i64(queries.rs:2525, 2568) wraps a client-controlledu64 >= 2^63to a negative bigint → Postgres rejects withOFFSET must not be negative→ HTTP 500.get_offset_and_itemsonly capsitems, notoffset. Atry_into()with an empty-page fallback (or an offset cap in the handler) would fix both endpoints. - Changelog:
api-server/CHANGELOG.md[Unreleased]is empty, but this changes client-visible behavior of/v2/tokenand/v2/token/ticker/{ticker}(updated tokens no longer appear once per stored version, page composition shifts) — the kind of change earlier entries document for these same endpoints.
| (SELECT nft_id | ||
| (SELECT DISTINCT nft_id | ||
| FROM ml.nft_issuance | ||
| ORDER BY nft_id |
There was a problem hiding this comment.
Blocking: the NFT branch's LIMIT now over-fetches in more cases. The formula CASE WHEN ($1 - count >= -$2) THEN ($2 + $1 - count) ELSE 0 END has no upper cap, so any offset greater than the fungible count returns more than items rows, with overlapping pages and switching the CTE to count(DISTINCT token_id) shrinks count, strictly widening the offsets that trigger it.
Example: 3 tokens each updated 5 times (15 rows, 3 distinct) plus 1000 NFTs. ?offset=10&items=10 returned exactly 10 entries pre-PR (count=15 → NFT LIMIT 5) but 17 post-PR (count=3 → NFT OFFSET 7 LIMIT 17). With 0 matching tokens and many NFTs (the common case for ticker searches that only match NFTs), ?offset=500000&items=10 returns ~500k ids the handler does no clamping, so MAX_NUM_ITEMS=100 is silently bypassed.
Minimal fix (here and in get_token_ids_by_ticker):
LIMIT LEAST($2, GREATEST($2 + $1 - (SELECT * FROM count_tokens), 0))or see the outer-pagination suggestion below, which removes the arithmetic entirely.
There was a problem hiding this comment.
Confirmed, I get the same numbers on your example: 10 rows before, 17 after. The arithmetic was already wrong, my change just widens when it bites. Taking the outer pagination version instead, which removes it entirely.
| FROM ml.fungible_token | ||
| ORDER BY token_id | ||
| OFFSET $1 | ||
| LIMIT $2) |
There was a problem hiding this comment.
Suggestion (subsumes the LIMIT bug and the count-CTE cost): wrap both deduplicated id sets in one outer ORDER BY/OFFSET/LIMIT a pattern this file already uses (get_latest_blocktimestamps, get_best_block):
SELECT id FROM (
(SELECT DISTINCT 0 AS grp, token_id AS id FROM ml.fungible_token)
UNION ALL
(SELECT DISTINCT 1, nft_id FROM ml.nft_issuance)
) t ORDER BY grp, id OFFSET $1 LIMIT $2No seam arithmetic to get wrong, at most items rows guaranteed, tokens-first order preserved, and it adds the outer ORDER BY that UNION ALL alone doesn't guarantee. It also drops the count_tokens CTE, which is a per-request O(all historical rows) sort on an unauthenticated endpoint (count(DISTINCT) disables parallel aggregation and Postgres has no loose index scan) at 100k tokens × 20 updates, every page-1 request sorts 2M values just to compute the seam. If the CTE stays, count(*) FROM (SELECT token_id FROM ml.fungible_token GROUP BY token_id) at least keeps hash-agg/parallelism.
Longer-term: this table now has a fourth idiom for "versioned rows, want one per id" (ORDER BY block_height DESC LIMIT 1, MAX(block_height), ROW_NUMBER, and now query-time DISTINCT). A latest_fungible_token cache table like the two existing latest_*_cache tables would make listing, counting, and ticker search O(page) and structurally duplicate-free.
There was a problem hiding this comment.
Taking this. No seam to get wrong, bounded rows, and it drops the per request count(DISTINCT). The cache table sounds right but feels like separate work, happy to do it here if you'd prefer.
| // Fungible token data is stored per block height, so a token that gets updated has | ||
| // a row for every height it changed at. It must still be listed only once. | ||
| db_tx | ||
| .set_fungible_token_issuance( |
There was a problem hiding this comment.
Blocking: this panics on the postgres backend the only backend the SQL fix applies to. A second set_fungible_token_issuance for the same token id does a plain INSERT into ml.coin_or_token_decimals (PK on coin_or_token_id alone, no ON CONFLICT), so the containerized postgres suite (ML_CONTAINERIZED_TESTS=1 in CI) dies with duplicate key value violates unique constraint at this .unwrap(), before reaching the new assertions. It passes in-memory only because that impl upserts.
Use set_fungible_token_data for the second version instead it has ON CONFLICT, doesn't touch the decimals table, and is the path real updates actually take (scanner-lib/src/blockchain_state/mod.rs), so the fixture then matches how duplicate rows arise in production.
There was a problem hiding this comment.
Confirmed. coin_or_token_decimals has its PK on coin_or_token_id alone and no ON CONFLICT, so it fails before reaching the assertions. This was the gap I flagged in the description, I couldn't run the containerised suite locally. Switching to set_fungible_token_data.
|
|
||
| let ids = db_tx.get_token_ids(6, 0).await.unwrap(); | ||
| let unique_ids: BTreeSet<_> = ids.iter().collect(); | ||
| assert_eq!( |
There was a problem hiding this comment.
The assertions are vacuous and only cover half the fix. ids.len() == unique_ids.len() is satisfied by an empty or truncated result e.g. reverting only the CTE to count(token_id) (keeping SELECT DISTINCT) makes one NFT unreachable on any page, yet both assertions still pass.
- The expected set (
all_ids) is already in scope: assert set-equality against it (the idiom used ~70 lines above), which subsumes the no-duplicates check. - Add a seam-crossing page after the duplicate insert (e.g.
get_token_ids(3, 3)) that's the only way to exercise thecount(DISTINCT)change. - The
DISTINCT nft_idhalf has zero coverage: no test creates a secondml.nft_issuancerow, thoughupdate_nft_ownerinserts one per owner change, so NFT duplicates are genuinely reachable (it's not dead code). Needs a postgres-suite case going through the owner-update path, since the in-memory backend asserts against multiple issuances per id.
There was a problem hiding this comment.
Agreed, the length check passes on an empty result. Switching to set equality against all_ids plus a page across the seam. I also got the NFT part wrong in the description. update_nft_owner inserts a row per owner change, so duplicates are reachable. Adding coverage through that path.
|
Pushed, thanks for the review. Took your outer pagination version. Set postgres up locally with your scenario and got your numbers exactly: 10 rows before, 17 with my first attempt, 10 now. Full scan returns 1003 of 1003 distinct and pages no longer overlap. Test now updates through Changelog added. Offset fixed with The containerised suite needs podman and I only have docker, so I checked the SQL directly rather than through the suite. |
|
@oliv3rdrt Merging is blocked |
The fungible_token table is keyed on (token_id, block_height), so a token that has been updated has one row per height it changed at. The query behind the /token endpoint selected token_id without deduplicating, so such tokens were listed once per stored version. The count used for the nft offset had the same problem, since it counted rows rather than tokens. Select distinct ids and count distinct token ids instead. Also extend the storage test suite to store a token at a second height and assert that the returned ids contain no duplicates.
Following review feedback. The previous version deduplicated each branch but kept the seam arithmetic that works out how much of the page the nfts should fill, and that arithmetic has no upper bound. Any offset past the fungible count made the nft branch return more rows than were asked for, and counting distinct tokens made the count smaller, so it happened for more offsets than before. With no matching tickers and 1000 nfts, offset 100 with items 10 returned 110 rows. Select the deduplicated ids from both tables and apply ORDER BY, OFFSET and LIMIT once on the outside, the way get_latest_blocktimestamps already does. There is no seam to get wrong, a page can never exceed items, tokens still sort before nfts, and the count CTE is gone along with its per request count(DISTINCT) over every historical row. The test now updates a token through set_fungible_token_data, which upserts and is the path the scanner takes, rather than set_fungible_token_issuance, which inserts into coin_or_token_decimals and would break on postgres. It also changes an nft owner so the nft side gets covered, asserts set equality against the ids seen before the updates, and pages across the token to nft boundary.
get_offset_and_items caps items but not offset, so an offset that does not fit in a bigint wrapped to a negative number in the cast and Postgres rejected the query with "OFFSET must not be negative", which surfaced as a 500. Use try_from and return an empty page, since an offset that large is past the end of any result set anyway. Note the same cast is used by five other queries in this file, so a cap in get_offset_and_items would cover all of them. Keeping this to the two token endpoints for now as they are the ones this PR touches.
e3c9c38 to
3d9b440
Compare
|
@erubboli all signed and verified now. rebased onto master while I was at it. |
The
/tokenendpoint was returning the same token id several times (#1982).The cause is that
ml.fungible_tokenis keyed on(token_id, block_height), so a token that has been updated has one row for every height it changed at. The query behind the endpoint selectedtoken_idwithout deduplicating, so those tokens came back once per stored version. Thecount_tokensCTE had the same problem, as it counted rows rather than distinct tokens, which also skewed the offset used for the nft part of the query.The fix selects distinct ids and counts distinct token ids, in both
get_token_idsandget_token_ids_by_ticker.I also extended the storage test suite to store a token again at a later height and assert that the returned ids contain no duplicates. The existing test only ever stored each token at a single height, which is why this was not caught.
On verification: I ran the in-memory suite, which passes, though the in-memory backend never had this problem since it is keyed by token id. The postgres suite needs podman, which I could not run locally, so the postgres side of this is verified by reading the schema rather than by running it. Worth a second pair of eyes on the SQL for that reason.
While writing the test I noticed the in-memory backend asserts on storing the same nft id twice ("multiple nft issuances with same token_id"), so nft issuance looks like a one time event. I kept
DISTINCTon the nft part anyway since the table permits multiple heights, but happy to drop it if that is unnecessary.