Skip to content

Commit 389b938

Browse files
committed
Add new Stage construct
Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
1 parent e44bc09 commit 389b938

16 files changed

Lines changed: 1914 additions & 29 deletions

File tree

csp_gateway/client/client.py

Lines changed: 325 additions & 8 deletions
Large diffs are not rendered by default.

csp_gateway/server/demo/omnibus.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
MountPerspectiveTables,
3434
MountRestRoutes,
3535
MountWebSocketRoutes,
36+
Stage,
3637
State,
3738
)
3839
from csp_gateway.server.config import load_gateway
@@ -127,6 +128,9 @@ class ExampleGatewayChannels(GatewayChannels):
127128
basket: Dict[ExampleEnum, ts[ExampleData]] = None
128129
str_basket: Dict[str, ts[ExampleData]] = None
129130

131+
# Staging can be added via annotation or the `set_stage` API in the module's `connect` method
132+
example_with_stage: Annotated[ts[ExampleData], Stage()] = None
133+
130134
# FIXME
131135
# basket_list: Dict[ExampleEnum, ts[[ExampleData]]] = None
132136
# NOTE: this second one is not populated with data so will 404
@@ -188,6 +192,7 @@ def connect(self, channels: ExampleGatewayChannels):
188192
channels.set_channel(ExampleGatewayChannels.example_list, data_list)
189193
channels.set_channel(ExampleGatewayChannels.example_with_state, data)
190194
channels.set_channel(ExampleGatewayChannels.example_with_state_multiple, data)
195+
channels.set_channel(ExampleGatewayChannels.example_with_stage, data)
191196

192197
# Generic channel for sending data from non-csp sources
193198
channels.add_send_channel(ExampleGatewayChannels.example)
@@ -202,6 +207,9 @@ def connect(self, channels: ExampleGatewayChannels):
202207
("id",),
203208
)
204209

210+
# Staging via `set_stage` — enables stage_add/remove/release/list/lookup APIs
211+
channels.set_stage(ExampleGatewayChannels.example)
212+
205213
# Create some data streams for dict baskets
206214
data_a = self.subscribe(
207215
csp.timer(interval=self.interval, value=True),

csp_gateway/server/gateway/csp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@
1010
on_request_node_dict_basket,
1111
)
1212
from .module import Module
13+
from .stage import *
1314
from .state import *

csp_gateway/server/gateway/csp/channels.py

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
named_wait_for_next_node,
4949
named_wait_for_next_node_dict_basket,
5050
)
51-
from .state import State, build_track_state_node
51+
from .stage import Stage, _StageManager, build_staging_node
52+
from .state import State, _StateManager, build_track_state_node
5253

5354
if TYPE_CHECKING:
5455
from csp_gateway.utils import Query
@@ -186,6 +187,7 @@ def __new__(mcs: Any, name: Any, bases: Any, namespace: Any, **kwargs: Any) -> A
186187
_add_field_attributes(cls)
187188
ts_pydantic_field_types = {}
188189
declared_states: Dict[str, _StateSpec] = {}
190+
declared_stages: List[str] = []
189191
for field_name, field_type in cls.model_fields.items():
190192
# Validate that timeseries types contain structs or list of structs
191193
outer_type = field_type.annotation
@@ -206,8 +208,12 @@ def __new__(mcs: Any, name: Any, bases: Any, namespace: Any, **kwargs: Any) -> A
206208
keyby=_normalize_keyby(meta._meta_keyby),
207209
indexer=meta._meta_indexer,
208210
)
211+
elif isinstance(meta, Stage):
212+
if field_name not in declared_stages:
213+
declared_stages.append(field_name)
209214

210215
cls._declared_states = declared_states
216+
cls._declared_stages = declared_stages
211217

212218
ts_pydantic_field_types[_CSP_ENGINE_CYCLE_TIMESTAMP_FIELD] = (Optional[datetime], None)
213219
dynamic_pydantic_model = create_model("_snapshot_model", __base__=_SnapshotModelBaseClass, **ts_pydantic_field_types)
@@ -235,6 +241,9 @@ class Channels(BaseModel, metaclass=ChannelsMetaclass):
235241
# alias -> _StateSpec(source_field, keyby, indexer)
236242
_declared_states: Dict[str, _StateSpec] = {}
237243

