Files
sientia-dataops-opc-ingestor/ingestor/managers/ingestor_manager.py
vitor-aignosi ecb055eefb SIENTIAPDE-1973
SIENTIAPDE-1973: Implement per-connector OPC client certificates and remove legacy read engine.
This update overhauls certificate management, transitioning from a single platform certificate to individual client certificates defined in the slot configuration. The CertProvider now downloads certificates from MinIO based on certificate_id, applying dynamic security policies (mode, policy) from the slot. The legacy READ_ENGINE=legacy option and OpcManager have been entirely removed, standardizing on the ServerReader with strict slot v2 enforcement. Write command resolution (ServerConfigResolver) now consistently uses connectors joined with opc_certificates. The CERT_BUCKET environment variable is no longer used.
2026-10-29 12:39:21 -03:00

790 lines
33 KiB
Python

import traceback
from copy import deepcopy
from sientia_do.notifications.handlers import CoreNotificationHandler as NotificationHandler
from sientia_do.notifications.models import NotificationLevel
from sientia_do.observability.logger import Logger
from sientia_do.observability.metrics_controller import MetricsController
from sientia_do.observability.sientia_monitoring import SientiaMonitoring
import ingestor.metrics as metrics
from ingestor.config import GatewayConfig
from ingestor.connection.certs import build_cert_provider
from ingestor.connection.pool import OpcConnectionPool
from ingestor.io.data_sink import DataSink
from ingestor.io.slots import parse_slot
from ingestor.managers.data_manager import DataManager
from ingestor.managers.resource_manager import ResourceManager
from ingestor.managers.server_reader import ServerReader
class IngestorManager(SientiaMonitoring):
"""
Central coordinator for managing OPC data ingestion operations.
The IngestorManager orchestrates the interaction between different components:
- DataManager: Handles data persistence
- OPC Managers: Manage individual OPC UA server connections
- ResourceManager: Coordinates slot leasing and load balancing
This class implements a slot-based architecture where:
- Each slot represents a collection of OPC tags from one or more servers
- Slots are distributed across multiple ingestor instances for load balancing
- Dynamic slot allocation ensures optimal resource utilization
Key Responsibilities:
- Slot lease management and distribution
- OPC server connection lifecycle management
- Tag subscription coordination
- System health monitoring and integrity checks
- Load balancing across multiple ingestor instances
Args:
redis_data (dict): Redis connection parameters (host, port, username, password)
lease_ttl (int): Time-to-live for slot leases in seconds
heartbeat_ttl (int): Time-to-live for heartbeat signals in seconds
poll_interval (int): Main loop polling interval in seconds
mongo_connection_string (str): MongoDB connection string
mongo_database (str): MongoDB database name
metadata (dict): Application metadata for notifications and tracking
logger (Logger): Logger instance for application logging
notification_handler (NotificationHandler): Handler for sending notifications
Attributes:
data_manager (DataManager): Manages data persistence
opc_managers (dict): ServerReader per OPC server, keyed by server name
pool (OpcConnectionPool): OPC connections shared by reads, writes and gRPC;
owned here because reads start in prepare_ingestor, before GatewayRuntime
sink (DataSink): read egress (fan-out to every slot v2 `names` entry)
resource_manager (ResourceManager): Manages Redis-based resource coordination
number_of_slots (int): Total number of slots configured in the system
poll_interval (int): Main loop polling interval
managed_tags (dict): Currently managed tags organized by slot
opc_servers (dict): OPC server configurations
metadata (dict): Application metadata
"""
def __init__(
self,
redis_data: dict,
lease_ttl: int,
heartbeat_ttl: int,
poll_interval: int,
mongo_connection_string: str,
mongo_database: str,
metadata: dict,
logger: Logger,
notification_handler: NotificationHandler,
metrics_controller: MetricsController,
gateway_config: GatewayConfig | None = None,
):
redis_host: str = redis_data['host']
redis_port: int = int(redis_data['port'])
redis_username: str | None = redis_data.get('username', None)
redis_password: str | None = redis_data.get('password', None)
SientiaMonitoring.__init__(
self,
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
self.data_manager = DataManager(
mongo_connection_string=mongo_connection_string,
mongo_database=mongo_database,
metadata=metadata,
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
self.opc_managers: dict = {}
# Read path (SIENTIAPDE-1974): pooled connections with each server's client
# certificate (named in the slot) from MinIO. Nothing here touches the network at
# construction (MinIO is lazy), so a MinIO/Postgres/NATS outage can't stop the
# reads before they start.
config = gateway_config or GatewayConfig.certs_from_env()
self.cert_provider = build_cert_provider(
config=config,
logger=logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
metadata=metadata,
)
self.pool = OpcConnectionPool(
self.cert_provider, logger, notification_handler, metrics_controller
)
self.sink = DataSink(self.data_manager, None)
self.resource_manager = ResourceManager(
host=redis_host,
port=redis_port,
lease_ttl=lease_ttl,
heartbeat_ttl=heartbeat_ttl,
metadata=metadata,
logger=logger,
notification_handler=notification_handler,
username=redis_username,
password=redis_password,
metrics_controller=metrics_controller,
)
self.number_of_slots = 0
self.poll_interval = poll_interval
self.managed_tags: dict = {}
self.opc_servers: dict = {}
self.metadata = metadata
def _build_reader(self, server_config: dict) -> ServerReader:
return ServerReader(
name=server_config['name'],
server_config=server_config,
pool=self.pool,
sink=self.sink,
logger=self.logger,
notification_handler=self.notification_handler,
metrics_controller=self.metrics_controller,
metadata=self.metadata,
)
async def initialize_opc_from_config(self, server_config: dict) -> ServerReader | None:
"""
Initializes the reader of one OPC UA server from its slot configuration.
Creates a ServerReader and opens its pooled connection. With a `certificate`, the
connection uses the slot's security mode and policy, with the certificate
downloaded from MinIO; without one, it connects without security (D5).
Args:
server_config (dict): The server configuration of the slot v2, without the
'tags' key: 'name', 'server_id', 'url', 'subscription_period_ms',
'security' and 'certificate'.
Returns:
ServerReader | None: The connected reader, or None if the
connection fails (logged and notified, so the other servers keep working).
"""
try:
self.logger.info(f'Initializing OPC reader at {server_config["url"]}')
manager = self._build_reader(server_config)
manager.config = server_config
await manager.connect()
except Exception as e:
trace = traceback.format_exc()
self.send_notification(
metadata=self.metadata,
notification_id=f'OPC_CONNECTION_ERROR_{server_config["name"]}',
message=f'Error initializing OPC manager: {e}',
block='opc_manager',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.logger.error(trace)
return None
return manager
async def shutdown(self):
"""
Gracefully shuts down the IngestorManager and all its components.
This method ensures proper cleanup of:
- All OPC readers, their subscriptions and the pooled connections
- Data manager connections and resources
The shutdown process is performed asynchronously to allow proper cleanup
of all managed resources before termination.
"""
for _server_name, server in self.opc_managers.items():
await server.shutdown()
await self.pool.close_all()
self.data_manager.shutdown()
async def remove_server(self, server: str, evict: bool = True):
"""
Removes an OPC server from the ingestor.
Args:
server (str): The server name.
evict (bool): Also drop the pooled connection (lost server, failed
subscription). False when the server merely left this pod's slots:
the shared connection stays for writes/gRPC until the idle sweep.
"""
if server in self.opc_managers:
await self.opc_managers[server].shutdown(evict=evict)
del self.opc_managers[server]
for slot, _config in self.managed_tags.items():
self.managed_tags[slot].pop(server, None)
async def update_opc_servers(self):
"""
Updates the OPC (OLE for Process Control) server connections managed by the ingestor.
This method ensures that the OPC servers defined in `self.managed_tags` are properly
initialized and updated. It performs the following tasks:
- Registers new OPC servers based on the configuration in `self.managed_tags`.
- Updates existing OPC server instances if their configuration has changed.
- Disconnects and removes OPC servers that are no longer present in `self.managed_tags`.
Steps:
1. Iterates through the `self.managed_tags` dictionary to identify and register servers.
2. Initializes new OPC server instances if they are not already managed.
3. Reinitializes OPC server instances if their configuration has changed.
4. Disconnects and removes OPC servers that are no longer registered.
Attributes:
self.managed_tags (dict): A nested dictionary containing slot and server configurations.
self.opc_managers (dict): A dictionary mapping server names to their
OPC manager instances.
self.data_manager: An object responsible for managing data operations.
self.logger: A logging object for recording warnings and other messages.
Raises:
Any exceptions raised during OPC server initialization or disconnection.
Logs:
- Warnings for servers that are no longer found in `self.managed_tags`.
"""
registered_servers = []
current_managed_tags = deepcopy(self.managed_tags)
for _slot, slot_config in current_managed_tags.items():
for server, server_config in slot_config.items():
registered_servers.append(server)
server_config = deepcopy(server_config)
server_config.pop('tags', None)
server_instance = self.opc_managers.get(server, None)
if server_instance is None:
self.logger.info(f'Initializing OPC manager for server {server}')
server_instance = await self.initialize_opc_from_config(server_config)
elif server_instance.config != server_config:
self.logger.warning(f'Reinitializing OPC manager for server {server}')
await server_instance.shutdown()
del self.opc_managers[server]
server_instance = await self.initialize_opc_from_config(server_config)
else:
self.logger.debug(
f'OPC manager for server {server} is already initialized and up to date'
)
if server_instance is not None:
self.opc_managers[server] = server_instance
else:
self.logger.warning(
f'Failed to initialize OPC manager for server {server}, '
f'removing server from managed tags.'
)
await self.remove_server(server)
servers = list(self.opc_managers.keys())
for server in servers:
if server not in registered_servers:
self.logger.warning(
f'Server {server} not found in managed tags. Unsubscribing from server.'
)
# Slot moved away: keep the shared connection for writes/gRPC.
await self.remove_server(server, evict=False)
await self.emit_metric(
metric_object=metrics.OPC_MANAGERS_ACTIVE,
method='set',
value=len(self.opc_managers),
tags={
'pod_id': self.pod_id,
},
)
async def check_opc_servers_integrity(self):
"""
Checks the integrity of the OPC servers and updates the OPC servers if necessary.
This method performs health checks on all managed OPC servers by:
- Checking cycle counts for data reception
- Monitoring connection health and data flow
- Triggering reconnection for lost servers
- Updating metrics for active OPC managers
Side Effects:
- Updates cycle monitoring for all nodes
- Removes lost servers from managed tags
- Updates OPC manager metrics
"""
to_disconnect: list[str] = []
for server, opc_manager in self.opc_managers.items():
await opc_manager.check_cycles()
is_lost = await opc_manager.check_opc_listenning()
if is_lost:
self.logger.warning(f'OPC server {server} is lost. Server will be disconnected.')
to_disconnect.append(server)
for server in to_disconnect:
await self.remove_server(server)
await self.emit_metric(
metric_object=metrics.OPC_MANAGERS_ACTIVE,
method='set',
value=len(self.opc_managers),
tags={
'pod_id': self.pod_id,
},
)
async def declare_active(self):
"""
Declares the ingestor as active by sending a heartbeat signal to the resource manager.
This method ensures that the ingestor is marked as active by invoking the
`ingestor_heartbeat` method of the associated resource manager. The heartbeat
mechanism enables load balancers and monitoring systems to track active instances.
Side Effects:
- Updates Redis with current instance heartbeat
- Enables load balancing and health monitoring
"""
await self.resource_manager.ingestor_heartbeat()
async def get_active_ingestors(self) -> list[str]:
"""
Retrieve a list of active ingestors.
This method fetches all ingestors from the resource manager and returns them.
If no ingestors are found, an empty list is returned.
Returns:
List[str]: A list of active ingestor names, or an empty list if none are found.
The method queries Redis for all active ingestor heartbeats and extracts
the pod identifiers for load balancing and coordination purposes.
"""
ingestors = await self.resource_manager.get_all_ingestors()
return ingestors if ingestors else []
async def get_number_of_leases(self) -> int:
"""
Retrieves the number of leases managed by the resource manager.
This method fetches all available leases from the resource manager,
calculates their count, and updates the `number_of_slots` attribute.
Returns:
int: The total number of leases. Returns 0 if no leases are available.
Side Effects:
- Updates internal slot count tracking
- Updates Prometheus metrics for total leases
"""
leases = await self.resource_manager.get_all_leases()
self.number_of_slots = len(leases) if leases else 0
await self.emit_metric(
metric_object=metrics.LEASES_TOTAL,
method='set',
value=self.number_of_slots,
tags={
'pod_id': self.pod_id,
},
)
return self.number_of_slots
async def get_number_of_slots(self) -> int:
"""
Retrieves the number of slots managed by the resource manager.
This method fetches all available slots from the resource manager,
calculates their count, and updates the `number_of_slots` attribute.
Returns:
int: The total number of slots. Returns 0 if no slots are available.
Side Effects:
- Updates internal slot count tracking
- Updates Prometheus metrics for total slots
"""
slots = await self.resource_manager.get_all_slots()
self.number_of_slots = len(slots) if slots else 0
await self.emit_metric(
metric_object=metrics.SLOTS_TOTAL,
method='set',
value=self.number_of_slots,
tags={
'pod_id': self.pod_id,
},
)
return self.number_of_slots
async def get_slot_leases(self, max_slots: int = 1) -> dict:
"""
Acquires a specified number of resource slots by leasing them from the resource manager.
This method implements the slot acquisition logic for load balancing:
- Iterates through available slots and attempts to lease them
- Logs the leasing of each slot
- Updates the `managed_tags` attribute with the acquired slots
- Stops leasing once the specified `max_slots` are acquired
Args:
max_slots (int): The maximum number of slots to lease. Defaults to 1.
Returns:
Dict: A dictionary where the keys are the slot identifiers (as strings)
and the values are the leased slot details.
Behavior:
- Attempts to lease slots sequentially starting from slot 1
- Skips slots that cannot be retrieved after leasing
- Logs warnings if unable to acquire the requested number of slots
- Updates metrics for acquired slots and managed slots count
Notes:
- If a slot is leased but its details cannot be retrieved
(i.e., `get_tag_slot` returns None) or are malformed, that slot is skipped.
- Slot details are normalized to the slot v2 structure (parse_slot).
"""
acquired = {}
for i in range(1, self.number_of_slots + 1):
if await self.resource_manager.lease_tag(str(i)):
self.logger.info(f'Leased slot {i}')
raw = await self.resource_manager.get_tag_slot(str(i))
if raw is None:
continue
slots = self.parse_slot_config(str(i), raw)
if slots is None:
continue
acquired[str(i)] = slots
await self.emit_metric(
metric_object=metrics.SLOTS_ACQUIRED,
method='inc',
value=1,
tags={
'pod_id': self.pod_id,
},
)
if len(acquired) >= max_slots:
self.managed_tags.update(acquired)
await self.emit_metric(
metric_object=metrics.SLOTS_MANAGED,
method='set',
value=len(self.managed_tags),
tags={
'pod_id': self.pod_id,
},
)
return acquired
self.logger.warning(
f'Unable to acquire {max_slots} slots. Only {acquired} slots were leased.'
)
self.managed_tags.update(acquired)
await self.emit_metric(
metric_object=metrics.SLOTS_MANAGED,
method='set',
value=len(self.managed_tags),
tags={
'pod_id': self.pod_id,
},
)
return acquired
async def unsubscribe_slot(self, slot: str):
"""
Unsubscribes a specific slot from every OPC reader that holds a subscription for it.
The readers' own subscriptions are the source of truth, not managed_tags: the
main loop removes a slot from managed_tags before update_ingestor_manager asks
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:
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
"""
# list() snapshots the readers: the loop awaits, so a reader added or removed
# meanwhile must not break the iteration. SonarQube flags it as an unnecessary
# list() (S7504); the issue is accepted in SonarQube for this reason.
for server, reader in list(self.opc_managers.items()):
if slot in reader.subscriptions:
await self._unsubscribe_reader(server, reader, slot)
async def _unsubscribe_reader(self, server: str, reader: ServerReader, slot: str) -> None:
"""
Unsubscribe one reader from a slot without letting a failure stop the main loop.
A failure (e.g. delete() raising 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): 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):
"""
Updates the configuration of managed slots by renewing their leases,
fetching the latest configurations, and handling any changes or removals.
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, 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.
7. Updates the OPC servers after processing all slots.
Side Effects:
- Modifies the `managed_tags` dictionary to reflect the latest slot configurations.
- Updates OPC server subscriptions based on the current state of managed slots.
Raises:
None explicitly, but relies on the behavior of `resource_manager` and
other dependencies for error handling.
Logging:
- Logs warnings for removed slots.
- Logs informational messages for updated slot configurations.
"""
removed_slots: list[str] = []
for slot, _slot_config in self.managed_tags.items():
await self.resource_manager.renew_tag_lease(slot)
raw = await self.resource_manager.get_tag_slot(slot)
if raw is None:
removed_slots.append(slot)
continue
update = self.parse_slot_config(slot, raw)
if update is None:
# Malformed config: keep reading with the last good one.
continue
self.managed_tags[slot] = update
# 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:
"""
Normalizes the raw slot read from Redis to the slot v2 structure (contract C1).
Only the slot v2 is accepted (SIENTIAPDE-1974): the gateway and the Orchestrator
are deployed together, and a slot v1 (`topics`, no `security`) is rejected.
Returns:
dict | None: The parsed slot, or None when the config is malformed
(logged and notified).
"""
try:
return parse_slot(raw)
except (AttributeError, KeyError, TypeError, ValueError) as e:
self.send_notification(
metadata=self.metadata,
notification_id=f'OPC_SLOT_CONFIG_ERROR_{slot}',
message=f'Slot {slot} has an invalid configuration: {e!r}',
block='ingestor_manager',
level=NotificationLevel.ERROR,
)
self.logger.error(f'Slot {slot} has an invalid configuration: {e!r}')
return None
async def drop_slot_leases(self, ids: list[str]) -> None:
"""
Releases the leases associated with the specified slot IDs.
This method iterates through a list of slot IDs and calls the
`drop_tag_lease` method of the `resource_manager` to release
the lease for each ID. It's used during load balancing and
graceful shutdown scenarios.
Args:
ids (List[str]): A list of slot IDs for which the leases
should be released.
Returns:
None
Side Effects:
- Releases Redis-based leases for specified slots
- Updates metrics for released slots count
"""
for lease_id in ids:
await self.resource_manager.drop_tag_lease(lease_id)
await self.emit_metric(
metric_object=metrics.SLOTS_RELEASED,
method='inc',
value=1,
tags={
'pod_id': self.pod_id,
},
)
async def manage_server(self, slot: str, server: str, server_config: dict, tags: dict) -> int:
"""
Manages the subscription of tags to a specified OPC server and slot.
This method ensures that the specified server and slot have an active subscription
for the provided tags. If the server or slot is not properly configured, or if
subscription fails, appropriate error handling is performed.
Args:
slot (str): The slot identifier for the subscription.
server (str): The name of the OPC server.
server_config (dict): Configuration dictionary for the server, which includes
the tags to be subscribed under the key 'tags'.
tags (dict): A dictionary of tags to be subscribed.
Returns:
int: Status code indicating the result of the operation:
- 0: Subscription was successful.
- 1: Server not found in `opc_managers`.
- 2: Subscription creation or tag subscription failed.
Logs:
- Logs informational messages about the subscription process.
- Logs errors if the server is not found, subscription creation fails, or
tag subscription fails.
- Logs a warning if a subscription is removed due to failure.
Raises:
Exception: Any unexpected exceptions during subscription creation or tag
subscription are logged but not propagated.
Side Effects:
- Creates or updates OPC subscriptions
- Manages tag subscriptions on OPC servers
- Updates error metrics and notifications
"""
self.logger.info(f'Subscribing to tags from {slot}:{server}')
tags_to_sub = server_config.get('tags')
if server not in self.opc_managers:
self.logger.error(f'Server {server} not found in opc_managers.')
return 1
if slot not in self.opc_managers[server].subscriptions:
try:
await self.opc_managers[server].create_subscription(slot)
except Exception as e:
self.logger.error(f'Failed to create subscription for slot {slot}: {e}')
return 2
try:
self.logger.info(tags_to_sub)
await self.opc_managers[server].subscribe(
slot, deepcopy(tags_to_sub), self.poll_interval
)
self.logger.info(tags_to_sub)
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_SUBSCRIPTION_ERROR_{slot}:{server}',
message=f'Failed to subscribe to tags from {slot}:{server}\n{tags}: {e}',
block='opc_manager',
level=NotificationLevel.ERROR,
attachment_content=trace,
)
self.logger.error(trace)
self.logger.warning(f'Removing subscription from server {server} for slot {slot}')
await self._unsubscribe_reader(server, self.opc_managers[server], slot)
return 2
return 0
async def subscribe_to_tags(self, tags: dict) -> None:
"""
Subscribes to a set of tags and manages their configurations.
This method processes a dictionary of tags, iterating through each slot and server
configuration. It attempts to manage the server configurations and removes any
servers that return a specific response code.
Args:
tags (Dict): A dictionary containing tag configurations. The structure is
expected to be {slot: {server: server_config}}.
Side Effects:
- Logs the provided tags for debugging purposes.
- Updates the `managed_tags` attribute by removing servers that meet the
removal criteria.
- Establishes OPC subscriptions for all configured tags.
Removal Criteria:
- If the `manage_server` method returns a response code of 2 for a given
slot and server, that server is removed from the `managed_tags` attribute.
The method ensures that only successfully configured servers remain in the
managed tags, maintaining system stability and preventing subscription errors.
"""
to_remove = []
self.logger.info(tags)
for slot, slot_config in tags.items():
for server, server_config in slot_config.items():
response = await self.manage_server(slot, server, server_config, tags)
if response == 2:
to_remove.append([slot, server])
for slot, server in to_remove:
if server in self.opc_managers:
await self.opc_managers[server].shutdown()
del self.opc_managers[server]
self.managed_tags[slot].pop(server, None)