Skip to content

Commit e44bc09

Browse files
authored
Merge pull request #278 from Point72/tkp/staterework
rework state to remove annoyances and inconsistensies, prep for Stage and UI APIs
2 parents e81e130 + 8abadad commit e44bc09

27 files changed

Lines changed: 613 additions & 338 deletions

File tree

.github/workflows/build.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,13 @@ jobs:
111111
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
112112
with:
113113
name: test-results-${{ matrix.os }}-${{ matrix.python-version }}
114-
path: '**/junit.xml'
114+
path: junit.xml
115115
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
116116

117117
- name: Publish Unit Test Results
118118
uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2
119119
with:
120-
files: '**/junit.xml'
120+
files: junit.xml
121121
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11'
122122

123123
- name: Upload coverage

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ test-py: ## run python tests
9797
tests-py: test-py
9898

9999
coverage-py: ## run python tests and collect test coverage
100-
python -m pytest -v csp_gateway/tests --cov=csp_gateway --cov-report term-missing --cov-report xml
100+
python -m pytest -v csp_gateway/tests --junitxml=junit.xml --cov=csp_gateway --cov-report term-missing --cov-report xml
101101

102102
.PHONY: test-js tests-js coverage-js
103103
test-js: ## run js tests

csp_gateway/server/demo/omnibus.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,14 @@ class ExampleGatewayChannels(GatewayChannels):
115115
example_list: ts[List[ExampleData]] = None
116116
never_ticks: ts[ExampleData] = None
117117

118-
s_example: ts[State[ExampleData]] = None
118+
# State fields can be added via annotation or the `set_state` API in the module's `connect` method
119+
example_with_state: Annotated[ts[ExampleData], State(("id", "x"))] = None
120+
example_with_state_multiple: Annotated[
121+
ts[ExampleData],
122+
State(("id", "x")),
123+
State(("id", "y"), alias="example_with_state_alternative"),
124+
] = None
125+
# example: Set in connect with a different schema, to show flexibility of state definition
119126

120127
basket: Dict[ExampleEnum, ts[ExampleData]] = None
121128
str_basket: Dict[str, ts[ExampleData]] = None
@@ -179,6 +186,8 @@ def connect(self, channels: ExampleGatewayChannels):
179186
# Channels set via `set_channel`
180187
channels.set_channel(ExampleGatewayChannels.example, data)
181188
channels.set_channel(ExampleGatewayChannels.example_list, data_list)
189+
channels.set_channel(ExampleGatewayChannels.example_with_state, data)
190+
channels.set_channel(ExampleGatewayChannels.example_with_state_multiple, data)
182191

183192
# Generic channel for sending data from non-csp sources
184193
channels.add_send_channel(ExampleGatewayChannels.example)
@@ -187,8 +196,11 @@ def connect(self, channels: ExampleGatewayChannels):
187196
channels.add_send_channel(ExampleGatewayChannels.basket, ExampleEnum.C)
188197
channels.add_send_channel(ExampleGatewayChannels.basket)
189198

190-
# Rudimentary state accumulation via `set_state`
191-
channels.set_state(ExampleGatewayChannels.example, "id")
199+
# State accumulation via `set_state`
200+
channels.set_state(
201+
ExampleGatewayChannels.example,
202+
("id",),
203+
)
192204