244+
# Populated by ChannelsMetaclass from Annotated[ts[X], Stage()] markers.
245+
_declared_stages: List[str] = []
246+
238247
model_config = dict(arbitrary_types_allowed=True) # (for FeedbackOutputDef)
239248

240249
_finalized: bool = PrivateAttr(default=False)
@@ -265,6 +274,11 @@ class Channels(BaseModel, metaclass=ChannelsMetaclass):
265274
_next_requests: Dict[Tuple[str, Optional[Union[str, int]]], Any] = PrivateAttr(default_factory=dict)
266275
_send_channels: Dict[Tuple[str, Optional[Union[str, int]]], Any] = PrivateAttr(default_factory=dict)
267276

277+
# Staging: channel_name -> _StageManager instance
278+
_stages: Dict[str, _StageManager] = PrivateAttr(default_factory=dict)
279+
# Staging: channel_name -> GenericPushAdapter (release trigger)
280+
_stage_triggers: Dict[str, Any] = PrivateAttr(default_factory=dict)
281+
268282
# inside context, the module being attached
269283
_module_being_attached: Any = PrivateAttr(None)
270284
# inside context, the requirements of the module being attached
@@ -410,6 +424,8 @@ def _finalize(self) -> None:
410424
if not self._finalized:
411425
# Auto-wire state collections declared via annotations.
412426
self._wire_declared_states()
427+
# Auto-wire staging declared via annotations.
428+
self._wire_declared_stages()
413429

