C# watchTradesForSymbols can return a trade twice when an append lands between getLimit and the cache snapshot · Issue #30645 · ccxt/ccxt · GitHub
Skip to content

C# watchTradesForSymbols can return a trade twice when an append lands between getLimit and the cache snapshot #30645

Open

Description

@alert101

Operating System

Windows 11 x64

Programming Language

C#

CCXT Version

4.5.84

Description

Steps to reproduce

No credentials or WebSocket connection are needed. With newUpdates enabled (the default Pro trade-watch behavior), force one append between getLimit and filterBySinceLimit on a single ArrayCache:

using System;
using System.Collections.Generic;
using System.Linq;
using ccxt;
using ccxt.pro;

var exchange = new BaseExchange(new Dictionary<string, object>());
var cache = new ArrayCache();
Dictionary<string, object> Trade(string id, int timestamp) => new()
{
    ["symbol"] = "BTC/USDT", ["id"] = id, ["timestamp"] = timestamp
};

cache.append(Trade("a1", 1));
cache.append(Trade("a2", 2));
var firstLimit = cache.getLimit("BTC/USDT", null); // 2
cache.append(Trade("a3", 3)); // receive-thread append after count read
var first = exchange.filterBySinceLimit(cache, null, firstLimit, "timestamp", true);

cache.append(Trade("a4", 4));
var secondLimit = cache.getLimit("BTC/USDT", null); // 2
var second = exchange.filterBySinceLimit(cache, null, secondLimit, "timestamp", true);

Console.WriteLine(string.Join(",", first.Select(row => ((Dictionary<string, object>)row)["id"])));
Console.WriteLine(string.Join(",", second.Select(row => ((Dictionary<string, object>)row)["id"])));
// a2,a3
// a3,a4  (a3 was appended only once)

The same sequence occurs when a real WebSocket trade arrives after the watch method has read the count but before it copies the cache. Pass an omitted limit (null), not 0: an explicit zero introduces a separate full-cache replay and would obscure this race.

Expected behavior

Successive new-update resolutions should not both contain a trade that was appended only once, even when a receive-thread append races a watch call. The count and returned rows should describe the same cache state.

Actual behavior

The deterministic interleaving returns a3 in both calls without a repeated input. In a two-minute C# public WebSocket observation with the optional limit omitted, 135 observed caches produced 45,417 returned-row occurrences (42,553 tracked; 2,864 not tracked) and 289 occurrences where a previously returned ID was selected again without another append of that ID. All 289 met the recorded ordering: the trade was appended after the first count read, was present in its later snapshot, and was still present when the next count was read. One Binance XRP/USDT example had first count-read sequence 23, single append/snapshot sequence 24, and next count-read sequence 25; the second call returned that same ID with an effective limit of 2. These are occurrences, not distinct trade IDs; the observation is bounded and its instrumentation can affect scheduling. It does not measure the uninstrumented race rate or attribute earlier production totals to this race.

Root cause

ArrayCache.getLimit(symbol, limit) reads the symbol's update count and sets its clear flag under lockObject, then releases the lock. filterBySinceLimit subsequently invokes toArray and tail-slices the cache; even in v4.5.84, which copies SlimConcurrentList atomically under its own read lock, this snapshot is not atomic with the earlier update-count read. An append between the two reads can enter the first returned tail and, because it occurs after the counter was marked for clearing, be counted again in the next call. A per-symbol cache and one unique venue trade are sufficient.

Suggested fix

Consume the update count and choose the returned rows from one consistent cache snapshot, or use a stable delivery cursor across resolutions. Preserve independent per-symbol/global polling scopes and the bounded cache's eviction semantics. Add a deterministic C# test that pauses an append between count and snapshot, then checks that the same unique ID is returned only once. Test venue redelivery separately (a second append of the same ID is different from this defect). Switching getLimit(symbol, limit) to getLimit(null, limit) alone does not make the count and snapshot atomic.

Activity

  1. carlotestor commented on Sep 25, 2026

    @carlotestor
    Collaborator

    Thanks for the detailed report and the deterministic repro. I checked it against current master (cs/ccxt/ws/ArrayCache.cs), and the interleaving happens as you describe:

    • getLimit(symbol, null) reads newUpdatesBySymbol[symbol] and sets clearUpdatesBySymbol[symbol] = true under lockObject, then releases the lock.
    • The racing append(a3) sees the flag, resets the counter to 0, then increments it to 1. So a3 is counted in the next window.
    • The generated watch code then calls filterBySinceLimit(..., tail: true), which snapshots the list after a3 went in. So a3 is also in the current tail.

    This is specific to the threaded ports. In TS/JS (and Python asyncio), getLimit and filterBySinceLimit run synchronously back to back with no await between them, so no append can land in the gap.

    A real fix has to either return the count and the rows from one locked snapshot, or keep a per-consumer delivery cursor. Either way it touches the hand-written cache and the way generated watch* methods consume it, not a one-line guard. Related C#/Go cache work is in flight in #30534, #30314 and #29874; none of them addresses this count-vs-snapshot atomicity. Marking as a confirmed C# WS bug.

  2. alert101 commented on Oct 5, 2026

    @alert101
    Author

    Confirming on v4.5.85, and adding a consequence that is not in the report: the same interleaving also drops a row, not only duplicates one.

    getLimit returns the count n of rows appended before the poll, and the watch method then takes the last n rows of a later snapshot. If an append lands in between, that snapshot holds n + 1 new rows, so the tail slice cuts off the oldest new row. That row is not counted again in the next window (the next count only includes the late append), so it is never delivered.

    The deterministic sequence from the report shows this directly (cache a1, a2, then getLimit -> 2, then append(a3), then filterBySinceLimit(..., tail: true)):

    • delivered in this window: [a2, a3], 2 rows as counted, and a1 is missing
    • delivered in the next window: a3 again

    An existing C# regression test asserts the 2-row result and a3 in it, which already implies a1 was not delivered. In other words, per window the result is a duplicate plus a loss, not just a replay.

    For a live data point: a 120 s observation of public trade caches with the limit omitted, on v4.5.84 with extra instrumentation, recorded 289 repeat-without-append rows across about 135 caches, all matching this read-window overlap. The instrumentation changes the timing, so this only shows the race occurs in practice, not its natural rate.

    On the fix, I agree with the snapshot option: a cache method that returns the new-update count and the rows from one critical section, with the watch* call sites calling it instead of getLimit followed by filterBySinceLimit. TS, Python and PHP can implement it as the current two calls, since they have no gap between them. The call sites are the same ones #30534 edits, so it would help to sequence the two changes.

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

    bugc#wsWebSocket (aka ccxt.pro)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions