SIENTIAPDE-1974: fix(slots): address the code review of the unsubscribe fix

Code review (code-review skill, xhigh) of 057c1fd: 6 findings, all fixed.

- unsubscribe_slot handles a failing reader: the legacy OpcManager's delete()
  raises when the server session is gone, which still stopped the main loop.
  The failure is notified (OPC_UNSUBSCRIBE_ERROR_<slot>:<server>) and logged,
  the slot is dropped from that reader's books (as the v2 engine already does)
  so it can be subscribed again, and the other readers are still unsubscribed.
  Repo convention: a single server failure must not kill the pod.
- update_slot_config unsubscribes a slot deleted from Redis right away
  (_unsubscribe_removed_slots) instead of leaving it to the snapshot diff in
  update_ingestor_manager, which leaked the subscription if anything in between
  failed. The later diff call is now a no-op.
- manage_leases unsubscribes the extra slots before giving their leases back,
  so another pod never takes a slot this pod is still reading.
- unsubscribe_slot iterates a snapshot of the readers (list(...) # NOSONAR, as
  in pool.close_all / ServerReader.shutdown): the loop awaits, so a reader
  added or removed meanwhile must not break the iteration.
- Contract test with the real readers (ServerReader over the real
  SubscriptionEngine, and the legacy OpcManager): both expose `subscriptions`
  keyed by slot and both are unsubscribed (subscription deleted, pool unpinned).
- The reader test double moves to tests/unit/conftest.py (reader_double).

283 unit tests; the 3 new behavior tests fail on 057c1fd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eduardo Rios
2026-10-07 16:04:56 -03:00
parent 057c1fd1ce
commit 085e6090c8
5 changed files with 168 additions and 36 deletions

View File

@@ -549,17 +549,46 @@ class IngestorManager(SientiaMonitoring):
also left the subscription open on the OPC server. Unsubscribing a slot that no
reader holds is a no-op.
A reader that fails to unsubscribe (e.g. the legacy OpcManager, whose delete()
raises when the server session is gone) is notified and logged, and the slot is
dropped from its books, as the v2 engine already does, so it can be subscribed
again. The other readers are still unsubscribed and the main loop goes on.
Args:
slot (str): The name of the slot to unsubscribe.
Side Effects:
- Removes the slot's subscription from every OPC reader that has one
- Cleans up subscription resources on the OPC servers
- Sends OPC_UNSUBSCRIBE_ERROR_<slot>:<server> when a reader fails
"""
for reader in self.opc_managers.values():
if slot in reader.subscriptions:
# list() snapshots the readers: the loop awaits, so a reader added or removed
# meanwhile must not break the iteration.
for server, reader in list(self.opc_managers.items()): # NOSONAR
if slot not in reader.subscriptions:
continue
try:
await reader.unsubscribe(slot)
except Exception as e:
trace = traceback.format_exc()
await self.send_notification_async(
metadata=self.metadata,
notification_id=f'OPC_UNSUBSCRIBE_ERROR_{slot}:{server}',
message=f'Failed to unsubscribe slot {slot} from {server}: {e}',
block='opc_manager',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.logger.error(trace)
reader.subscriptions.pop(slot, None)
async def _unsubscribe_removed_slots(self, slots: list[str]) -> None:
"""Unsubscribe and forget slots whose configuration is gone from Redis."""
for slot in slots:
self.logger.warning(f'Slot {slot} is no longer configured; unsubscribing it')
await self.unsubscribe_slot(slot)
self.managed_tags.pop(slot, None)
async def update_slot_config(self):
"""
@@ -569,7 +598,7 @@ class IngestorManager(SientiaMonitoring):
This method performs the following steps:
1. Renews the lease for each managed slot using the resource manager.
2. Fetches the latest configuration for each slot.
3. Logs and removes slots whose configurations are no longer available.
3. Logs, unsubscribes and removes slots whose configurations are no longer available.
4. Updates the configuration of slots if changes are detected.
5. Unsubscribes and re-subscribes to slots with updated configurations.
6. Removes slots from the managed tags if they are no longer valid.
@@ -602,8 +631,9 @@ class IngestorManager(SientiaMonitoring):
continue
self.managed_tags[slot] = update
for slot in removed_slots:
self.managed_tags.pop(slot, None)
# Unsubscribe right away: leaving it to update_ingestor_manager's snapshot diff
# would leak the subscription if anything in between failed
await self._unsubscribe_removed_slots(removed_slots)
def parse_slot_config(self, slot: str, raw: dict) -> dict | None:
"""