diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2440c66..82383fe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,15 +17,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.12', '3.13', '3.14'] + python-version: ['3.13', '3.14', '3.13t', '3.14t'] + env: + UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v5 - name: Install uv uses: astral-sh/setup-uv@v9.0.0 - - name: Set up Python ${{ matrix.python-version }} - run: uv venv --python ${{ matrix.python-version }} - - name: Install dependencies - run: uv pip install ".[tests]" + - name: Install the project and its test dependencies + run: uv sync --extra tests - name: Test with pytest - run: uv run pytest tests/ + run: uv run --extra tests pytest tests/ diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index bf03f53..061e54a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -19,7 +19,7 @@ jobs: - name: Build wheels uses: pypa/cibuildwheel@v4.1.1 env: - CIBW_BUILD: "cp312-* cp313-* cp314-*" + CIBW_BUILD: "cp313-* cp314-* cp314t-*" CIBW_ARCHS_LINUX: x86_64 CIBW_ARCHS_MACOS: arm64 CIBW_TEST_REQUIRES: pytest requests sortedcontainers diff --git a/CHANGES.md b/CHANGES.md index bc566ae..c34b9b0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ### 1.1.0 * Update: Remove FTX checksum support + * Feature: free-thread python (3.13+) support ### 1.0.2 (2026-08-15) * Feature: Bitfinex checksum support for L3 books diff --git a/README.md b/README.md index 50e698f..b61dc26 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Orderbook [![License](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE) -![Python](https://img.shields.io/badge/Python-3.12+-green.svg) +![Python](https://img.shields.io/badge/Python-3.13+-green.svg) [![PyPi](https://img.shields.io/badge/PyPi-order--book-brightgreen)](https://pypi.python.org/pypi/order-book) ![coverage-lines](https://img.shields.io/badge/coverage%3A%20lines-88.5%25-blue) ![coverage-functions](https://img.shields.io/badge/coverage%3A%20functions-100%25-blue) @@ -12,7 +12,7 @@ A ***fast*** L2/L3 orderbook data structure, in C, for Python ### Installation -Python 3.12+ supported. In general, [uv](https://docs.astral.sh/uv/) is preferred and will be utilized throughout this document. +Python 3.13+ supported. [uv](https://docs.astral.sh/uv/) is preferred and will be utilized throughout this document. To add it to a project `uv add order-book` or, to install it into an environment directly, `uv pip install order-book` @@ -231,6 +231,7 @@ print(ob.to_dict(from_type=str, to_type=float)) * Sides maintained in correct order * Can perform orderbook checksums * Supports max depth and depth truncation +* Free-threaded Python support diff --git a/build-wheels-macos.sh b/build-wheels-macos.sh index 70e6533..aae3e22 100755 --- a/build-wheels-macos.sh +++ b/build-wheels-macos.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -PY_VERS="3.12 3.13 3.14" +PY_VERS="3.13 3.14 3.14t" DEST="./wheelhouse" for V in ${PY_VERS}; do diff --git a/build-wheels.sh b/build-wheels.sh index 21dcae4..abe89da 100755 --- a/build-wheels.sh +++ b/build-wheels.sh @@ -2,7 +2,7 @@ # Do not run manually (runs in the docker container). use wheels.sh to launch it set -e -x -py_vers=("/opt/python/cp312-cp312/bin" "/opt/python/cp313-cp313/bin" "/opt/python/cp314-cp314/bin") +py_vers=("/opt/python/cp313-cp313/bin" "/opt/python/cp314-cp314/bin" "/opt/python/cp314-cp314t/bin") rm -rf /tmp/wheelhouse mkdir -p /tmp/wheelhouse diff --git a/coverage.sh b/coverage.sh index 7be94d6..55fc656 100755 --- a/coverage.sh +++ b/coverage.sh @@ -10,7 +10,7 @@ uv build --wheel --python .venv/bin/python uv pip install --reinstall dist/*.whl pytest requests sortedcontainers # produces the coverage data -uv run pytest tests/ +uv run --no-sync pytest tests/ cd build/temp* lcov -c --directory . --output-file all.info diff --git a/orderbook/orderbook.c b/orderbook/orderbook.c index f9a1439..333694a 100644 --- a/orderbook/orderbook.c +++ b/orderbook/orderbook.c @@ -34,8 +34,6 @@ static void replace_side(SortedDict *side, PyObject *data) void Orderbook_dealloc(Orderbook *self) { PyObject_GC_UnTrack(self); - free(self->checksum_buffer); - self->checksum_buffer = NULL; Py_CLEAR(self->bids); Py_CLEAR(self->asks); Py_TYPE(self)->tp_free((PyObject *) self); @@ -88,25 +86,17 @@ PyObject *Orderbook_new(PyTypeObject *type, PyObject *args, PyObject *kwds) self->max_depth = 0; self->truncate = false; self->checksum = INVALID_CHECKSUM_FORMAT; - self->checksum_buffer = NULL; self->checksum_len = 0; - self->checksumming = false; } return (PyObject *) self; } -int Orderbook_init(Orderbook *self, PyObject *args, PyObject *kwds) +static int locked_init(Orderbook *self, PyObject *args, PyObject *kwds) { static char *kwlist[] = {"max_depth", "max_depth_strict", "checksum_format", NULL}; Py_buffer checksum_str = {0}; - // reachable because rendering a level calls __str__ (which could be re-entrant) - if (EXPECT(self->checksumming, 0)) { - PyErr_SetString(PyExc_RuntimeError, "cannot modify orderbook while checksumming"); - return -1; - } - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ipz*", kwlist, &self->max_depth, &self->truncate, &checksum_str)) { return -1; } @@ -133,22 +123,9 @@ int Orderbook_init(Orderbook *self, PyObject *args, PyObject *kwds) return -1; } - uint8_t *buffer = calloc(buffer_len, sizeof(uint8_t)); - if (!buffer) { - PyBuffer_Release(&checksum_str); - PyErr_SetNone(PyExc_MemoryError); - return -1; - } - - // __init__ can be called more than once on the same book - // so make sure we are properly cleaning up - free(self->checksum_buffer); self->checksum = format; - self->checksum_buffer = buffer; self->checksum_len = buffer_len; } else { - free(self->checksum_buffer); - self->checksum_buffer = NULL; self->checksum_len = 0; self->checksum = INVALID_CHECKSUM_FORMAT; } @@ -164,6 +141,16 @@ int Orderbook_init(Orderbook *self, PyObject *args, PyObject *kwds) } +int Orderbook_init(Orderbook *self, PyObject *args, PyObject *kwds) +{ + int ret; + Py_BEGIN_CRITICAL_SECTION2(self->bids, self->asks); + ret = locked_init(self, args, kwds); + Py_END_CRITICAL_SECTION2(); + return ret; +} + + /* Orderbook methods */ PyObject* Orderbook_todict(const Orderbook *self, PyObject *unused, PyObject *kwargs) { @@ -180,15 +167,18 @@ PyObject* Orderbook_todict(const Orderbook *self, PyObject *unused, PyObject *kw return NULL; } - PyObject *bids = SortedDict_todict_impl(self->bids, from, to); - if (EXPECT(!bids, 0)) { - Py_DECREF(ret); - return NULL; + PyObject *bids; + PyObject *asks = NULL; + + Py_BEGIN_CRITICAL_SECTION2(self->bids, self->asks); + bids = locked_SortedDict_todict(self->bids, from, to); + if (EXPECT(bids != NULL, 1)) { + asks = locked_SortedDict_todict(self->asks, from, to); } + Py_END_CRITICAL_SECTION2(); - PyObject *asks = SortedDict_todict_impl(self->asks, from, to); - if (EXPECT(!asks, 0)) { - Py_DECREF(bids); + if (EXPECT(!bids || !asks, 0)) { + Py_XDECREF(bids); Py_DECREF(ret); return NULL; } @@ -213,32 +203,54 @@ PyObject* Orderbook_todict(const Orderbook *self, PyObject *unused, PyObject *kw } -PyObject* Orderbook_checksum(const Orderbook *self, PyObject *Py_UNUSED(ignored)) +static PyObject *locked_checksum(const Orderbook *self) { if (EXPECT(self->checksum == INVALID_CHECKSUM_FORMAT, 0)) { PyErr_SetString(PyExc_ValueError, "no checksum format specified"); return NULL; } - // see __init__ - if (EXPECT(self->checksumming, 0)) { - PyErr_SetString(PyExc_RuntimeError, "cannot checksum while checksumming"); - return NULL; - } + uint8_t buffer[CHECKSUM_BUFFER_MAX]; - if (EXPECT(update_keys(self->bids), 0)) { - return NULL; - } + for (int attempt = 0; ; ++attempt) { + if (EXPECT(update_keys(self->bids), 0)) { + return NULL; + } - if (EXPECT(update_keys(self->asks), 0)) { - return NULL; + if (EXPECT(update_keys(self->asks), 0)) { + return NULL; + } + + // refreshing the asks can release the lock, and a writer that got in + // may have logged a change on the bids + if (EXPECT(self->bids->dirty || self->bids->pend_count, 0)) { + continue; + } + + uint64_t bids_version = self->bids->version; + uint64_t asks_version = self->asks->version; + + PyObject *ret = calculate_checksum(self, buffer); + if (EXPECT(ret != NULL, 1)) { + return ret; + } + + bool moved = (self->bids->version != bids_version) || (self->asks->version != asks_version); + if (!moved || attempt == SD_READ_RETRIES || !PyErr_ExceptionMatches(PyExc_KeyError)) { + return NULL; + } + + PyErr_Clear(); } +} - Orderbook *book = (Orderbook *)self; - book->checksumming = true; - PyObject *ret = calculate_checksum(self); - book->checksumming = false; +PyObject* Orderbook_checksum(const Orderbook *self, PyObject *Py_UNUSED(ignored)) +{ + PyObject *ret; + Py_BEGIN_CRITICAL_SECTION2(self->bids, self->asks); + ret = locked_checksum(self); + Py_END_CRITICAL_SECTION2(); return ret; } @@ -246,7 +258,11 @@ PyObject* Orderbook_checksum(const Orderbook *self, PyObject *Py_UNUSED(ignored) /* Orderbook Mapping Functions */ Py_ssize_t Orderbook_len(const Orderbook *self) { - return SortedDict_len(self->bids) + SortedDict_len(self->asks); + Py_ssize_t ret; + Py_BEGIN_CRITICAL_SECTION2(self->bids, self->asks); + ret = locked_SortedDict_len(self->bids) + locked_SortedDict_len(self->asks); + Py_END_CRITICAL_SECTION2(); + return ret; } @@ -312,7 +328,10 @@ int Orderbook_setitem(const Orderbook *self, PyObject *key, PyObject *value) return -1; } - replace_side(key_int == BID ? self->bids : self->asks, copy); + SortedDict *side = (key_int == BID) ? self->bids : self->asks; + Py_BEGIN_CRITICAL_SECTION(side); + replace_side(side, copy); + Py_END_CRITICAL_SECTION(); return 0; } @@ -342,6 +361,13 @@ PyMODINIT_FUNC PyInit_order_book(void) if (m == NULL) return NULL; +#ifdef Py_GIL_DISABLED + if (PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED) < 0) { + Py_DECREF(m); + return NULL; + } +#endif + Py_INCREF(&OrderbookType); if (PyModule_AddObject(m, "OrderBook", (PyObject *) &OrderbookType) < 0) { Py_DECREF(&OrderbookType); @@ -469,7 +495,7 @@ typedef struct { static int snapshot_side(SortedDict *side, Py_ssize_t limit, side_snapshot *snap) { - Py_ssize_t levels = SortedDict_len(side); + Py_ssize_t levels = locked_SortedDict_len(side); Py_ssize_t cached = side->k_len; if (levels > cached) { @@ -505,10 +531,11 @@ static void release_side(side_snapshot *snap) static int snapshot_level(const side_snapshot *snap, Py_ssize_t index, PyObject **price, PyObject **amount) { PyObject *key = Py_NewRef(PyTuple_GET_ITEM(snap->keys, index)); - PyObject *value = PyDict_GetItemWithError(snap->contents, key); + PyObject *value; + int found = PyDict_GetItemRef(snap->contents, key, &value); - if (EXPECT(!value, 0)) { - if (!PyErr_Occurred()) { + if (EXPECT(found <= 0, 0)) { + if (found == 0) { PyErr_SetObject(PyExc_KeyError, key); } Py_DECREF(key); @@ -516,7 +543,7 @@ static int snapshot_level(const side_snapshot *snap, Py_ssize_t index, PyObject } *price = key; - *amount = Py_NewRef(value); + *amount = value; return 0; } @@ -549,7 +576,7 @@ static int kraken_populate_side(const side_snapshot *snap, uint8_t *data, int *p } -static PyObject* kraken_checksum(const Orderbook *ob) +static PyObject* kraken_checksum(const Orderbook *ob, uint8_t *buffer) { if (EXPECT(ob->max_depth && ob->max_depth < 10, 0)) { PyErr_SetString(PyExc_ValueError, "Max depth is less than usual number of levels for Kraken checksum"); @@ -570,15 +597,15 @@ static PyObject* kraken_checksum(const Orderbook *ob) PyObject *ret = NULL; int pos = 0; - if (EXPECT(kraken_populate_side(&asks, ob->checksum_buffer, &pos, ob->checksum_len), 0)) { + if (EXPECT(kraken_populate_side(&asks, buffer, &pos, ob->checksum_len), 0)) { goto done; } - if (EXPECT(kraken_populate_side(&bids, ob->checksum_buffer, &pos, ob->checksum_len), 0)) { + if (EXPECT(kraken_populate_side(&bids, buffer, &pos, ob->checksum_len), 0)) { goto done; } - ret = PyLong_FromUnsignedLong(crc32_orderbook(ob->checksum_buffer, pos)); + ret = PyLong_FromUnsignedLong(crc32_orderbook(buffer, pos)); done: release_side(&bids); @@ -713,7 +740,11 @@ static void cursor_close_level(side_cursor *cursor) // order the level holds them in - a dict keeps them in the order they arrived static int cursor_open_level(side_cursor *cursor, PyObject *level) { +#ifdef Py_GIL_DISABLED + PyObject *orders = PyDict_Items(level); +#else PyObject *orders = PyDict_Keys(level); +#endif if (EXPECT(!orders, 0)) { return -1; } @@ -724,7 +755,9 @@ static int cursor_open_level(side_cursor *cursor, PyObject *level) } cursor->orders = orders; +#ifndef Py_GIL_DISABLED cursor->amounts = Py_NewRef(level); +#endif cursor->order = 0; return 0; @@ -736,19 +769,24 @@ static int cursor_next(side_cursor *cursor, PyObject **field, PyObject **amount) while (true) { if (cursor->orders) { if (cursor->order < PyList_GET_SIZE(cursor->orders)) { - PyObject *id = PyList_GET_ITEM(cursor->orders, cursor->order++); - PyObject *value = PyDict_GetItemWithError(cursor->amounts, id); - - if (EXPECT(!value, 0)) { - if (!PyErr_Occurred()) { - PyErr_SetObject(PyExc_KeyError, id); + PyObject *entry = PyList_GET_ITEM(cursor->orders, cursor->order++); +#ifdef Py_GIL_DISABLED + *field = Py_NewRef(PyTuple_GET_ITEM(entry, 0)); + *amount = Py_NewRef(PyTuple_GET_ITEM(entry, 1)); +#else + PyObject *value; + int found = PyDict_GetItemRef(cursor->amounts, entry, &value); + + if (EXPECT(found <= 0, 0)) { + if (found == 0) { + PyErr_SetObject(PyExc_KeyError, entry); } return -1; } - *field = Py_NewRef(id); - *amount = Py_NewRef(value); - + *field = Py_NewRef(entry); + *amount = value; +#endif return 1; } @@ -833,7 +871,7 @@ static int append_entry(side_cursor *cursor, uint8_t *data, int *pos, int size, // build the interleaved string, and report the length to hash -static int build_alternating(const Orderbook *ob, const uint32_t depth, char separator, string_builder_t string_builder, bool signed_asks, bool expand_orders, int *length) +static int build_alternating(const Orderbook *ob, uint8_t *buffer, const uint32_t depth, char separator, string_builder_t string_builder, bool signed_asks, bool expand_orders, int *length) { side_snapshot bids, asks; if (EXPECT(snapshot_side(ob->bids, -1, &bids), 0)) { @@ -853,11 +891,11 @@ static int build_alternating(const Orderbook *ob, const uint32_t depth, char sep int buffer_len = ob->checksum_len; for(uint32_t i = 0; i < depth; ++i) { - if (EXPECT(append_entry(&bid_cursor, ob->checksum_buffer, &pos, buffer_len, separator, string_builder, false) < 0, 0)) { + if (EXPECT(append_entry(&bid_cursor, buffer, &pos, buffer_len, separator, string_builder, false) < 0, 0)) { goto done; } - if (EXPECT(append_entry(&ask_cursor, ob->checksum_buffer, &pos, buffer_len, separator, string_builder, signed_asks) < 0, 0)) { + if (EXPECT(append_entry(&ask_cursor, buffer, &pos, buffer_len, separator, string_builder, signed_asks) < 0, 0)) { goto done; } } @@ -875,7 +913,7 @@ static int build_alternating(const Orderbook *ob, const uint32_t depth, char sep } -static PyObject* alternating_checksum(const Orderbook *ob, const uint32_t depth, char separator, string_builder_t string_builder, bool signed_asks) +static PyObject* alternating_checksum(const Orderbook *ob, uint8_t *buffer, const uint32_t depth, char separator, string_builder_t string_builder, bool signed_asks) { if (EXPECT(ob->max_depth && ob->max_depth < depth, 0)) { PyErr_SetString(PyExc_ValueError, "Max depth is less than minimum number of levels for checksum"); @@ -883,11 +921,11 @@ static PyObject* alternating_checksum(const Orderbook *ob, const uint32_t depth, } int length; - if (EXPECT(build_alternating(ob, depth, separator, string_builder, signed_asks, false, &length), 0)) { + if (EXPECT(build_alternating(ob, buffer, depth, separator, string_builder, signed_asks, false, &length), 0)) { return NULL; } - return PyLong_FromUnsignedLong(crc32_orderbook(ob->checksum_buffer, length)); + return PyLong_FromUnsignedLong(crc32_orderbook(buffer, length)); } @@ -906,7 +944,7 @@ static bool bitfinex_rerender_needed(uint8_t *data, int length) } -static PyObject* bitfinex_checksum(const Orderbook *ob) +static PyObject* bitfinex_checksum(const Orderbook *ob, uint8_t *buffer) { if (EXPECT(ob->max_depth && ob->max_depth < 25, 0)) { PyErr_SetString(PyExc_ValueError, "Max depth is less than minimum number of levels for checksum"); @@ -914,31 +952,31 @@ static PyObject* bitfinex_checksum(const Orderbook *ob) } int length; - if (EXPECT(build_alternating(ob, 25, ':', str_string_builder, true, true, &length), 0)) { + if (EXPECT(build_alternating(ob, buffer, 25, ':', str_string_builder, true, true, &length), 0)) { return NULL; } - if (EXPECT(bitfinex_rerender_needed(ob->checksum_buffer, length), 0)) { - if (EXPECT(build_alternating(ob, 25, ':', bitfinex_string_builder, true, true, &length), 0)) { + if (EXPECT(bitfinex_rerender_needed(buffer, length), 0)) { + if (EXPECT(build_alternating(ob, buffer, 25, ':', bitfinex_string_builder, true, true, &length), 0)) { return NULL; } } - return PyLong_FromUnsignedLong(crc32_orderbook(ob->checksum_buffer, length)); + return PyLong_FromUnsignedLong(crc32_orderbook(buffer, length)); } -static PyObject* calculate_checksum(const Orderbook *ob) +static PyObject* calculate_checksum(const Orderbook *ob, uint8_t *buffer) { switch (ob->checksum) { case KRAKEN: - return kraken_checksum(ob); + return kraken_checksum(ob, buffer); case OKX: - return alternating_checksum(ob, 25, ':', okx_string_builder, false); + return alternating_checksum(ob, buffer, 25, ':', okx_string_builder, false); case BITGET: - return alternating_checksum(ob, 25, ':', str_string_builder, false); + return alternating_checksum(ob, buffer, 25, ':', str_string_builder, false); case BITFINEX: - return bitfinex_checksum(ob); + return bitfinex_checksum(ob, buffer); default: return NULL; } diff --git a/orderbook/orderbook.h b/orderbook/orderbook.h index fe2603b..e97f540 100644 --- a/orderbook/orderbook.h +++ b/orderbook/orderbook.h @@ -17,6 +17,8 @@ associated with this software. #include "sorteddict.h" +#define CHECKSUM_BUFFER_MAX 4096 + enum Checksums { CHECKSUM_PROCESSING_ERROR = -1, KRAKEN, @@ -31,12 +33,9 @@ typedef struct { SortedDict *bids; SortedDict *asks; uint32_t max_depth; - uint8_t *checksum_buffer; uint32_t checksum_len; enum Checksums checksum; bool truncate; - // see __init__ in orderbook.c - bool checksumming; } Orderbook; @@ -133,7 +132,7 @@ static PyModuleDef orderbookmodule = { // Checksum Definitions -static PyObject* calculate_checksum(const Orderbook *ob); +static PyObject* calculate_checksum(const Orderbook *ob, uint8_t *buffer); #endif diff --git a/orderbook/sorteddict.c b/orderbook/sorteddict.c index 13cf7bd..3c38828 100644 --- a/orderbook/sorteddict.c +++ b/orderbook/sorteddict.c @@ -15,12 +15,21 @@ static PyObject *SortedDict_iter_new(SortedDict *self, bool pairs); /* Sorted Dictionary */ void SortedDict_flush_pending(SortedDict *self) { - for (uint16_t i = 0; i < self->pend_count; ++i) { - Py_CLEAR(self->pend[i].key); + PyObject *keys[SD_PENDING_MAX]; + uint16_t count = self->pend_count; + + for (uint16_t i = 0; i < count; ++i) { + keys[i] = self->pend[i].key; + self->pend[i].key = NULL; } self->pend_count = 0; self->version++; + + // empty before anything is released + for (uint16_t i = 0; i < count; ++i) { + Py_DECREF(keys[i]); + } } @@ -43,15 +52,17 @@ void SortedDict_drop_key_cache(SortedDict *self) // install a freshly built key array, releasing the previous one afterwards -static void karr_install(SortedDict *self, PyObject **arr, Py_ssize_t len) +static void karr_install(SortedDict *self, PyObject **arr, Py_ssize_t len, PyObject *tuple) { PyObject **prev = self->karr; Py_ssize_t prev_len = self->k_len; + PyObject *prev_tuple = self->keys_tuple; self->karr = arr; self->k_len = len; - Py_CLEAR(self->keys_tuple); + self->keys_tuple = tuple; + Py_XDECREF(prev_tuple); if (prev) { for (Py_ssize_t i = 0; i < prev_len; ++i) { Py_XDECREF(prev[i]); @@ -102,8 +113,8 @@ PyObject *SortedDict_key_window(SortedDict *self, Py_ssize_t want) static void escalate_to_dirty(SortedDict *self) { - SortedDict_flush_pending(self); self->dirty = true; + SortedDict_flush_pending(self); } @@ -194,7 +205,7 @@ PyObject *SortedDict_new(PyTypeObject *type, PyObject *args, PyObject *kwds) } -int SortedDict_init(SortedDict *self, PyObject *args, PyObject *kwds) +static int locked_init(SortedDict *self, PyObject *args, PyObject *kwds) { PyObject *dict = NULL; @@ -219,10 +230,10 @@ int SortedDict_init(SortedDict *self, PyObject *args, PyObject *kwds) return -1; } - Py_XSETREF(self->data, copy); // the cached keys and pending log describe the old data - SortedDict_drop_key_cache(self); escalate_to_dirty(self); + SortedDict_drop_key_cache(self); + Py_XSETREF(self->data, copy); } @@ -298,6 +309,16 @@ int SortedDict_init(SortedDict *self, PyObject *args, PyObject *kwds) } +int SortedDict_init(SortedDict *self, PyObject *args, PyObject *kwds) +{ + int ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = locked_init(self, args, kwds); + Py_END_CRITICAL_SECTION(); + return ret; +} + + // bisect over the live key array. every compare can reenter and swap the // array out from under us, so the version is checked after each one. // ret >= 0 position, -1 exception, -2 the book mutated mid-search @@ -337,7 +358,9 @@ static int full_sort(SortedDict *self) for (int attempt = 0; ; ++attempt) { uint64_t version = self->version; - PyObject *keys = PyDict_Keys(self->data); + PyObject *data = Py_NewRef(self->data); + PyObject *keys = PyDict_Keys(data); + Py_DECREF(data); if (EXPECT(!keys, 0)) { return 1; } @@ -374,16 +397,14 @@ static int full_sort(SortedDict *self) } if (EXPECT(self->version == version, 1)) { - karr_install(self, arr, n); - Py_XSETREF(self->keys_tuple, tuple); self->dirty = false; + karr_install(self, arr, n, tuple); return 0; } if (attempt == 3) { // a comparator keeps mutating the book, so leave dirty - karr_install(self, arr, n); - Py_XSETREF(self->keys_tuple, tuple); + karr_install(self, arr, n, tuple); return 0; } @@ -679,31 +700,38 @@ static int apply_pending(SortedDict *self) /* internal helper function to update keys */ inline int update_keys(SortedDict *self) { - if (!self->dirty && self->karr) { - if (self->pend_count == 0) { - return 0; - } + while (true) { + if (!self->dirty && self->karr) { + if (self->pend_count == 0) { + return 0; + } - int applied = apply_pending(self); - if (applied == 0) { - return 0; + int applied = apply_pending(self); + if (applied < 0) { + return 1; + } + + if (applied == 0) { + continue; + } } - if (applied < 0) { + if (full_sort(self)) { return 1; } } - - return full_sort(self); } -static PyObject *build_items(SortedDict *self) +static PyObject *build_items_once(SortedDict *self, bool *moved) { + *moved = false; + if (EXPECT(update_keys(self), 0)) { return NULL; } + uint64_t version = self->version; PyObject *keys = karr_materialize(self); if (EXPECT(!keys, 0)) { return NULL; @@ -724,10 +752,12 @@ static PyObject *build_items(SortedDict *self) for (Py_ssize_t i = 0; i < len; ++i) { PyObject *key = PyTuple_GET_ITEM(keys, i); - PyObject *value = PyDict_GetItemWithError(data, key); + PyObject *value; + int found = PyDict_GetItemRef(data, key, &value); - if (EXPECT(!value, 0)) { - if (!PyErr_Occurred()) { + if (EXPECT(found <= 0, 0)) { + if (found == 0) { + *moved = (self->version != version); PyErr_SetObject(PyExc_KeyError, key); } Py_DECREF(ret); @@ -736,12 +766,13 @@ static PyObject *build_items(SortedDict *self) PyObject *entry = PyTuple_New(2); if (EXPECT(!entry, 0)) { + Py_DECREF(value); Py_DECREF(ret); goto error; } PyTuple_SET_ITEM(entry, 0, Py_NewRef(key)); - PyTuple_SET_ITEM(entry, 1, Py_NewRef(value)); + PyTuple_SET_ITEM(entry, 1, value); PyList_SET_ITEM(ret, i, entry); } @@ -756,7 +787,21 @@ static PyObject *build_items(SortedDict *self) } -PyObject* SortedDict_keys(SortedDict *self, PyObject *Py_UNUSED(ignored)) +static PyObject *build_items(SortedDict *self) +{ + bool moved; + PyObject *ret = build_items_once(self, &moved); + + for (int attempt = 0; !ret && moved && attempt < SD_READ_RETRIES; ++attempt) { + PyErr_Clear(); + ret = build_items_once(self, &moved); + } + + return ret; +} + + +static PyObject *locked_keys(SortedDict *self) { if (EXPECT(update_keys(self), 0)) { return NULL; @@ -775,6 +820,16 @@ PyObject* SortedDict_keys(SortedDict *self, PyObject *Py_UNUSED(ignored)) } +PyObject* SortedDict_keys(SortedDict *self, PyObject *Py_UNUSED(ignored)) +{ + PyObject *ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = locked_keys(self); + Py_END_CRITICAL_SECTION(); + return ret; +} + + // answer index(0) from the clean cache plus the pending log without merging static int peek_best(SortedDict *self, PyObject **out) { @@ -820,11 +875,19 @@ static int peek_best(SortedDict *self, PyObject **out) } Py_DECREF(cand); + if (EXPECT(self->version != version, 0)) { + goto done; + } } } // a pending insert can beat the best surviving old key for (Py_ssize_t p = 0; p < count; ++p) { + // the previous round ended in a release + if (EXPECT(self->version != version, 0)) { + goto done; + } + if (self->pend[p].op != PENDING_INSERT) { continue; } @@ -878,13 +941,13 @@ static int peek_best(SortedDict *self, PyObject **out) goto done; } - PyObject *value = PyDict_GetItemWithError(data, best); - if (EXPECT(!value, 0)) { - status = PyErr_Occurred() ? -1 : 1; + PyObject *value; + int found = PyDict_GetItemRef(data, best, &value); + if (EXPECT(found <= 0, 0)) { + status = (found < 0) ? -1 : 1; goto done; } - Py_INCREF(value); if (EXPECT(self->version != version, 0)) { Py_DECREF(value); goto done; @@ -909,26 +972,16 @@ static int peek_best(SortedDict *self, PyObject **out) } -PyObject* SortedDict_index(SortedDict *self, PyObject *index) +static PyObject *index_once(SortedDict *self, long i, bool *moved) { - long i = PyLong_AsLong(index); - if (EXPECT(PyErr_Occurred() != NULL, 0)) { - return NULL; - } - - if (i == 0 && !self->dirty && self->karr && self->pend_count) { - PyObject *ret = NULL; - int status = peek_best(self, &ret); - if (status <= 0) { - return ret; // NULL with the exception set when status < 0 - } - // status 1: fall through to the merge - } + *moved = false; if (EXPECT(update_keys(self), 0)) { return NULL; } + uint64_t version = self->version; + // validate against max_depth Py_ssize_t len = self->k_len; if ((self->depth > 0) && (self->depth < len)) { @@ -946,11 +999,13 @@ PyObject* SortedDict_index(SortedDict *self, PyObject *index) } PyObject *key = Py_NewRef(self->karr[i]); - - // borrowed reference - PyObject *value = PyDict_GetItemWithError(self->data, key); - if (EXPECT(!value, 0)) { - if (!PyErr_Occurred()) { + PyObject *value; + PyObject *data = Py_NewRef(self->data); + int found = PyDict_GetItemRef(data, key, &value); + Py_DECREF(data); + if (EXPECT(found <= 0, 0)) { + if (found == 0) { + *moved = (self->version != version); PyErr_SetObject(PyExc_KeyError, key); } Py_DECREF(key); @@ -959,17 +1014,55 @@ PyObject* SortedDict_index(SortedDict *self, PyObject *index) PyObject *ret = PyTuple_New(2); if (EXPECT(!ret, 0)) { + Py_DECREF(value); Py_DECREF(key); return NULL; } PyTuple_SET_ITEM(ret, 0, key); - PyTuple_SET_ITEM(ret, 1, Py_NewRef(value)); + PyTuple_SET_ITEM(ret, 1, value); + + return ret; +} + + +static PyObject *locked_index(SortedDict *self, long i) +{ + if (i == 0 && !self->dirty && self->karr && self->pend_count) { + PyObject *ret = NULL; + int status = peek_best(self, &ret); + if (status <= 0) { + return ret; + } + } + + bool moved; + PyObject *ret = index_once(self, i, &moved); + + for (int attempt = 0; !ret && moved && attempt < SD_READ_RETRIES; ++attempt) { + PyErr_Clear(); + ret = index_once(self, i, &moved); + } return ret; } +PyObject* SortedDict_index(SortedDict *self, PyObject *index) +{ + long i = PyLong_AsLong(index); + if (EXPECT(PyErr_Occurred() != NULL, 0)) { + return NULL; + } + + PyObject *ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = locked_index(self, i); + Py_END_CRITICAL_SECTION(); + return ret; +} + + static int convert_item(PyObject **obj, PyObject *from, PyObject *to) { if (from) { @@ -993,12 +1086,16 @@ static int convert_item(PyObject **obj, PyObject *from, PyObject *to) } -PyObject* SortedDict_todict_impl(SortedDict *self, PyObject *from, PyObject *to) +static PyObject *todict_once(SortedDict *self, PyObject *from, PyObject *to, bool *moved) { + *moved = false; + if (EXPECT(update_keys(self), 0)) { return NULL; } + uint64_t version = self->version; + // the book may be mutated by a key hash or a conversion callback below, // hold both so the snapshot stays intact PyObject *keys = karr_materialize(self); @@ -1024,16 +1121,18 @@ PyObject* SortedDict_todict_impl(SortedDict *self, PyObject *from, PyObject *to) } for (Py_ssize_t i = 0; i < len; ++i) { - PyObject *value = PyDict_GetItemWithError(data, PyTuple_GET_ITEM(keys, i)); + PyObject *value; + int found = PyDict_GetItemRef(data, PyTuple_GET_ITEM(keys, i), &value); - if (EXPECT(!value, 0)) { - if (!PyErr_Occurred()) { + if (EXPECT(found <= 0, 0)) { + if (found == 0) { + *moved = (self->version != version); PyErr_SetObject(PyExc_KeyError, PyTuple_GET_ITEM(keys, i)); } goto error; } - PyList_SET_ITEM(values, i, Py_NewRef(value)); + PyList_SET_ITEM(values, i, value); } } @@ -1060,16 +1159,16 @@ PyObject* SortedDict_todict_impl(SortedDict *self, PyObject *from, PyObject *to) Py_DECREF(key); Py_DECREF(value); } else { - value = PyDict_GetItemWithError(data, key); - if (EXPECT(!value, 0)) { - if (!PyErr_Occurred()) { + int found = PyDict_GetItemRef(data, key, &value); + if (EXPECT(found <= 0, 0)) { + if (found == 0) { + *moved = (self->version != version); PyErr_SetObject(PyExc_KeyError, key); } goto error; } - // the insert hashes the key, which can drop the book's value ref - Py_INCREF(value); + // the insert hashes the key, which can drop the book ref failed = PyDict_SetItem(ret, key, value) < 0; Py_DECREF(value); } @@ -1093,6 +1192,20 @@ PyObject* SortedDict_todict_impl(SortedDict *self, PyObject *from, PyObject *to) } +PyObject* locked_SortedDict_todict(SortedDict *self, PyObject *from, PyObject *to) +{ + bool moved; + PyObject *ret = todict_once(self, from, to, &moved); + + for (int attempt = 0; !ret && moved && attempt < SD_READ_RETRIES; ++attempt) { + PyErr_Clear(); + ret = todict_once(self, from, to, &moved); + } + + return ret; +} + + PyObject* SortedDict_todict(SortedDict *self, PyObject *unused, PyObject *kwargs) { static char *kwlist[] = {"from_type", "to_type", NULL}; @@ -1103,13 +1216,21 @@ PyObject* SortedDict_todict(SortedDict *self, PyObject *unused, PyObject *kwargs return NULL; } - return SortedDict_todict_impl(self, from, to); + PyObject *ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = locked_SortedDict_todict(self, from, to); + Py_END_CRITICAL_SECTION(); + return ret; } PyObject* SortedDict_tolist(SortedDict *self, PyObject *Py_UNUSED(ignored)) { - return build_items(self); + PyObject *ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = build_items(self); + Py_END_CRITICAL_SECTION(); + return ret; } @@ -1136,15 +1257,22 @@ static int truncate_to_depth(SortedDict *self) return -1; } Py_INCREF(keys); + PyObject *data = Py_NewRef(self->data); for (Py_ssize_t i = self->depth; i < size; ++i) { - if (EXPECT(PyDict_DelItem(self->data, PyTuple_GET_ITEM(keys, i)) == -1, 0)) { - escalate_to_dirty(self); - Py_DECREF(keys); - return -1; + if (EXPECT(PyDict_DelItem(data, PyTuple_GET_ITEM(keys, i)) == -1, 0)) { + if (!PyErr_ExceptionMatches(PyExc_KeyError)) { + escalate_to_dirty(self); + Py_DECREF(data); + Py_DECREF(keys); + return -1; + } + PyErr_Clear(); } } + Py_DECREF(data); + if (EXPECT(self->version == version, 1)) { // evictions only come off the tail: shrink in place. the evicted // refs are released only after the array is consistent @@ -1161,6 +1289,7 @@ static int truncate_to_depth(SortedDict *self) } self->k_len = self->depth; + self->version++; Py_CLEAR(self->keys_tuple); for (Py_ssize_t i = 0; i < size - self->depth; ++i) { @@ -1181,7 +1310,12 @@ static int truncate_to_depth(SortedDict *self) PyObject* SortedDict_truncate(SortedDict *self, PyObject *Py_UNUSED(ignored)) { - if (EXPECT(truncate_to_depth(self), 0)) { + int ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = truncate_to_depth(self); + Py_END_CRITICAL_SECTION(); + + if (EXPECT(ret, 0)) { return NULL; } @@ -1190,7 +1324,7 @@ PyObject* SortedDict_truncate(SortedDict *self, PyObject *Py_UNUSED(ignored)) /* Sorted Dictionary Mapping Functions */ -Py_ssize_t SortedDict_len(const SortedDict *self) +Py_ssize_t locked_SortedDict_len(const SortedDict *self) { Py_ssize_t len = PyDict_GET_SIZE(self->data); if (self->depth && self->depth < len) { @@ -1200,22 +1334,46 @@ Py_ssize_t SortedDict_len(const SortedDict *self) return len; } + +Py_ssize_t SortedDict_len(const SortedDict *self) +{ + Py_ssize_t ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = locked_SortedDict_len(self); + Py_END_CRITICAL_SECTION(); + return ret; +} + + +PyObject *SortedDict_get_data(SortedDict *self, void *Py_UNUSED(closure)) +{ + PyObject *ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = Py_NewRef(self->data); + Py_END_CRITICAL_SECTION(); + return ret; +} + PyObject *SortedDict_getitem(SortedDict *self, PyObject *key) { - PyObject *ret = PyDict_GetItemWithError(self->data, key); - if (ret) { - Py_INCREF(ret); - return ret; - } + PyObject *ret; + int found; + + Py_BEGIN_CRITICAL_SECTION(self); + PyObject *data = Py_NewRef(self->data); + found = PyDict_GetItemRef(data, key, &ret); + Py_DECREF(data); + Py_END_CRITICAL_SECTION(); - if (EXPECT(!PyErr_Occurred(), 0)) { + if (EXPECT(found == 0, 0)) { PyErr_SetString(PyExc_KeyError, "key does not exist"); } return ret; } -int SortedDict_setitem(SortedDict *self, PyObject *key, PyObject *value) + +static int locked_setitem(SortedDict *self, PyObject *key, PyObject *value) { bool cache_live = (!self->dirty && self->karr != NULL); uint64_t version = self->version; @@ -1228,28 +1386,34 @@ int SortedDict_setitem(SortedDict *self, PyObject *key, PyObject *value) version = self->version; } + PyObject *data = Py_NewRef(self->data); + if (value) { - Py_ssize_t before = PyDict_GET_SIZE(self->data); - int ret = PyDict_SetItem(self->data, key, value); + Py_ssize_t before = PyDict_GET_SIZE(data); + int ret = PyDict_SetItem(data, key, value); if (EXPECT(ret == -1, 0)) { + Py_DECREF(data); return ret; } - if (PyDict_GET_SIZE(self->data) == before && self->version == version) { + if (PyDict_GET_SIZE(data) == before && self->version == version) { // in place value update, the key set did not change + Py_DECREF(data); return ret; } if (!cache_live) { self->dirty = true; self->version++; - } else if (PyDict_GET_SIZE(self->data) == before + 1 && self->version == version) { + } else if (PyDict_GET_SIZE(data) == before + 1 && self->version == version) { log_append(self, PENDING_INSERT, key); } else { escalate_to_dirty(self); } + Py_DECREF(data); + if (EXPECT(self->truncate && truncate_to_depth(self), 0)) { return -1; } @@ -1257,7 +1421,35 @@ int SortedDict_setitem(SortedDict *self, PyObject *key, PyObject *value) return ret; } else { // setitem also called for del (value will be null for deletes) - int ret = PyDict_DelItem(self->data, key); +#ifdef Py_GIL_DISABLED + int present = PyDict_Contains(data, key); + if (EXPECT(present <= 0, 0)) { + Py_DECREF(data); + if (present == 0) { + PyErr_SetObject(PyExc_KeyError, key); + } + return -1; + } + + if (!cache_live) { + self->dirty = true; + self->version++; + } else if (self->version == version) { + log_append(self, PENDING_DELETE, key); + } else { + escalate_to_dirty(self); + } + + int ret = PyDict_DelItem(data, key); + Py_DECREF(data); + if (EXPECT(ret != 0, 0)) { + escalate_to_dirty(self); + } + + return ret; +#else + int ret = PyDict_DelItem(data, key); + Py_DECREF(data); if (ret != 0) { // a failed delete leaves the cache and log untouched return ret; @@ -1273,13 +1465,30 @@ int SortedDict_setitem(SortedDict *self, PyObject *key, PyObject *value) } return ret; +#endif } } +int SortedDict_setitem(SortedDict *self, PyObject *key, PyObject *value) +{ + int ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = locked_setitem(self, key, value); + Py_END_CRITICAL_SECTION(); + return ret; +} + + /* Seq Functions */ int SortedDict_contains(const SortedDict *self, PyObject *value) { - return PyDict_Contains(self->data, value); + int ret; + Py_BEGIN_CRITICAL_SECTION(self); + PyObject *data = Py_NewRef(self->data); + ret = PyDict_Contains(data, value); + Py_DECREF(data); + Py_END_CRITICAL_SECTION(); + return ret; } /* side iterator */ @@ -1288,6 +1497,7 @@ static void SortedDictIter_dealloc(SortedDictIter *self) PyObject_GC_UnTrack(self); Py_CLEAR(self->keys); Py_CLEAR(self->data); + Py_CLEAR(self->owner); PyObject_GC_Del(self); } @@ -1296,6 +1506,7 @@ static int SortedDictIter_traverse(SortedDictIter *self, visitproc visit, void * { Py_VISIT(self->keys); Py_VISIT(self->data); + Py_VISIT(self->owner); return 0; } @@ -1305,6 +1516,7 @@ static int SortedDictIter_clear(SortedDictIter *self) { Py_CLEAR(self->keys); Py_CLEAR(self->data); + Py_CLEAR(self->owner); return 0; } @@ -1312,27 +1524,31 @@ static int SortedDictIter_clear(SortedDictIter *self) static PyObject *SortedDictIter_next(SortedDictIter *self) { - if (self->index >= self->len) { + Py_ssize_t index = self->index; + if (index >= self->len) { return NULL; } - PyObject *key = PyTuple_GET_ITEM(self->keys, self->index); + PyObject *key = PyTuple_GET_ITEM(self->keys, index); if (!self->pairs) { - self->index++; + self->index = index + 1; return Py_NewRef(key); } - PyObject *value = PyDict_GetItemWithError(self->data, key); - if (EXPECT(!value, 0)) { - if (!PyErr_Occurred()) { + PyObject *value; + int found; + Py_BEGIN_CRITICAL_SECTION(self->owner); + found = PyDict_GetItemRef(self->data, key, &value); + Py_END_CRITICAL_SECTION(); + if (EXPECT(found <= 0, 0)) { + if (found == 0) { // the level was deleted mid iteration so raise PyErr_SetObject(PyExc_KeyError, key); } return NULL; } - Py_INCREF(value); PyObject *ret = PyTuple_New(2); if (EXPECT(!ret, 0)) { Py_DECREF(value); @@ -1341,7 +1557,7 @@ static PyObject *SortedDictIter_next(SortedDictIter *self) PyTuple_SET_ITEM(ret, 0, Py_NewRef(key)); PyTuple_SET_ITEM(ret, 1, value); - self->index++; + self->index = index + 1; return ret; } @@ -1361,7 +1577,7 @@ PyTypeObject SortedDictIterType = { }; -static PyObject *SortedDict_iter_new(SortedDict *self, bool pairs) +static PyObject *locked_iter_new(SortedDict *self, bool pairs) { if (EXPECT(update_keys(self), 0)) { return NULL; @@ -1379,6 +1595,7 @@ static PyObject *SortedDict_iter_new(SortedDict *self, bool pairs) it->keys = Py_NewRef(snapshot); it->data = Py_NewRef(self->data); + it->owner = Py_NewRef((PyObject *)self); it->index = 0; it->pairs = pairs; @@ -1394,6 +1611,16 @@ static PyObject *SortedDict_iter_new(SortedDict *self, bool pairs) } +static PyObject *SortedDict_iter_new(SortedDict *self, bool pairs) +{ + PyObject *ret; + Py_BEGIN_CRITICAL_SECTION(self); + ret = locked_iter_new(self, pairs); + Py_END_CRITICAL_SECTION(); + return ret; +} + + PyObject *SortedDict_getiter(SortedDict *self) { return SortedDict_iter_new(self, false); diff --git a/orderbook/sorteddict.h b/orderbook/sorteddict.h index dfca6ce..099b4ce 100644 --- a/orderbook/sorteddict.h +++ b/orderbook/sorteddict.h @@ -25,6 +25,7 @@ enum Ordering { // pending key changes tracked. once the limit is hit, full sort of the cache #define SD_PENDING_MAX 64 +#define SD_READ_RETRIES 64 // changes to the keys are either inserts or deletes enum PendingOp { @@ -40,7 +41,7 @@ typedef struct { typedef struct { PyObject_HEAD PyObject *data; - // the sorted key cache: a plain array of owned refs. merges move pointers between arrays + // the sorted key cache - a plain array of owned refs. merges move pointers between arrays PyObject **karr; Py_ssize_t k_len; // consumers that need immutable snapshot get this lazily built tuple of karr, cached until the key set changes @@ -50,7 +51,7 @@ typedef struct { int depth; uint16_t pend_count; bool truncate; - // set when only a full re-sort can rebuild the cache. changes that are pended are should not toggle + // set when only a full sort can rebuild the cache. changes that are pended are should not toggle bool dirty; PendingEntry pend[SD_PENDING_MAX]; } SortedDict; @@ -61,6 +62,7 @@ typedef struct { PyObject_HEAD PyObject *keys; PyObject *data; + PyObject *owner; Py_ssize_t index; Py_ssize_t len; // obeys max_depth bool pairs; @@ -79,12 +81,13 @@ int SortedDict_clear(SortedDict *self); PyObject* SortedDict_keys(SortedDict *self, PyObject *Py_UNUSED(ignored)); PyObject* SortedDict_index(SortedDict *self, PyObject *index); PyObject* SortedDict_todict(SortedDict *self, PyObject *unused, PyObject *kwargs); -PyObject* SortedDict_todict_impl(SortedDict *self, PyObject *from, PyObject *to); +PyObject* locked_SortedDict_todict(SortedDict *self, PyObject *from, PyObject *to); PyObject* SortedDict_tolist(SortedDict *self, PyObject *Py_UNUSED(ignored)); PyObject* SortedDict_items(SortedDict *self, PyObject *Py_UNUSED(ignored)); PyObject* SortedDict_truncate(SortedDict *self, PyObject *Py_UNUSED(ignored)); Py_ssize_t SortedDict_len(const SortedDict *self); +Py_ssize_t locked_SortedDict_len(const SortedDict *self); PyObject *SortedDict_getitem(SortedDict *self, PyObject *key); int SortedDict_setitem(SortedDict *self, PyObject *key, PyObject *value); @@ -95,13 +98,19 @@ PyObject *SortedDict_getiter(SortedDict *self); // SortedDict class members static PyMemberDef SortedDict_members[] = { - {"__data", T_OBJECT_EX, offsetof(SortedDict, data), READONLY, "internal data"}, {"__ordering", T_INT, offsetof(SortedDict, ordering), 0, "ordering flag"}, {"__truncate", T_BOOL, offsetof(SortedDict, truncate), 0, "truncate flag"}, {"__max_depth", T_INT, offsetof(SortedDict, depth), 0, "maximum depth"}, {NULL} }; +PyObject *SortedDict_get_data(SortedDict *self, void *closure); + +static PyGetSetDef SortedDict_getset[] = { + {"__data", (getter) SortedDict_get_data, NULL, "internal data", NULL}, + {NULL} +}; + // SortedDict methods static PyMethodDef SortedDict_methods[] = { {"keys", (PyCFunction) SortedDict_keys, METH_NOARGS, "return a list of keys in the sorted dictionary"}, @@ -140,6 +149,7 @@ static PyTypeObject SortedDictType = { .tp_traverse = (traverseproc) SortedDict_traverse, .tp_clear = (inquiry) SortedDict_clear, .tp_members = SortedDict_members, + .tp_getset = SortedDict_getset, .tp_methods = SortedDict_methods, .tp_as_mapping = &SortedDict_mapping, .tp_as_sequence = &SortedDict_seq, diff --git a/perf/performance_test.py b/perf/performance_test.py index 4b9668d..afec615 100644 --- a/perf/performance_test.py +++ b/perf/performance_test.py @@ -20,6 +20,7 @@ from pathlib import Path from time import perf_counter_ns +import order_book from order_book import OrderBook from pyorderbook import OrderBook as PyOrderBook @@ -650,6 +651,7 @@ def main(): 'seed': args.seed, 'ops': args.ops, 'python_ops': args.python_ops, 'depth': args.depth, 'python': platform.python_version(), 'machine': platform.machine(), 'timer_resolution_ns': resolution, + 'so': order_book.__file__, }} print(f'order_book real-data benchmark -- {l2_snap["product"]} snapshot ' diff --git a/pyproject.toml b/pyproject.toml index 561dc9d..cd9a3f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "A fast orderbook implementation, in C, for Python" authors = [{name = "Bryant Moscon", email = "bmoscon@gmail.com"}] license = "GPL-3.0-or-later" license-files = ["LICENSE"] -requires-python = ">=3.12" +requires-python = ">=3.13" keywords = ["market data", "trading", "order book", "MBO", "L2", "L3", "LOB"] dynamic = ["readme"] classifiers = [ @@ -17,10 +17,10 @@ classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: C", "Programming Language :: Python", - "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Free Threading :: 3 - Stable", "Programming Language :: Python :: Implementation :: CPython", "Operating System :: MacOS", "Operating System :: POSIX :: Linux", diff --git a/tests/test_threading.py b/tests/test_threading.py new file mode 100644 index 0000000..8ce7f38 --- /dev/null +++ b/tests/test_threading.py @@ -0,0 +1,169 @@ +''' +Copyright (C) 2020-2026 Bryant Moscon - bmoscon@gmail.com + +Please see the LICENSE file for the terms and conditions +associated with this software. +''' +import random +import sys +import sysconfig +import threading +from decimal import Decimal + +import pytest + +from order_book import OrderBook, SortedDict + + +FREE_THREADED = bool(sysconfig.get_config_var('Py_GIL_DISABLED')) +THREADS = 8 +ITERATIONS = 5000 + + +def run_threads(target, count=THREADS): + barrier = threading.Barrier(count) + errors = [] + + def wrapped(seed): + barrier.wait() + try: + target(seed) + except Exception as e: # noqa: BLE001 + errors.append(e) + + threads = [threading.Thread(target=wrapped, args=(seed,)) for seed in range(count)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, errors + + +def check_side(side, reverse): + keys = side.keys() + assert list(keys) == sorted(keys, reverse=reverse) + assert list(keys) == sorted(side.to_dict().keys(), reverse=reverse) + assert [k for k, _ in side.to_list()] == list(keys) + assert len(keys) == len(side) + for key in keys: + assert key in side + side[key] + + +def read_side(side): + side.keys() + if len(side): + try: + side.index(0) + except IndexError: + pass + try: + list(side.items()) + except KeyError: + pass + side.to_list() + + +@pytest.mark.skipif(not FREE_THREADED, reason='free-threaded build only') +def test_import_leaves_gil_disabled(): + assert not sys._is_gil_enabled() + + +def test_shared_sorteddict_churn(): + sd = SortedDict(ordering='DESC') + + def churn(seed): + rng = random.Random(seed) + for i in range(ITERATIONS): + key = Decimal(rng.randint(1, 300)) + r = rng.random() + if r < 0.5: + sd[key] = Decimal(i) + elif r < 0.8: + try: + del sd[key] + except KeyError: + pass + else: + read_side(sd) + + run_threads(churn) + check_side(sd, reverse=True) + + +def test_shared_sorteddict_truncate(): + depth = 20 + sd = SortedDict(ordering='ASC', max_depth=depth, truncate=True) + + def churn(seed): + rng = random.Random(seed) + for i in range(ITERATIONS): + sd[Decimal(rng.randint(1, 1000))] = Decimal(i) + if i % 50 == 0: + sd.truncate() + read_side(sd) + + run_threads(churn) + assert len(getattr(sd, '__data')) <= depth + check_side(sd, reverse=False) + + +@pytest.mark.parametrize('fmt', ['KRAKEN', 'OKX', 'BITFINEX']) +def test_shared_orderbook_writers_and_readers(fmt): + ob = OrderBook(checksum_format=fmt, max_depth=50) + + def writer(seed): + rng = random.Random(seed) + for i in range(ITERATIONS): + side = ob.bids if rng.random() < 0.5 else ob.asks + price = Decimal(rng.randint(1, 500)) + r = rng.random() + if r < 0.6: + side[price] = Decimal(i) + elif r < 0.95: + try: + del side[price] + except KeyError: + pass + else: + # replace a whole side + ob['bid' if side is ob.bids else 'ask'] = {Decimal(p): Decimal(1) for p in range(1, 30)} + + def reader(seed): + for _ in range(ITERATIONS // 5): + assert isinstance(ob.checksum(), int) + d = ob.to_dict() + assert set(d) == {'bid', 'ask'} + len(ob) + read_side(ob.bids) + read_side(ob.asks) + + def worker(seed): + if seed % 2: + reader(seed) + else: + writer(seed) + + run_threads(worker) + check_side(ob.bids, reverse=True) + check_side(ob.asks, reverse=False) + assert isinstance(ob.checksum(), int) + + +def test_shared_iterator(): + sd = SortedDict(ordering='ASC') + for i in range(2000): + sd[Decimal(i)] = Decimal(i) + + it = sd.items() + seen = [] + lock = threading.Lock() + + def drain(seed): + for pair in it: + with lock: + seen.append(pair) + + run_threads(drain) + assert set(seen) == set(sd.to_list()) diff --git a/uv.lock b/uv.lock index 7804ead..8ea575d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.12" +requires-python = ">=3.13" [[package]] name = "certifi" @@ -17,22 +17,6 @@ version = "3.5.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, - { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, - { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, - { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, - { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, - { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, - { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, - { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, - { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, @@ -171,7 +155,7 @@ wheels = [ [[package]] name = "order-book" -version = "1.0.2" +version = "1.1.0" source = { editable = "." } [package.optional-dependencies]