From 31cf0377bc2e28cc286a5f4c332694cd1c16d8da Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 14 Jun 2026 15:12:40 +0100 Subject: [PATCH] Partition Python cache hot storage --- packages/cultcache-py/PARITY.md | 9 ++- packages/cultcache-py/README.md | 6 +- .../cultcache-py/src/cultcache_py/cache.py | 74 ++++++++++++------- 3 files changed, 57 insertions(+), 32 deletions(-) diff --git a/packages/cultcache-py/PARITY.md b/packages/cultcache-py/PARITY.md index 4a4544d0..4d304524 100644 --- a/packages/cultcache-py/PARITY.md +++ b/packages/cultcache-py/PARITY.md @@ -184,6 +184,8 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti. benchmarks with the same operation names and reports median Python-to-C# ratios from three samples by default. It also emits per-metric threshold checks and an overall parity status so performance claims have an explicit evidence gate. + The Python in-memory record owner is type-partitioned so public `get(...)` + reads avoid per-call composite-key allocation on the hot path. ## Local Python Gates @@ -206,9 +208,10 @@ node --test packages\cultnet-ts\dist-test\test\interop\cultnet-interop.test.js ## Still Not Claimed -- Python does not claim to match the C# runtime for raw throughput. It has a - measured local baseline plus a shared public `CultCache` upsert/get comparison - harness with threshold status, not a performance victory certificate. +- Python does not claim to match the C# runtime for every raw-throughput + workload. It has a measured local baseline plus a shared public `CultCache` + upsert/get comparison harness with threshold status, not a universal + performance victory certificate. - Python does not own LiteNetLib security/session behavior; it speaks the schema-v0 interop lane used by the package harness. - Python does not implement the full C# daemon/server stack. Its current role is diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index b3701ce1..63bde028 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -418,5 +418,7 @@ registered cache, and public `CultCache` upsert/get throughput. The C# compare command also runs `packages/cultcache-py/tools/GameCult.Caching.Benchmark` and reports median Python-to-C# ratios for the shared public cache operations. It uses three samples by default; pass `--samples` when you need a different -evidence shape. Treat those numbers as a local baseline, not a claim that -Python matches the C# reference in every workload. +evidence shape. The public in-memory cache owner stores records in +type-partitioned dictionaries so hot `get(...)` calls avoid per-call +composite-key allocation. Treat the benchmark numbers as a local baseline, not +a claim that Python matches the C# reference in every workload. diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index ca4bd72f..0ffdd18a 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -17,8 +17,8 @@ class CultCacheError(RuntimeError): @dataclass class _State: documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) - values: dict[tuple[str, str], Any] = field(default_factory=dict) - envelopes: dict[tuple[str, str], CultCacheEnvelope] = field(default_factory=dict) + values: dict[str, dict[str, Any]] = field(default_factory=dict) + envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict) stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict) generic_stores: list[BackingStore] = field(default_factory=list) name_extractors: dict[str, str | Any] = field(default_factory=dict) @@ -113,13 +113,14 @@ class CultCache: raise CultCacheError(f"Duplicate global document for type: {envelope.type}") seen_globals.add(envelope.type) value = document.decode_payload(envelope.payload) - self._state.values[(envelope.type, envelope.key)] = value - self._state.envelopes[(envelope.type, envelope.key)] = envelope + self._state.values.setdefault(envelope.type, {})[envelope.key] = value + self._state.envelopes.setdefault(envelope.type, {})[envelope.key] = envelope self._rebuild_indexes() def get(self, document: DocumentDefinition[T], key: str) -> T | None: self._assert_registered(document) - return self._state.values.get((document.type, key)) + values = self._state.values.get(document.type) + return None if values is None else values.get(key) def get_required(self, document: DocumentDefinition[T], key: str) -> T: value = self.get(document, key) @@ -129,11 +130,12 @@ class CultCache: def get_all(self, document: DocumentDefinition[T]) -> list[T]: self._assert_registered(document) - return [value for (type, _), value in self._state.values.items() if type == document.type] + return list(self._state.values.get(document.type, {}).values()) def get_envelope(self, document: DocumentDefinition[Any], key: str) -> CultCacheEnvelope | None: self._assert_registered(document) - return self._state.envelopes.get((document.type, key)) + envelopes = self._state.envelopes.get(document.type) + return None if envelopes is None else envelopes.get(key) def get_required_envelope(self, document: DocumentDefinition[Any], key: str) -> CultCacheEnvelope: envelope = self.get_envelope(document, key) @@ -169,7 +171,9 @@ class CultCache: self._assert_registered(document) if document.global_document and key != GLOBAL_KEY: raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") - old_value = self._state.values.get((document.type, key)) + values = self._state.values.setdefault(document.type, {}) + envelopes = self._state.envelopes.setdefault(document.type, {}) + old_value = values.get(key) catalog_entry = document.catalog_entry() envelope = CultCacheEnvelope.create( key=key, @@ -183,8 +187,8 @@ class CultCache: store.push(envelope) if old_value is not None: self._remove_value_indexes(document.type, key, old_value) - self._state.values[(document.type, key)] = value - self._state.envelopes[(document.type, key)] = envelope + values[key] = value + envelopes[key] = envelope self._add_value_indexes(document.type, key, value) def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T: @@ -193,14 +197,16 @@ class CultCache: raise CultCacheError( f"Envelope type {envelope.type} does not match document type {document.type}" ) - old_value = self._state.values.get((document.type, envelope.key)) + values = self._state.values.setdefault(document.type, {}) + envelopes = self._state.envelopes.setdefault(document.type, {}) + old_value = values.get(envelope.key) value = document.decode_payload(envelope.payload) for store in self._stores_for_type(document.type): store.push(envelope) if old_value is not None: self._remove_value_indexes(document.type, envelope.key, old_value) - self._state.values[(document.type, envelope.key)] = value - self._state.envelopes[(document.type, envelope.key)] = envelope + values[envelope.key] = value + envelopes[envelope.key] = envelope self._add_value_indexes(document.type, envelope.key, value) return value @@ -219,12 +225,14 @@ class CultCache: stores = self._stores_for_type(document.type) for store in stores: store.push_all(envelopes) + values_by_key = self._state.values.setdefault(document.type, {}) + envelopes_by_key = self._state.envelopes.setdefault(document.type, {}) for envelope, value in zip(envelopes, values): - old_value = self._state.values.get((document.type, envelope.key)) + old_value = values_by_key.get(envelope.key) if old_value is not None: self._remove_value_indexes(document.type, envelope.key, old_value) - self._state.values[(document.type, envelope.key)] = value - self._state.envelopes[(document.type, envelope.key)] = envelope + values_by_key[envelope.key] = value + envelopes_by_key[envelope.key] = envelope self._add_value_indexes(document.type, envelope.key, value) return values @@ -246,8 +254,15 @@ class CultCache: self._assert_registered(document) for store in self._stores_for_type(document.type): store.delete(document.type, key) - old_value = self._state.values.pop((document.type, key), None) - self._state.envelopes.pop((document.type, key), None) + values = self._state.values.get(document.type) + old_value = None if values is None else values.pop(key, None) + if values == {}: + self._state.values.pop(document.type, None) + envelopes = self._state.envelopes.get(document.type) + if envelopes is not None: + envelopes.pop(key, None) + if envelopes == {}: + self._state.envelopes.pop(document.type, None) if old_value is not None: self._remove_value_indexes(document.type, key, old_value) @@ -257,12 +272,16 @@ class CultCache: def snapshot(self) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} - for (type, key), value in self._state.values.items(): - out.setdefault(type, {})[key] = value + for type, values in self._state.values.items(): + out[type] = dict(values) return out def snapshot_envelopes(self) -> list[CultCacheEnvelope]: - return list(self._state.envelopes.values()) + return [ + envelope + for envelopes in self._state.envelopes.values() + for envelope in envelopes.values() + ] def _stores_for_type(self, type: str) -> list[BackingStore]: specific = self._state.stores_by_type.get(type) @@ -282,8 +301,9 @@ class CultCache: def _rebuild_indexes(self) -> None: self._state.names.clear() self._state.indexes.clear() - for (type, key), value in self._state.values.items(): - self._add_value_indexes(type, key, value) + for type, values in self._state.values.items(): + for key, value in values.items(): + self._add_value_indexes(type, key, value) def _add_value_indexes(self, type: str, key: str, value: Any) -> None: name_extractor = self._state.name_extractors.get(type) @@ -315,9 +335,9 @@ class CultCache: name_extractor = self._state.name_extractors.get(type) if name_extractor is None: return - for (candidate_type, candidate_key), candidate in self._state.values.items(): + for candidate_key, candidate in self._state.values.get(type, {}).items(): candidate_name = extract_value(candidate, name_extractor) - if candidate_type == type and candidate_key != key and candidate_name is not None and str(candidate_name) == name: + if candidate_key != key and candidate_name is not None and str(candidate_name) == name: self._state.names[lookup_key] = candidate_key def _remove_secondary_index(self, type: str, key: str, index: str, value: str) -> None: @@ -328,9 +348,9 @@ class CultCache: extractor = self._state.index_extractors.get(type, {}).get(index) if extractor is None: return - for (candidate_type, candidate_key), candidate in self._state.values.items(): + for candidate_key, candidate in self._state.values.get(type, {}).items(): candidate_value = extract_value(candidate, extractor) - if candidate_type == type and candidate_key != key and candidate_value is not None and str(candidate_value) == value: + if candidate_key != key and candidate_value is not None and str(candidate_value) == value: self._state.indexes[lookup_key] = candidate_key def _assert_registered(self, document: DocumentDefinition[Any]) -> None: