SIENTIAPDE-1974: fix(slots): guard every reader unsubscribe and count failures
Second code review round (xhigh) of057c1fd+085e609: 5 findings. Fixed: - manage_server's subscribe-failure path called reader.unsubscribe() directly; with the legacy OpcManager a dead session made it raise out of the except block and stop the main loop. Both paths now go through _unsubscribe_reader, the single guarded per-reader unsubscribe. - An unsubscribe failure also increments OPC_SUBSCRIPTION_ERRORS (same tags as the subscription failures), besides the OPC_UNSUBSCRIBE_ERROR notification. - The contract test creates the subscriptions through the readers' real create_subscription (fake pool / client) instead of writing engine internals, and checks the pool pin/unpin pair. Documented (no code change): - If delete() fails while the session is still alive (e.g. a timeout), the server-side subscription may keep delivering data until that session ends (reconnection or pod restart); noted in _unsubscribe_reader. - manage_leases now unsubscribes before releasing the leases: if Redis fails while releasing them, the slots stay unread until LEASE_TTL expires, which is preferred to two pods reading the same slot. 284 unit tests; the 2 new behavior tests fail on085e609. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -549,10 +549,8 @@ class IngestorManager(SientiaMonitoring):
|
|||||||
also left the subscription open on the OPC server. Unsubscribing a slot that no
|
also left the subscription open on the OPC server. Unsubscribing a slot that no
|
||||||
reader holds is a no-op.
|
reader holds is a no-op.
|
||||||
|
|
||||||
A reader that fails to unsubscribe (e.g. the legacy OpcManager, whose delete()
|
A reader that fails to unsubscribe is handled by _unsubscribe_reader: the other
|
||||||
raises when the server session is gone) is notified and logged, and the slot is
|
readers are still unsubscribed and the main loop goes on.
|
||||||
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:
|
Args:
|
||||||
slot (str): The name of the slot to unsubscribe.
|
slot (str): The name of the slot to unsubscribe.
|
||||||
@@ -560,28 +558,56 @@ class IngestorManager(SientiaMonitoring):
|
|||||||
Side Effects:
|
Side Effects:
|
||||||
- Removes the slot's subscription from every OPC reader that has one
|
- Removes the slot's subscription from every OPC reader that has one
|
||||||
- Cleans up subscription resources on the OPC servers
|
- Cleans up subscription resources on the OPC servers
|
||||||
- Sends OPC_UNSUBSCRIBE_ERROR_<slot>:<server> when a reader fails
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# list() snapshots the readers: the loop awaits, so a reader added or removed
|
# list() snapshots the readers: the loop awaits, so a reader added or removed
|
||||||
# meanwhile must not break the iteration.
|
# meanwhile must not break the iteration.
|
||||||
for server, reader in list(self.opc_managers.items()): # NOSONAR
|
for server, reader in list(self.opc_managers.items()): # NOSONAR
|
||||||
if slot not in reader.subscriptions:
|
if slot in reader.subscriptions:
|
||||||
continue
|
await self._unsubscribe_reader(server, reader, slot)
|
||||||
try:
|
|
||||||
await reader.unsubscribe(slot)
|
async def _unsubscribe_reader(
|
||||||
except Exception as e:
|
self, server: str, reader: ServerReader | OpcManager, slot: str
|
||||||
trace = traceback.format_exc()
|
) -> None:
|
||||||
await self.send_notification_async(
|
"""
|
||||||
metadata=self.metadata,
|
Unsubscribe one reader from a slot without letting a failure stop the main loop.
|
||||||
notification_id=f'OPC_UNSUBSCRIBE_ERROR_{slot}:{server}',
|
|
||||||
message=f'Failed to unsubscribe slot {slot} from {server}: {e}',
|
A failure (e.g. the legacy OpcManager, whose delete() raises when the server
|
||||||
block='opc_manager',
|
session is gone) increments OPC_SUBSCRIPTION_ERRORS, sends
|
||||||
level=NotificationLevel.ERROR,
|
OPC_UNSUBSCRIBE_ERROR_<slot>:<server> and is logged. The slot is dropped from the
|
||||||
attachment_content=trace,
|
reader's books, as the v2 engine already does, so it can be subscribed again.
|
||||||
)
|
|
||||||
self.logger.error(trace)
|
Known limitation: if delete() failed while the session is still alive (e.g. a
|
||||||
reader.subscriptions.pop(slot, None)
|
timeout), the server-side subscription may keep delivering data until that
|
||||||
|
session ends (reconnection or pod restart).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server (str): The reader's key in opc_managers
|
||||||
|
reader (ServerReader | OpcManager): The OPC reader
|
||||||
|
slot (str): The slot to unsubscribe
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await reader.unsubscribe(slot)
|
||||||
|
except Exception as e:
|
||||||
|
await self.emit_metric(
|
||||||
|
metric_object=metrics.OPC_SUBSCRIPTION_ERRORS,
|
||||||
|
tags={
|
||||||
|
'pod_id': self.pod_id,
|
||||||
|
'server': server,
|
||||||
|
'slot': slot,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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:
|
async def _unsubscribe_removed_slots(self, slots: list[str]) -> None:
|
||||||
"""Unsubscribe and forget slots whose configuration is gone from Redis."""
|
"""Unsubscribe and forget slots whose configuration is gone from Redis."""
|
||||||
@@ -796,7 +822,7 @@ class IngestorManager(SientiaMonitoring):
|
|||||||
self.logger.error(trace)
|
self.logger.error(trace)
|
||||||
|
|
||||||
self.logger.warning(f'Removing subscription from server {server} for slot {slot}')
|
self.logger.warning(f'Removing subscription from server {server} for slot {slot}')
|
||||||
await self.opc_managers[server].unsubscribe(slot)
|
await self._unsubscribe_reader(server, self.opc_managers[server], slot)
|
||||||
return 2
|
return 2
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
|
|||||||
from pytest import fixture, mark
|
from pytest import fixture, mark
|
||||||
from sientia_do.notifications.models import NotificationLevel
|
from sientia_do.notifications.models import NotificationLevel
|
||||||
|
|
||||||
|
import ingestor.metrics as metrics
|
||||||
from ingestor.config import GatewayConfig
|
from ingestor.config import GatewayConfig
|
||||||
from ingestor.connection.pool import OpcConnectionPool
|
from ingestor.connection.pool import OpcConnectionPool
|
||||||
from ingestor.io.slots import parse_slot
|
from ingestor.io.slots import parse_slot
|
||||||
@@ -607,6 +608,10 @@ async def test_unsubscribe_slot_reader_failure_does_not_raise(ingestor_manager,
|
|||||||
|
|
||||||
healthy.unsubscribe.assert_called_once_with('slot1')
|
healthy.unsubscribe.assert_called_once_with('slot1')
|
||||||
assert 'slot1' not in broken.subscriptions
|
assert 'slot1' not in broken.subscriptions
|
||||||
|
ingestor_manager.emit_metric.assert_called_once_with(
|
||||||
|
metric_object=metrics.OPC_SUBSCRIPTION_ERRORS,
|
||||||
|
tags={'pod_id': ingestor_manager.pod_id, 'server': 'server1', 'slot': 'slot1'},
|
||||||
|
)
|
||||||
ingestor_manager.send_notification_async.assert_called_once_with(
|
ingestor_manager.send_notification_async.assert_called_once_with(
|
||||||
metadata=ingestor_manager.metadata,
|
metadata=ingestor_manager.metadata,
|
||||||
notification_id='OPC_UNSUBSCRIBE_ERROR_slot1:server1',
|
notification_id='OPC_UNSUBSCRIBE_ERROR_slot1:server1',
|
||||||
@@ -619,12 +624,18 @@ async def test_unsubscribe_slot_reader_failure_does_not_raise(ingestor_manager,
|
|||||||
|
|
||||||
@mark.asyncio
|
@mark.asyncio
|
||||||
async def test_unsubscribe_slot_with_real_readers(ingestor_manager):
|
async def test_unsubscribe_slot_with_real_readers(ingestor_manager):
|
||||||
"""Contract: both real readers expose `subscriptions` keyed by slot, and
|
"""Contract: both real readers keep `subscriptions` keyed by slot, and
|
||||||
unsubscribe_slot closes the slot's subscription on each of them"""
|
unsubscribe_slot closes the slot's subscription on each of them"""
|
||||||
pool = MagicMock(get=AsyncMock(), evict=AsyncMock())
|
v2_subscription = AsyncMock()
|
||||||
|
client = MagicMock(create_subscription=AsyncMock(return_value=v2_subscription))
|
||||||
|
pool = MagicMock(get=AsyncMock(return_value=client), evict=AsyncMock())
|
||||||
server_reader = ServerReader(
|
server_reader = ServerReader(
|
||||||
name='kepserver',
|
name='kepserver',
|
||||||
server_config={'server_id': '1', 'url': 'opc.tcp://kepserver:49320'},
|
server_config={
|
||||||
|
'server_id': '1',
|
||||||
|
'url': 'opc.tcp://kepserver:49320',
|
||||||
|
'subscription_period_ms': 1000.0,
|
||||||
|
},
|
||||||
pool=pool,
|
pool=pool,
|
||||||
sink=AsyncMock(),
|
sink=AsyncMock(),
|
||||||
logger=MagicMock(),
|
logger=MagicMock(),
|
||||||
@@ -633,9 +644,7 @@ async def test_unsubscribe_slot_with_real_readers(ingestor_manager):
|
|||||||
metadata={},
|
metadata={},
|
||||||
)
|
)
|
||||||
server_reader.engine.emit_metric = AsyncMock()
|
server_reader.engine.emit_metric = AsyncMock()
|
||||||
v2_subscription = AsyncMock()
|
await server_reader.create_subscription('slot1')
|
||||||
server_reader.engine.subscriptions['slot1'] = v2_subscription
|
|
||||||
server_reader.engine._servers['slot1'] = '1'
|
|
||||||
|
|
||||||
with patch('ingestor.managers.opc_manager.metrics'):
|
with patch('ingestor.managers.opc_manager.metrics'):
|
||||||
legacy_reader = OpcManager(
|
legacy_reader = OpcManager(
|
||||||
@@ -649,9 +658,15 @@ async def test_unsubscribe_slot_with_real_readers(ingestor_manager):
|
|||||||
metadata={},
|
metadata={},
|
||||||
metrics_controller=AsyncMock(),
|
metrics_controller=AsyncMock(),
|
||||||
)
|
)
|
||||||
|
legacy_reader.emit_metric = AsyncMock()
|
||||||
legacy_subscription = AsyncMock()
|
legacy_subscription = AsyncMock()
|
||||||
legacy_reader.subscriptions['slot1'] = legacy_subscription
|
legacy_reader.client = MagicMock(
|
||||||
|
create_subscription=AsyncMock(return_value=legacy_subscription)
|
||||||
|
)
|
||||||
|
await legacy_reader.create_subscription('slot1')
|
||||||
|
|
||||||
|
assert 'slot1' in server_reader.subscriptions
|
||||||
|
assert 'slot1' in legacy_reader.subscriptions
|
||||||
ingestor_manager.opc_managers = {'kepserver': server_reader, 'legacy': legacy_reader}
|
ingestor_manager.opc_managers = {'kepserver': server_reader, 'legacy': legacy_reader}
|
||||||
|
|
||||||
await ingestor_manager.unsubscribe_slot('slot1')
|
await ingestor_manager.unsubscribe_slot('slot1')
|
||||||
@@ -660,6 +675,7 @@ async def test_unsubscribe_slot_with_real_readers(ingestor_manager):
|
|||||||
legacy_subscription.delete.assert_awaited_once()
|
legacy_subscription.delete.assert_awaited_once()
|
||||||
assert 'slot1' not in server_reader.subscriptions
|
assert 'slot1' not in server_reader.subscriptions
|
||||||
assert 'slot1' not in legacy_reader.subscriptions
|
assert 'slot1' not in legacy_reader.subscriptions
|
||||||
|
pool.pin.assert_called_once_with('1')
|
||||||
pool.unpin.assert_called_once_with('1')
|
pool.unpin.assert_called_once_with('1')
|
||||||
|
|
||||||
|
|
||||||
@@ -765,6 +781,30 @@ async def test_manage_server_create_subscription_failure(ingestor_manager):
|
|||||||
ingestor_manager.opc_managers['server1'].subscribe.assert_not_called()
|
ingestor_manager.opc_managers['server1'].subscribe.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
async def test_manage_server_subscribe_and_unsubscribe_failure(ingestor_manager):
|
||||||
|
"""subscribe() fails and so does the cleanup unsubscribe (legacy reader, session
|
||||||
|
gone): manage_server reports the failure and nothing escapes to the main loop"""
|
||||||
|
reader = AsyncMock()
|
||||||
|
reader.subscriptions = {}
|
||||||
|
reader.subscribe.side_effect = Exception('Subscription error')
|
||||||
|
reader.unsubscribe.side_effect = ConnectionError('session closed')
|
||||||
|
ingestor_manager.opc_managers = {'server1': reader}
|
||||||
|
server_config = {'tags': {'tag1': 'config1'}}
|
||||||
|
|
||||||
|
result = await ingestor_manager.manage_server('slot1', 'server1', server_config, server_config)
|
||||||
|
|
||||||
|
assert result == 2
|
||||||
|
reader.unsubscribe.assert_called_once_with('slot1')
|
||||||
|
notified = [
|
||||||
|
c.kwargs['notification_id'] for c in ingestor_manager.send_notification_async.call_args_list
|
||||||
|
]
|
||||||
|
assert notified == [
|
||||||
|
'OPC_SUBSCRIPTION_ERROR_slot1:server1',
|
||||||
|
'OPC_UNSUBSCRIBE_ERROR_slot1:server1',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@mark.asyncio
|
@mark.asyncio
|
||||||
async def test_manage_server(ingestor_manager):
|
async def test_manage_server(ingestor_manager):
|
||||||
ingestor_manager.opc_managers = {'server1': AsyncMock(), 'server2': AsyncMock()}
|
ingestor_manager.opc_managers = {'server1': AsyncMock(), 'server2': AsyncMock()}
|
||||||
|
|||||||
@@ -341,11 +341,15 @@ async def test_update_ingestor_manager_no_ingestor_manager(ingestor_manager_star
|
|||||||
|
|
||||||
|
|
||||||
def _manager_with_real_unsubscribe(managed_tags, readers):
|
def _manager_with_real_unsubscribe(managed_tags, readers):
|
||||||
"""A mocked IngestorManager whose unsubscribe_slot is the real one."""
|
"""A mocked IngestorManager whose unsubscribe path (unsubscribe_slot and
|
||||||
|
_unsubscribe_reader) is the real one."""
|
||||||
manager = AsyncMock(update_opc_servers=AsyncMock(), subscribe_to_tags=AsyncMock())
|
manager = AsyncMock(update_opc_servers=AsyncMock(), subscribe_to_tags=AsyncMock())
|
||||||
manager.managed_tags = managed_tags
|
manager.managed_tags = managed_tags
|
||||||
manager.opc_managers = readers
|
manager.opc_managers = readers
|
||||||
manager.unsubscribe_slot = lambda slot: IngestorManager.unsubscribe_slot(manager, slot)
|
manager.unsubscribe_slot = lambda slot: IngestorManager.unsubscribe_slot(manager, slot)
|
||||||
|
manager._unsubscribe_reader = lambda server, reader, slot: IngestorManager._unsubscribe_reader(
|
||||||
|
manager, server, reader, slot
|
||||||
|
)
|
||||||
return manager
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user