414430
# first ensure everything is provided
415431
for (
@@ -529,6 +545,37 @@ def _wire_declared_states(self) -> None:
529545
edge = self.get_channel(spec.source_field)
530546
self._wire_state_edge(alias, edge, spec.keyby, spec.indexer)
531547

548+
def _wire_declared_stages(self) -> None:
549+
"""Auto-wire staging for channels declared via Annotated[ts[X], Stage()]."""
550+
for field_name in self.__class__._declared_stages:
551+
if field_name in self._stages:
552+
continue # already enabled via set_stage in connect
553+
554+
if not self._delayed_edge_providers.get(field_name):
555+
continue # no provider for this channel
556+
557+
# Determine element type from the channel's ts type
558+
tstype = self.get_outer_type(field_name)
559+
if is_dict_basket(tstype):
560+
element_type = get_dict_basket_value_type(tstype)
561+
elif isTsType(tstype):
562+
element_type = tstype.typ
563+
else:
564+
continue
565+
566+
# Unwrap List[T] -> T
567+
from typing import get_args as _get_args, get_origin as _get_origin
568+
569+
if _get_origin(element_type) is list:
570+
element_type = _get_args(element_type)[0]
571+
572+
stage, push_adapter = build_staging_node(element_type)
573+
self._stages[field_name] = stage
574+
self._stage_triggers[field_name] = push_adapter
575+
576+
# Wire the push adapter's output as an additional provider for this channel
577+
self._delayed_edge_providers[field_name].append((None, push_adapter.out()))
578+
532579
def _bind_delayed_channel(self, field, list_of_edges_and_modules, indexer=None):
533580
tstype = self.get_outer_type(field)
534581
# Make sure a getter node exists first
@@ -893,7 +940,7 @@ def get_state(self, field: str, indexer: Union[str, int] = None) -> Any:
893940
delayed = self._delayed_state_edges.get((field, indexer))
894941
if delayed is None:
895942
element_type = self._pending_state_element_types[field]
896-
delayed = DelayedEdge(ts[State[element_type]])
943+
delayed = DelayedEdge(ts[_StateManager[element_type]])
897944
delayed.__name__ = "s_{}".format(field)
898945
self._delayed_state_edges[field, indexer] = delayed
899946
return delayed
@@ -1128,6 +1175,130 @@ def send(self, field: str, value: Any, indexer: Union[str, int] = None) -> None:
11281175
# basket, push into first item of tuple
11291176
send_channel[0].push_tick(value)
11301177

1178+
# ------------------------------------------------------------------
1179+
# Staging API
1180+
# ------------------------------------------------------------------
1181+
1182+
def set_stage(self, field: str) -> None:
1183+
"""Enable staging mode for a channel.
1184+
1185+
Once staging is enabled, the channel gains stage_add/stage_remove/
1186+
stage_release/stage_list/stage_lookup methods. Released items are
1187+
injected into the channel as a single tick via a feedback edge.
1188+
1189+
Must be called during module connect (before finalization).
1190+
"""
1191+
if field in self._stages:
1192+
return # already enabled
1193+
1194+
if not self._validate_field_name(field):
1195+
raise AttributeError("{} has no channel: {}".format(self.__class__, field))
1196+
1197+
# Determine element type from the channel's ts type
1198+
tstype = self.get_outer_type(field)
1199+
if is_dict_basket(tstype):
1200+
element_type = get_dict_basket_value_type(tstype)
1201+
elif isTsType(tstype):
1202+
element_type = tstype.typ
1203+
else:
1204+
raise TypeError(f"Cannot enable staging on non-timeseries field: {field}")
1205+
1206+
# Unwrap List[T] -> T
1207+
from typing import get_args as _get_args, get_origin as _get_origin
1208+
1209+
if _get_origin(element_type) is list:
1210+
element_type = _get_args(element_type)[0]
1211+
1212+
stage, push_adapter = build_staging_node(element_type)
1213+
self._stages[field] = stage
1214+
self._stage_triggers[field] = push_adapter
1215+
1216+
# Wire the push adapter's output as an additional provider for this channel
1217+
module = self._module_being_attached
1218+
self._delayed_edge_providers[field].append((module, push_adapter.out()))
1219+
1220+
def staged_channels(self) -> List[str]:
1221+
"""Return list of channel names that have staging enabled."""
1222+
return list(self._stages.keys())
1223+
1224+
def stage_add(
1225+
self,
1226+
field: str,
1227+
struct: Any = None,
1228+
staging_ids: Optional[List[str]] = None,
1229+
) -> List[str]:
1230+
"""Add a struct to staging area(s) for a channel.
1231+
1232+
See STAGE.md for full semantics.
1233+
"""
1234+
if field not in self._stages:
1235+
raise NoProviderException(f"No staging enabled for channel: {field}")
1236+
return self._stages[field].stage_add(struct, staging_ids)
1237+
1238+
def stage_remove(
1239+
self,
1240+
field: str,
1241+
struct: Any = None,
1242+
staging_ids: Optional[List[str]] = None,
1243+
) -> List[str]:
1244+
"""Remove struct(s) from staging area(s).
1245+
1246+
See STAGE.md for full semantics.
1247+
"""
1248+
if field not in self._stages:
1249+
raise NoProviderException(f"No staging enabled for channel: {field}")
1250+
return self._stages[field].stage_remove(struct, staging_ids)
1251+
1252+
def stage_release(
1253+
self,
1254+
field: str,
1255+
staging_ids: Optional[List[str]] = None,
1256+
) -> Dict[str, List[Any]]:
1257+
"""Release staged structs into the channel.
1258+
1259+
Released items are pushed into the csp graph individually.
1260+
Returns dict mapping staging_id -> list of released structs.
1261+
"""
1262+
if field not in self._stages:
1263+
raise NoProviderException(f"No staging enabled for channel: {field}")
1264+
1265+
stage = self._stages[field]
1266+
released = stage.stage_release(staging_ids)
1267+
1268+
# Push each released item into the graph via the push adapter
1269+
push_adapter = self._stage_triggers[field]
1270+
for items in released.values():
1271+
for item in items:
1272+
push_adapter.push_tick(item)
1273+
1274+
return released
1275+
1276+
def stage_list(
1277+
self,
1278+
field: str,
1279+
staging_id: Optional[str] = None,
1280+
) -> List[str]:
1281+
"""List staging IDs for a channel.
1282+
1283+
See STAGE.md for full semantics.
1284+
"""
1285+
if field not in self._stages:
1286+
raise NoProviderException(f"No staging enabled for channel: {field}")
1287+
return self._stages[field].stage_list(staging_id)
1288+
1289+
def stage_lookup(
1290+
self,
1291+
field: str,
1292+
staging_id: Optional[str] = None,
1293+
) -> Dict[str, List[Any]]:
1294+
"""Look up contents of staging area(s).
1295+
1296+
Returns dict mapping staging_id -> list of structs.
1297+
"""
1298+
if field not in self._stages:
1299+
raise NoProviderException(f"No staging enabled for channel: {field}")
1300+
return self._stages[field].stage_lookup(staging_id)
1301+
11311302
def _check(self, field: str, where: Dict, kind: str, indexer: Union[str, int] = None) -> None:
11321303
if (field, indexer) not in where:
11331304
# TODO should only be called once the graph is started

0 commit comments

Comments
 (0)