4 Commits

Author SHA1 Message Date
Eduardo Rios
dba5300379 SIENTIAPDE-1974: docs(metrics): OPC_SUBSCRIPTION_ERRORS also counts unsubscribe failures
The help text said subscribe only; _unsubscribe_reader now increments it too.
Series name and labels are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:16:42 -03:00
Eduardo Rios
4235ce8edb SIENTIAPDE-1974: fix(slots): guard every reader unsubscribe and count failures
Second code review round (xhigh) of 057c1fd + 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 on 085e609.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:15:06 -03:00
Eduardo Rios
085e6090c8 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>
2026-10-07 16:04:56 -03:00
Eduardo Rios
057c1fd1ce SIENTIAPDE-1974: fix(slots): unsubscribe a slot from the readers that hold it
The main loop snapshots managed_tags, then two steps remove a slot from
managed_tags before update_ingestor_manager compares the snapshot with the
current state and calls unsubscribe_slot for every slot that disappeared:

- manage_leases drops an extra lease ("Extra slots available"): it already
  unsubscribed the slot, so the second call is redundant;
- update_slot_config finds the slot gone from Redis (deleted by the
  orchestrator): nothing unsubscribed it yet.

unsubscribe_slot looked the slot up in managed_tags[slot] to find its servers,
so both cases raised KeyError, which stopped the main loop and restarted the
container (seen on dev on 2026-10-06 18:30 during a rollout, ~40 s without
reads). In the second case the subscription also stayed open on the OPC server.
The same code is on main, so production has it too.

unsubscribe_slot now asks the readers themselves: every reader (ServerReader or
the legacy OpcManager, both keep `subscriptions` by slot) holding a
subscription for the slot unsubscribes it. A slot no reader holds is a no-op.
This also covers a resubscription whose new config dropped a server: the old
server's subscription for that slot is closed too.

Tests: unit tests for the new lookup and the double call, plus two regressions
at the Ingestor level with the real unsubscribe_slot (dropped extra lease;
slot removed from Redis). Without the fix they fail with the same KeyError at
ingestor_manager.py:558. 280 unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:55:20 -03:00
6 changed files with 317 additions and 23 deletions

View File