193205
# Create some data streams for dict baskets
194206
data_a = self.subscribe(

csp_gateway/server/gateway/csp/channels.py

Lines changed: 229 additions & 60 deletions
Large diffs are not rendered by default.

csp_gateway/server/gateway/csp/factory.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime
2-
from typing import Generic, List, Optional, Type
2+
from typing import Generic, List, Optional, Type, get_args, get_origin
33

44
from ccflow import BaseModel
55
from csp.impl.enum import Enum
@@ -57,6 +57,24 @@ def build(self, channels: ChannelsType) -> ChannelsType:
5757
if self.block_set_channels_until is not None:
5858
channels._block_set_channels_until = self.block_set_channels_until
5959

60+
# Pre-declare dynamically-created state channels so that modules calling get_state
61+
# during connect() are independent of the order in which set_state is called by the
62+
# owning module. The owning module includes the name in both dynamic_channels()
63+
# (which provides the element type) and dynamic_state_channels().
64+
for node in enabled_modules:
65+
declared = node.dynamic_state_channels() if hasattr(node, "dynamic_state_channels") else None
66+
if not declared:
67+
continue
68+
owned_channels = node.dynamic_channels() or {}
69+
for name in declared:
70+
if name not in owned_channels:
71+
raise ValueError(
72+
f"Module {type(node).__name__} declared '{name}' in dynamic_state_channels() but did not include it in dynamic_channels()"
73+
)
74+
t = owned_channels[name]
75+
element_type = get_args(t)[0] if get_origin(t) is list else t
76+
channels._declare_dynamic_state(name, element_type)
77+
6078
# Wire in each edge Provider.
6179
# The implementation of set_channel will handle multiplexing streams
6280
for node in enabled_modules:

csp_gateway/server/gateway/csp/module.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ def dynamic_channels(self) -> Optional[Dict[str, Union[Type[GatewayStruct], Type
4949
...
5050

5151
def dynamic_state_channels(self) -> Optional[Set[str]]:
52-
"""
53-
The set of dynamic channels that have state.
52+
"""The subset of :meth:`dynamic_channels` for which this module will also call
53+
:meth:`GatewayChannels.set_state` from within :meth:`connect`.
54+
55+
Declaring a name here lets *other* modules call :meth:`GatewayChannels.get_state`
56+
on the channel from their own ``connect`` regardless of the order in which modules
57+
are connected; the returned state edge is bound to the real state node once the
58+
owning module's ``connect`` calls ``set_state``.
5459
"""
5560
...
5661

csp_gateway/server/gateway/csp/state.py

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from enum import Enum as PyEnum
88
from functools import lru_cache
99
from pprint import pformat
10-
from typing import Any, Deque, Dict, List, Tuple, Union
10+
from typing import Any, Deque, Dict, List, Optional, Tuple, Union
1111

1212
import csp
1313
import duckdb
@@ -75,6 +75,16 @@ def disable_duckdb_state() -> None:
7575
_USE_DUCKDB_STATE = False
7676

7777

78+
def _resolve_keyby_attr(record: Any, path: str) -> Any:
79+
"""Resolve a (possibly dotted) attribute path on ``record``, returning ``None`` if any segment is missing."""
80+
obj = record
81+
for segment in path.split("."):
82+
if obj is None:
83+
return None
84+
obj = getattr(obj, segment, None)
85+
return obj
86+
87+
7888
class StateType(CoreEnum):
7989
UNKNOWN = 0
8090
DEFAULT = 1
@@ -149,7 +159,7 @@ def insert(self, record: Any) -> None:
149159

150160
for subkey in self._keyby:
151161
# extract the key from the record
152-
subkey_to_use = getattr(record, subkey, None)
162+
subkey_to_use = _resolve_keyby_attr(record, subkey)
153163

154164
if subkey == self._keyby[-1]:
155165
# Put the element there if last
@@ -408,7 +418,7 @@ def insert(self, record: Struct) -> None:
408418
obj_id = None
409419
for subkey in self._keyby:
410420
# extract the key from the record
411-
subkey_to_use = getattr(record, subkey, None)
421+
subkey_to_use = _resolve_keyby_attr(record, subkey)
412422

413423
if subkey == self._keyby[-1]:
414424
if subkey_to_use not in place.keys():
@@ -527,23 +537,45 @@ def get_duckdb_schema_struct(cls: Struct) -> Tuple[Dict, bool]:
527537
return (new_type_info, use_duckdb)
528538

529539

530-
# NOTE: NEVER access State object directly, always access through the __class_getitem__ API
540+
# NOTE: For runtime state instances, always use the State[<typ>] API.
541+
# State() called directly (with no parameterized typ) is the annotation
542+
# marker form used in `Annotated[ts[X], State(keyby=..., indexer=..., alias=...)]`.
531543
class State(BaseState):
532-
def __init__(self, keyby: Union[Tuple[str, ...], str] = ("id",)) -> None:
533-
"""Switch case between different state specializations based on the type of the records"""
544+
# Annotation metadata. Set on every instance; consumed by ChannelsMetaclass
545+
# when this State is found in a field's Annotated metadata.
546+
_meta_keyby: Union[Tuple[str, ...], str] = ("id",)
547+
_meta_indexer: Optional[Union[str, int]] = None
548+
_meta_alias: Optional[str] = None
549+
550+
def __init__(
551+
self,
552+
keyby: Union[Tuple[str, ...], str] = ("id",),
553+
indexer: Optional[Union[str, int]] = None,
554+
alias: Optional[str] = None,
555+
) -> None:
556+
"""Initialize a State.
557+
558+
When ``self._typ`` is set (via ``State[T](...)``), this constructs a runtime
559+
state collection and dispatches to the appropriate backend. Otherwise the
560+
instance is treated as an annotation marker and only retains its metadata.
561+
"""
562+
# Always retain annotation metadata.
563+
self._meta_keyby = keyby
564+
self._meta_indexer = indexer
565+
self._meta_alias = alias
566+
567+
typ = getattr(self, "_typ", None)
568+
if typ is None:
569+
# Annotation-marker form; no runtime backend needed.
570+
return
534571

535572
global _USE_DUCKDB_STATE
536-
try:
537-
typ = self._typ
538-
if _USE_DUCKDB_STATE and isinstance(typ, type) and issubclass(typ, Struct):
539-
schema, use_duckdb = get_duckdb_schema_struct(typ)
540-
if use_duckdb:
541-
self._state_impl = DuckDBState(typ, keyby, schema)
542-
self._state_type = StateType.DUCKDB
543-
return
544-
except AttributeError:
545-
log.warning("Do not create object directly from State use the State[<typ>] API instead for performance reasons")
546-
log.warning("Using DefaultStateClass")
573+
if _USE_DUCKDB_STATE and isinstance(typ, type) and issubclass(typ, Struct):
574+
schema, use_duckdb = get_duckdb_schema_struct(typ)
575+
if use_duckdb:
576+
self._state_impl = DuckDBState(typ, keyby, schema)
577+
self._state_type = StateType.DUCKDB
578+
return
547579
self._state_impl = DefaultState(keyby)
548580
self._state_type = StateType.DEFAULT
549581

csp_gateway/server/gateway/gateway.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@
66
from datetime import datetime, timedelta
77
from socket import gethostname
88
from time import sleep
9-
from typing import Any, Callable, List, Optional, Type, Union, get_args, get_origin
9+
from typing import Any, Callable, List, Optional, Type, Union
1010

1111
import csp
1212
from csp import ts
1313
from pydantic import Field, PrivateAttr, create_model, model_validator
1414

15-
from csp_gateway.server.gateway import State
1615
from csp_gateway.server.settings import Settings
1716

1817
from .csp import Channels, ChannelsFactory, ChannelsType, Module
@@ -113,19 +112,13 @@ def _instantiate_dynamic_channel(self, modules: List[Module[GatewayChannels]], c
113112
for m in modules:
114113
module_dynamic_channels = m.dynamic_channels() if hasattr(m, "dynamic_channels") else None
115114
if module_dynamic_channels:
116-
channels_with_state = m.dynamic_state_channels() if hasattr(m, "dynamic_state_channels") else None
117115
for n, t in module_dynamic_channels.items():
118116
existing_type = dynamic_channels.get(n, None)
119117
if existing_type is not None:
120118
if t is not existing_type:
121119
raise ValueError(f"Conflicting types for dynamic channel {n}.")
122120

123121
dynamic_channels[n] = t
124-
if channels_with_state and n in channels_with_state:
125-
if get_origin(t) is list:
126-
t = get_args(t)[0]
127-
128-
dynamic_channels[f"s_{n}"] = State[t]
129122

130123
if dynamic_channels:
131124
dynamic_channel_kwargs = {n: (ts[t], None) for n, t in dynamic_channels.items()}

csp_gateway/server/modules/logging/stdlib.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ def _convert_log_level(cls, v: Union[str, int]) -> int:
383383
def connect(self, channels: ChannelsType):
384384
logger_to_use = logging.getLogger(self.log_name)
385385

386-
for field in self.selection.select_from(channels, state_channels=self.log_states):
386+
for field in self.selection.select_from(channels):
387387
data = channels.get_channel(field)
388388
# list baskets not supported yet
389389
if isinstance(data, dict):
@@ -393,6 +393,11 @@ def connect(self, channels: ChannelsType):
393393
edge = channels.get_channel(field)
394394
csp.log(self.log_level, field, edge, logger=logger_to_use)
395395

396+
if self.log_states:
397+
for alias in self.selection.select_from(channels, state_channels=True):
398+
edge = channels.get_state(alias)
399+
csp.log(self.log_level, f"s_{alias}", edge, logger=logger_to_use)
400+
396401

397402
# Backwards compatibility alias
398403
StdlibLogging = Logging

csp_gateway/server/modules/web/mount.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ def _mount_send(self, app: GatewayWebApp) -> None:
9191

9292
def _mount_state(self, app: GatewayWebApp) -> None:
9393
selection = ChannelSelection() if self.force_mount_all else self.mount_state
94-
channels_set = set(selection.select_from(app.gateway.channels_model, state_channels=True))
94+
# Use the instance so dynamically-registered set_state aliases are included.
95+
channels_set = set(selection.select_from(app.gateway.channels, state_channels=True))
9596
seen_channels = set()
9697

9798
# Bind every wire
@@ -110,7 +111,7 @@ def _mount_state(self, app: GatewayWebApp) -> None:
110111

111112
missing_channels = channels_set - seen_channels
112113
if missing_channels:
113-
log.info(f"Requested channels missing state routes: {list(channel[2:] for channel in missing_channels)}")
114+
log.info(f"Requested channels missing state routes: {list(missing_channels)}")
114115

115116
app.add_state_available_channels(seen_channels)
116117

0 commit comments

Comments
 (0)