3030from opendecree ._convert import typed_value_to_string
3131from opendecree ._stubs import process_get_all_response
3232from opendecree ._watcher_base import (
33+ _DEFAULT_CHANGE_QUEUE_SIZE ,
3334 _RECONNECT_INITIAL ,
3435 _RECONNECT_MAX ,
3536 _RECONNECT_MULTIPLIER ,
3637 _WatchedFieldBase ,
38+ _validate_max_queue_size ,
3739)
3840from opendecree .types import Change
3941
@@ -56,10 +58,12 @@ def __init__(
5658 type_ : type [T ],
5759 default : T ,
5860 * ,
61+ max_queue_size : int = _DEFAULT_CHANGE_QUEUE_SIZE ,
5962 on_callback_error : Callable [[Exception ], None ] | None = None ,
6063 ) -> None :
64+ max_queue_size = _validate_max_queue_size (max_queue_size )
6165 super ().__init__ (path , type_ , default , on_callback_error = on_callback_error )
62- self ._change_queue : asyncio .Queue [Change | None ] = asyncio .Queue ()
66+ self ._change_queue : asyncio .Queue [Change | None ] = asyncio .Queue (maxsize = max_queue_size )
6367
6468 @property
6569 def value (self ) -> T :
@@ -84,15 +88,40 @@ def _update(self, raw_value: str | None, change: Change) -> None:
8488 """Update the field value from a raw string. Called by the watcher task."""
8589 old , new = self ._apply_raw (raw_value )
8690 self ._fire_callbacks (old , new )
87- self ._change_queue . put_nowait (change )
91+ self ._enqueue_change (change )
8892
8993 def _load_initial (self , raw_value : str ) -> None :
9094 """Set initial value from snapshot. No callbacks fired."""
9195 self ._apply_raw (raw_value )
9296
9397 def _stop (self ) -> None :
9498 """Signal the changes() iterator to stop."""
95- self ._change_queue .put_nowait (None )
99+ self ._enqueue_stop ()
100+
101+ def _enqueue_change (self , change : Change ) -> None :
102+ """Queue a change, dropping the oldest queued change if the queue is full."""
103+ while True :
104+ try :
105+ self ._change_queue .put_nowait (change )
106+ return
107+ except asyncio .QueueFull :
108+ try :
109+ self ._change_queue .get_nowait ()
110+ except asyncio .QueueEmpty :
111+ continue
112+ self ._dropped_changes += 1
113+
114+ def _enqueue_stop (self ) -> None :
115+ """Queue the stop sentinel without failing on a full queue."""
116+ while True :
117+ try :
118+ self ._change_queue .put_nowait (None )
119+ return
120+ except asyncio .QueueFull :
121+ try :
122+ self ._change_queue .get_nowait ()
123+ except asyncio .QueueEmpty :
124+ continue
96125
97126
98127class AsyncConfigWatcher :
@@ -125,6 +154,7 @@ def field(
125154 type_ : type [T ],
126155 * ,
127156 default : T ,
157+ max_queue_size : int = _DEFAULT_CHANGE_QUEUE_SIZE ,
128158 on_callback_error : Callable [[Exception ], None ] | None = None ,
129159 ) -> AsyncWatchedField [T ]:
130160 """Register a field to watch.
@@ -135,6 +165,8 @@ def field(
135165 path: Dot-separated field path (e.g., "payments.fee").
136166 type_: Python type to convert values to (str, int, float, bool, timedelta).
137167 default: Default value when the field is null or not set.
168+ max_queue_size: Maximum number of unread changes buffered before
169+ dropping the oldest queued change.
138170 on_callback_error: Optional hook called with the exception when an
139171 on_change callback raises. If not set, the exception is logged.
140172 The hook may re-raise to terminate the watcher's background task.
@@ -144,7 +176,13 @@ def field(
144176 """
145177 if self ._task is not None :
146178 raise RuntimeError ("Cannot register fields after watcher has started" )
147- watched = AsyncWatchedField (path , type_ , default , on_callback_error = on_callback_error )
179+ watched = AsyncWatchedField (
180+ path ,
181+ type_ ,
182+ default ,
183+ max_queue_size = max_queue_size ,
184+ on_callback_error = on_callback_error ,
185+ )
148186 self ._fields [path ] = watched
149187 return watched
150188
0 commit comments