@@ -268,12 +268,14 @@ class Ingestor(SientiaMonitoring):
overleases = list(self.ingestor_manager.managed_tags.keys())[1:] overleases = list(self.ingestor_manager.managed_tags.keys())[1:]
await self.ingestor_manager.drop_slot_leases(overleases) # Unsubscribe before giving the leases back: another pod may take a released
# slot right away, and must not find this pod still reading it
for lease in overleases: for lease in overleases:
await self.ingestor_manager.unsubscribe_slot(lease) await self.ingestor_manager.unsubscribe_slot(lease)
self.ingestor_manager.managed_tags.pop(lease) self.ingestor_manager.managed_tags.pop(lease)
await self.ingestor_manager.drop_slot_leases(overleases)
await self.emit_metric( await self.emit_metric(
metric_object=metrics.SLOTS_MANAGED, metric_object=metrics.SLOTS_MANAGED,
method='set', method='set',

View File

@@ -538,26 +538,83 @@ class IngestorManager(SientiaMonitoring):
async def unsubscribe_slot(self, slot: str): async def unsubscribe_slot(self, slot: str):
""" """
Unsubscribes a specific slot from all associated OPC servers. Unsubscribes a specific slot from every OPC reader that holds a subscription for it.
This method removes all subscriptions for a given slot across all The readers' own subscriptions are the source of truth, not managed_tags: the
OPC servers that were managing it. It ensures clean cleanup of main loop removes a slot from managed_tags before update_ingestor_manager asks
resources when slots are released or reconfigured. to unsubscribe it, both when manage_leases drops an extra lease (which already
unsubscribed it) and when update_slot_config finds the slot gone from Redis
(which did not). Looking the slot up in managed_tags raised KeyError in both
cases, which stopped the main loop and restarted the container; the second case
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 is handled by _unsubscribe_reader: 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.
Raises:
KeyError: If the specified slot does not exist in the managed tags.
Side Effects: Side Effects:
- Removes subscriptions from all OPC servers for the specified slot - 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
""" """
for server in self.managed_tags[slot].keys(): # list() snapshots the readers: the loop awaits, so a reader added or removed
if server in self.opc_managers: # meanwhile must not break the iteration.
await self.opc_managers[server].unsubscribe(slot) for server, reader in list(self.opc_managers.items()): # NOSONAR
if slot in reader.subscriptions:
await self._unsubscribe_reader(server, reader, slot)
async def _unsubscribe_reader(
self, server: str, reader: ServerReader | OpcManager, slot: str
) -> None:
"""
Unsubscribe one reader from a slot without letting a failure stop the main loop.
A failure (e.g. the legacy OpcManager, whose delete() raises when the server
session is gone) increments OPC_SUBSCRIPTION_ERRORS, sends
OPC_UNSUBSCRIBE_ERROR_<slot>:<server> and is logged. The slot is dropped from the
reader's books, as the v2 engine already does, so it can be subscribed again.
Known limitation: if delete() failed 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).
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:
"""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): async def update_slot_config(self):
""" """
@@ -567,7 +624,7 @@ class IngestorManager(SientiaMonitoring):
This method performs the following steps: This method performs the following steps:
1. Renews the lease for each managed slot using the resource manager. 1. Renews the lease for each managed slot using the resource manager.
2. Fetches the latest configuration for each slot. 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. 4. Updates the configuration of slots if changes are detected.
5. Unsubscribes and re-subscribes to slots with updated configurations. 5. Unsubscribes and re-subscribes to slots with updated configurations.
6. Removes slots from the managed tags if they are no longer valid. 6. Removes slots from the managed tags if they are no longer valid.
@@ -600,8 +657,9 @@ class IngestorManager(SientiaMonitoring):
continue continue
self.managed_tags[slot] = update self.managed_tags[slot] = update
for slot in removed_slots: # Unsubscribe right away: leaving it to update_ingestor_manager's snapshot diff
self.managed_tags.pop(slot, None) # 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: def parse_slot_config(self, slot: str, raw: dict) -> dict | None:
""" """
@@ -764,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

View File

@@ -93,7 +93,7 @@ OPC_MANAGERS_ACTIVE = Gauge(
) )
OPC_SUBSCRIPTION_ERRORS = Counter( OPC_SUBSCRIPTION_ERRORS = Counter(
'ingestor_opc_subscription_errors_total', 'ingestor_opc_subscription_errors_total',
'Errors when trying to subscribe to OPC tags', 'Errors when trying to subscribe to or unsubscribe from OPC tags',
['pod_id', 'server', 'slot'], ['pod_id', 'server', 'slot'],
) )

20
tests/unit/conftest.py Normal file
View File

@@ -0,0 +1,20 @@
from unittest.mock import AsyncMock, MagicMock
from pytest import fixture
@fixture
def reader_double():
"""Factory of OPC reader doubles holding subscriptions for the given slots.
Like the real readers (ServerReader, OpcManager), `subscriptions` is keyed by slot
and `unsubscribe` drops the slot from it.
"""
def make(*slots):
reader = MagicMock()
reader.subscriptions = {slot: MagicMock() for slot in slots}
reader.unsubscribe = AsyncMock(side_effect=lambda slot: reader.subscriptions.pop(slot))
return reader
return make

View File

@@ -1,13 +1,16 @@
import gc import gc
from unittest.mock import AsyncMock, MagicMock, call, patch 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
from ingestor.managers.ingestor_manager import IngestorManager from ingestor.managers.ingestor_manager import IngestorManager
from ingestor.managers.opc_manager import OpcManager
from ingestor.managers.server_reader import ServerReader
metadata = { metadata = {
'metadata': { 'metadata': {
@@ -548,21 +551,153 @@ async def test_get_slot_leases_1_failure(ingestor_manager):
@mark.asyncio @mark.asyncio
async def test_unsubscribe_slot(ingestor_manager): async def test_unsubscribe_slot(ingestor_manager, reader_double):
ingestor_manager.managed_tags = { ingestor_manager.managed_tags = {
'slot1': {'server1': {'tags': 'config1'}, 'server2': {'tags': 'config2'}}, 'slot1': {'server1': {'tags': 'config1'}, 'server2': {'tags': 'config2'}},
'slot2': {'server3': {'tags': 'config3'}, 'server1': {'tags': 'config1'}}, 'slot2': {'server3': {'tags': 'config3'}, 'server1': {'tags': 'config1'}},
} }
ingestor_manager.opc_managers = { ingestor_manager.opc_managers = {
'server1': AsyncMock(), 'server1': reader_double('slot1', 'slot2'),
'server2': AsyncMock(), 'server2': reader_double('slot1'),
'server3': AsyncMock(), 'server3': reader_double('slot2'),
} }
await ingestor_manager.unsubscribe_slot('slot1') await ingestor_manager.unsubscribe_slot('slot1')
ingestor_manager.opc_managers['server1'].unsubscribe.assert_called_once_with('slot1') ingestor_manager.opc_managers['server1'].unsubscribe.assert_called_once_with('slot1')
ingestor_manager.opc_managers['server2'].unsubscribe.assert_called_once_with('slot1') ingestor_manager.opc_managers['server2'].unsubscribe.assert_called_once_with('slot1')
ingestor_manager.opc_managers['server3'].unsubscribe.assert_not_called() ingestor_manager.opc_managers['server3'].unsubscribe.assert_not_called()
assert list(ingestor_manager.opc_managers['server1'].subscriptions) == ['slot2']
@mark.asyncio
async def test_unsubscribe_slot_not_in_managed_tags(ingestor_manager, reader_double):
"""The slot is already gone from managed_tags: the readers still holding it are unsubscribed"""
ingestor_manager.managed_tags = {'slot1': {'server1': {'tags': 'config1'}}}
ingestor_manager.opc_managers = {
'server1': reader_double('slot1', 'slot3'),
'server2': reader_double('slot3'),
}
await ingestor_manager.unsubscribe_slot('slot3')
ingestor_manager.opc_managers['server1'].unsubscribe.assert_called_once_with('slot3')
ingestor_manager.opc_managers['server2'].unsubscribe.assert_called_once_with('slot3')
@mark.asyncio
async def test_unsubscribe_slot_twice_is_a_no_op(ingestor_manager, reader_double):
"""A second unsubscribe of the same slot (lease already dropped) does nothing and never raises"""
ingestor_manager.managed_tags = {}
ingestor_manager.opc_managers = {'server1': reader_double('slot2')}
await ingestor_manager.unsubscribe_slot('slot2')
await ingestor_manager.unsubscribe_slot('slot2')
ingestor_manager.opc_managers['server1'].unsubscribe.assert_called_once_with('slot2')
@mark.asyncio
async def test_unsubscribe_slot_reader_failure_does_not_raise(ingestor_manager, reader_double):
"""A reader that fails to unsubscribe is notified and forgotten; the others still go"""
broken = reader_double('slot1')
broken.unsubscribe = AsyncMock(side_effect=ConnectionError('session closed'))
healthy = reader_double('slot1')
ingestor_manager.opc_managers = {'server1': broken, 'server2': healthy}
await ingestor_manager.unsubscribe_slot('slot1')
healthy.unsubscribe.assert_called_once_with('slot1')
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(
metadata=ingestor_manager.metadata,
notification_id='OPC_UNSUBSCRIBE_ERROR_slot1:server1',
message='Failed to unsubscribe slot slot1 from server1: session closed',
block='opc_manager',
level=NotificationLevel.ERROR,
attachment_content=ANY,
)
@mark.asyncio
async def test_unsubscribe_slot_with_real_readers(ingestor_manager):
"""Contract: both real readers keep `subscriptions` keyed by slot, and
unsubscribe_slot closes the slot's subscription on each of them"""
v2_subscription = AsyncMock()
client = MagicMock(create_subscription=AsyncMock(return_value=v2_subscription))
pool = MagicMock(get=AsyncMock(return_value=client), evict=AsyncMock())
server_reader = ServerReader(
name='kepserver',
server_config={
'server_id': '1',
'url': 'opc.tcp://kepserver:49320',
'subscription_period_ms': 1000.0,
},
pool=pool,
sink=AsyncMock(),
logger=MagicMock(),
notification_handler=MagicMock(),
metrics_controller=AsyncMock(),
metadata={},
)
server_reader.engine.emit_metric = AsyncMock()
await server_reader.create_subscription('slot1')
with patch('ingestor.managers.opc_manager.metrics'):
legacy_reader = OpcManager(
name='legacy',
url='opc.tcp://legacy:4840',
data_manager=AsyncMock(),
subscription_period_ms=1000,
logger=MagicMock(),
server_uri='opc.tcp://legacy:4840',
notification_handler=MagicMock(),
metadata={},
metrics_controller=AsyncMock(),
)
legacy_reader.emit_metric = AsyncMock()
legacy_subscription = AsyncMock()
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}
await ingestor_manager.unsubscribe_slot('slot1')
v2_subscription.delete.assert_awaited_once()
legacy_subscription.delete.assert_awaited_once()
assert 'slot1' not in server_reader.subscriptions
assert 'slot1' not in legacy_reader.subscriptions
pool.pin.assert_called_once_with('1')
pool.unpin.assert_called_once_with('1')
@mark.asyncio
async def test_update_slot_config_unsubscribes_removed_slot(ingestor_manager, reader_double):
"""A slot deleted from Redis is unsubscribed right away, not left to the main loop"""
ingestor_manager.managed_tags = {
'slot1': parse_slot(raw_slot_v1('Same')),
'slot2': parse_slot(raw_slot_v1('Same')),
}
reader = reader_double('slot1', 'slot2')
ingestor_manager.opc_managers = {'test_server': reader}
ingestor_manager.resource_manager.get_tag_slot = AsyncMock(
side_effect=lambda slot: raw_slot_v1('Same') if slot == 'slot1' else None
)
ingestor_manager.resource_manager.renew_tag_lease = AsyncMock()
await ingestor_manager.update_slot_config()
reader.unsubscribe.assert_called_once_with('slot2')
assert list(ingestor_manager.managed_tags) == ['slot1']
assert list(reader.subscriptions) == ['slot1']
@mark.asyncio @mark.asyncio
@@ -646,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()}

View File

@@ -1,8 +1,10 @@
from copy import deepcopy
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
from pytest import fixture, mark from pytest import fixture, mark
from ingestor.ingestor import Ingestor from ingestor.ingestor import Ingestor
from ingestor.managers.ingestor_manager import IngestorManager
@patch('ingestor.ingestor.getenv') @patch('ingestor.ingestor.getenv')
@@ -229,6 +231,10 @@ async def test_manage_leases_no_available_slots_extra_sltos(ingestor_manager_sta
ingestor_manager_started.ingestor_manager.drop_slot_leases.assert_called_once_with( ingestor_manager_started.ingestor_manager.drop_slot_leases.assert_called_once_with(
['tag2', 'tag3'] ['tag2', 'tag3']
) )
# The extra slots are unsubscribed before their leases are given back
calls = [c[0] for c in ingestor_manager_started.ingestor_manager.mock_calls]
assert calls.index('drop_slot_leases') > calls.index('unsubscribe_slot')
assert list(ingestor_manager_started.ingestor_manager.managed_tags) == ['tag1']
@mark.asyncio @mark.asyncio
@@ -332,3 +338,52 @@ async def test_update_ingestor_manager_no_ingestor_manager(ingestor_manager_star
ingestor_manager_started.ingestor_manager = None ingestor_manager_started.ingestor_manager = None
await ingestor_manager_started.update_ingestor_manager({}) await ingestor_manager_started.update_ingestor_manager({})
def _manager_with_real_unsubscribe(managed_tags, readers):
"""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.managed_tags = managed_tags
manager.opc_managers = readers
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
@mark.asyncio
async def test_dropped_extra_lease_does_not_stop_the_loop(ingestor_manager_started, reader_double):
"""Regression: manage_leases drops slot 2, then update_ingestor_manager sees slot 2 in
the snapshot only and unsubscribes it again. That used to raise KeyError and stop
the main loop (container restart)."""
reader = reader_double('1', '2')
ingestor_manager_started.ingestor_manager = _manager_with_real_unsubscribe(
{'1': {'kepserver': {}}, '2': {'kepserver': {}}}, {'kepserver': reader}
)
snapshot = deepcopy(ingestor_manager_started.ingestor_manager.managed_tags)
await ingestor_manager_started.manage_leases(0, 0, 1)
await ingestor_manager_started.update_ingestor_manager(snapshot)
reader.unsubscribe.assert_called_once_with('2')
assert list(reader.subscriptions) == ['1']
assert list(ingestor_manager_started.ingestor_manager.managed_tags) == ['1']
@mark.asyncio
async def test_slot_removed_from_redis_is_unsubscribed(ingestor_manager_started, reader_double):
"""Regression: update_slot_config drops a slot deleted by the orchestrator from
managed_tags without unsubscribing it; update_ingestor_manager must close its
subscription instead of raising KeyError."""
reader = reader_double('1', '3')
ingestor_manager_started.ingestor_manager = _manager_with_real_unsubscribe(
{'1': {'kepserver': {}}}, {'kepserver': reader}
)
snapshot = {'1': {'kepserver': {}}, '3': {'kepserver': {}}}
await ingestor_manager_started.update_ingestor_manager(snapshot)
reader.unsubscribe.assert_called_once_with('3')
assert list(reader.subscriptions) == ['1']