Repository navigation
fix(trading): page order history by a real keyset cursor, not the same "0:" forever - #234
Conversation
…e "0:" forever POST /trading/orders/search built each order's cursor from `timestamp` and `client_order_id`, but order rows carry `created_at` and `order_id`, so every row got the cursor "0:" and sorted as a tie. The page after "0:" was the list minus its first row -- most of the previous page again -- and it handed "0:" back, so a client that followed next_cursor looped forever over the same rows. An agent paging its order history with limit=3 read records 2-3 again on every call and never reached the end. Behind that sat a second cap. Each account was read as its newest `limit * 2` rows at offset 0, then merged and sliced in memory, so even a correct cursor could never reach an order older than that window, while has_more said the history had ended. Multi-value connector_names/trading_pairs were filtered in memory after the cap, shrinking pages further, and total_count counted the fetched window rather than the matching orders. Pagination now happens in the database, keyset on (created_at, client_order_id): - OrderRepository.get_orders takes the account/connector/pair filters as lists and a `before` cursor, orders by created_at desc then client_order_id desc, and cuts strictly after the cursor, so orders sharing a created_at are neither skipped nor served twice. count_orders applies the same filters for a real total_count. - The route runs one query across every account with limit + 1 rows; the extra row is how has_more is known. next_cursor is "<created_at ISO>|<order id>", split back on the first "|", which an ISO timestamp never contains. - A cursor the route did not hand out (including the old "0:") is refused with a 400. An unknown cursor used to restart at page one, which a client walking the history cannot tell from genuinely older orders. TradingHistoryService.get_orders becomes search_orders, returning the page and the total from one session; the route was its only caller. routers/trading.py also clears the flake8 findings it already carried on main -- unused imports, inline `import logging` in except blocks (the module logger was already there), untyped `connector_service = Depends(...)` parameters, over-long lines -- because the pre-commit flake8 hook lints the whole file and refuses any commit that touches it. Formatting only; no behaviour change outside the order search route. test/test_order_history_cursor.py drives the real route, service and repository against a real SQL table and walks the cursor as a client does. Against the old route every walk looped on "0:"; now each order comes back exactly once, newest first, at every page size.
|
| created_at, _, client_order_id = cursor.partition(_ORDER_CURSOR_SEPARATOR) | ||
| try: | ||
| if not client_order_id: | ||
| raise ValueError("no client order id") | ||
| return datetime.fromisoformat(created_at), client_order_id |
There was a problem hiding this comment.
The parser accepts any cursor containing a valid ISO timestamp and a nonempty ID, even if the route never issued it. For example, 2026-09-11T10:00:00|unknown is used directly as the database boundary, so the response can silently omit newer orders instead of returning the promised 400. Validate that the cursor identifies an existing order in the applicable result set, or use an opaque authenticated cursor.
There was a problem hiding this comment.
Fixed in 4dab50a by narrowing the documented contract, not by validating existence. A cursor is a keyset position, not a token: a well-formed <created_at ISO>|<order id> pages back from that point whether or not the order exists, the same way the repository's before bound works. Only a malformed cursor gets a 400. Looking the order up would cost a query per page and add nothing for a client that follows next_cursor. The route and _parse_order_cursor docstrings now say this, and test_a_well_formed_cursor_is_a_position_even_for_an_order_that_does_not_exist pins it.
…ght back: hummingbot-api built every order's cursor from fields its rows do not carry, so each page returned "0:" and an agent following it looped over overlapping pages; a non-advancing cursor now ends the walk with a note that the rows may repeat, until the backend fix (hummingbot/hummingbot-api#234) is deployed
The keyset cursor is applied as a filter, not an index bound — deep pages cost ~5× page oneTesting this branch at volume: pagination is correct throughout (the cursor walk is clean at every page size I tried, including against real PostgreSQL or_(
Order.created_at < created_at,
and_(Order.created_at == created_at, Order.client_order_id < client_order_id),
)PostgreSQL applies that as a filter over an index scan rather than as an index range bound, so each page walks backward from the newest row and discards everything newer than the cursor before it can return Measured on 1,000,000 orders:
End to end through Two smaller things noticed alongside it: Steps to reproduce1. Bring up the stack (from this branch) and seed 1M orders: INSERT INTO orders (
client_order_id, exchange_order_id, created_at, updated_at,
account_name, connector_name, trading_pair,
trade_type, order_type, amount, price, status, filled_amount
)
SELECT
format('vol-%s', lpad(i::text, 8, '0')),
format('ex-%s', i),
TIMESTAMPTZ '2025-01-01 00:00:00+00' + ((i / 5) * INTERVAL '2 seconds'),
TIMESTAMPTZ '2025-01-01 00:00:00+00' + ((i / 5) * INTERVAL '2 seconds'),
'acct-' || (i % 4),
(ARRAY['binance','kucoin','okx','bybit'])[1 + (i % 4)],
(ARRAY['BTC-USDT','ETH-USDT','SOL-USDT'])[1 + (i % 3)],
CASE WHEN i % 2 = 0 THEN 'BUY' ELSE 'SELL' END,
'LIMIT', 1.0, 100 + (i % 1000),
(ARRAY['FILLED','CANCELLED','OPEN'])[1 + (i % 3)],
0
FROM generate_series(1, 1000000) AS i;
ANALYZE orders;That spans 2. Plan the query the route issues, with a cursor deep in the history: EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders
WHERE account_name IN ('acct-0','acct-1','acct-2','acct-3')
AND (created_at < TIMESTAMPTZ '2025-01-01 05:33:20+00'
OR (created_at = TIMESTAMPTZ '2025-01-01 05:33:20+00'
AND client_order_id < 'vol-00000001'))
ORDER BY created_at DESC, client_order_id DESC LIMIT 11;Observed: Sweep the cursor timestamp across the seeded range to see the cost track depth linearly. 3. Confirm it end to end. Page one: curl -s -u admin:PASSWORD -X POST http://localhost:8000/trading/orders/search \
-H 'Content-Type: application/json' \
-d '{"limit":100,"account_names":["acct-0","acct-1","acct-2","acct-3"]}'then the same request with
Correctness is unaffected — every walk I ran returned each order exactly once, newest first, including with ties on |
…a cursor is The keyset cut in OrderRepository.get_orders was written as `created_at < t OR (created_at = t AND client_order_id < id)`. PostgreSQL runs that as a filter over a backward scan of ix_orders_created_at, so every page first walks and discards each order newer than the cursor: the O(offset) cost keyset pagination is meant to remove. At 1M orders a page 95% deep spent 107 ms discarding 950k rows. The same cut as a row-value comparison, `(created_at, client_order_id) < (t, id)`, lets the planner derive `created_at <= t` as an index condition on the existing index. The same deep page now runs in 0.06-0.08 ms, 5 rows removed by the filter, and a repository call costs the same at page one and at 95% depth. A composite (created_at, client_order_id) index was tried too. The planner ignored it, so it is not added. The route's docstring claimed any cursor it did not hand out gets a 400. It only checks the format. A well-formed cursor is a keyset position and pages back from that point whether or not the order it names exists. The docs now say that, and a test pins it. Verified against PostgreSQL 16 with 1M seeded orders: EXPLAIN plans at 5/50/95% depth, and a 2,000-order walk at limit 7 over tied created_at values that matches a direct ordered read exactly, with no repeats. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PjWZzXVi2w1hrbY3Ru9U1q
|
@david-hummingbot thanks for the numbers and the repro. Fixed in 4dab50a. The cut is now a row-value comparison,
Through the repository at On the two side notes:
|
Problem
Paging
POST /trading/orders/searchlooped forever. An agent asking for order history withlimit=3got page one, followednext_cursor, and got most of page one back, with the same cursor, on every later call.Root cause. The route built each order's cursor from
timestampandclient_order_id, butOrderRepository.to_dictreturnscreated_atandorder_id. Every row got the cursor"0:"and sorted as a tie. The page after"0:"was the list minus its first row, and it handed"0:"back.A second bug behind it. Each account was read as its newest
limit * 2rows at offset 0, then merged and sliced in memory. So even a correct cursor could never reach an older order, andhas_moresaid history had ended. Filters with more than oneconnector_names/trading_pairsvalue ran in memory after that cap, andtotal_countcounted the rows fetched, not the orders that match.Fix
Pagination now happens in the database, keyset on
(created_at, client_order_id):OrderRepository.get_orderstakes the account, connector and pair filters as lists, plus abeforecursor. It orders bycreated_at DESC, client_order_id DESCand returns only orders strictly after the cursor, so orders with the samecreated_atare never skipped or served twice. The cut is a row-value comparison,(created_at, client_order_id) < (t, id). PostgreSQL derivescreated_at <= tfrom it as a range bound onix_orders_created_at, so a deep page costs the same as page one. The equivalentORwould run as a filter that walks every newer order first. The newcount_ordersuses the same filters, sototal_countis real.limit + 1rows; the extra row is how it knowshas_more.next_cursoris"<created_at ISO>|<order id>", split back on the first|, which an ISO timestamp never contains."0:". It used to silently restart at page one, which a client walking the history can't tell apart from older orders. Only the format is checked. A well-formed cursor is a keyset position and pages back from that point, whether or not the order it names exists.TradingHistoryService.get_orders→search_orders, which returns the page and the total from one session. The route was its only caller.routers/trading.pyalso clears the flake8 findings it already had onmain: unused imports, inlineimport logginginexceptblocks, untypedconnector_service = Depends(...)parameters, and over-long lines. The pre-commit flake8 hook lints the whole file, so no commit touching it could pass without this. It's formatting only; nothing outside the order search route behaves differently.Compatibility
data,pagination.{limit, has_more, next_cursor, total_count}.next_cursor), but their format changed. A client holding an old"0:"cursor now gets a 400 instead of a repeated page.total_countis now every order that matches the filters, not the size of the fetched window.Testing
test/test_order_history_cursor.pyruns the real route, service and repository against a real SQL table: sync SQLite behind an async shim, since the env has noaiosqlite. It follows the cursor the way a client does, at page sizes 1, 2, 3, 4, 10, 11 and 100. On the old route every walk looped on"0:". Now each order comes back exactly once, newest first. The tests also cover orders past2 × limit, filters with more than one value,total_count, and the 400 for unknown cursors.--max-line-length=130) and isort are clean on every file touched, and the pre-commit gate passed without--no-verify.ORcut discarded 950k rows on a page 95% deep, taking 107 ms. The row-value cut plans asIndex Cond: created_at <= …and discards 5 rows, taking 0.06–0.08 ms at 5%, 50% and 95% depth. A 2,000-order walk atlimit=7over tiedcreated_atvalues matched a direct ordered read exactly, with no repeats. A composite(created_at, client_order_id)index was tried, but the planner didn't use it, so it isn't added.Follow-ups (found here, not fixed)
start_timeandend_timedisagree.hummingbot-api-clientdocuments them as Unix seconds.OrderRepositorydivides by 1000, so it expects milliseconds. A seconds value filters from 1970.POST /trading/tradeshas the samelimit * 2window. Its cursor is built from fields the rows actually have, so it doesn't loop, but it still can't page past the window.🤖 Generated with Claude Code
https://claude.ai/code/session_015UhbCLq9uRjJ1bWYDr59da