feat(gateway): gRPC server ReadTag (C4)
This commit is contained in:
68
ingestor/adapters/grpc_server.py
Normal file
68
ingestor/adapters/grpc_server.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""gRPC input adapter for on-demand tag reads (contract C4).
|
||||
|
||||
Thin adapter: unpack the request, call ActiveReadEngine.read_now, map the
|
||||
result onto ReadTagResponse. Internal-only for now -- no auth/TLS, per the
|
||||
feature owner (no consumer exists yet, likely a frontend later).
|
||||
"""
|
||||
|
||||
import grpc
|
||||
from sientia_do.observability.logger import Logger
|
||||
|
||||
from ingestor.engines.active_read import ActiveReadEngine
|
||||
from ingestor.proto import opc_gateway_pb2, opc_gateway_pb2_grpc
|
||||
|
||||
|
||||
class ReadTagServicer(opc_gateway_pb2_grpc.OpcGatewayServicer):
|
||||
def __init__(self, active_read: ActiveReadEngine, logger: Logger, slot_lookup=None):
|
||||
self.active_read = active_read
|
||||
self.logger = logger
|
||||
# ponytail: slot_lookup wiring (address -> names from the current slot)
|
||||
# lands in Task 18 (app wiring); optional callable keeps this servicer usable now.
|
||||
self.slot_lookup = slot_lookup
|
||||
|
||||
async def ReadTag(self, request, context):
|
||||
names = None
|
||||
if self.slot_lookup is not None:
|
||||
names = self.slot_lookup(request.server_id, request.addr)
|
||||
try:
|
||||
result = await self.active_read.read_now(request.server_id, request.addr, names=names)
|
||||
except Exception as e:
|
||||
self.logger.error(f'ReadTag failed for {request.server_id}:{request.addr}: {e}')
|
||||
await context.abort(grpc.StatusCode.INTERNAL, f'read failed: {e}')
|
||||
return
|
||||
return opc_gateway_pb2.ReadTagResponse(
|
||||
value=str(result['value']),
|
||||
opc_status_code=result['opc_status_code'] or '',
|
||||
source_timestamp=result['source_timestamp'] or '',
|
||||
server_timestamp=result['server_timestamp'] or '',
|
||||
persisted=result['persisted'],
|
||||
)
|
||||
|
||||
|
||||
class GrpcServer:
|
||||
def __init__(
|
||||
self,
|
||||
active_read: ActiveReadEngine,
|
||||
logger: Logger,
|
||||
port: int = 50051,
|
||||
slot_lookup=None,
|
||||
):
|
||||
self.active_read = active_read
|
||||
self.logger = logger
|
||||
self.port = port
|
||||
self.slot_lookup = slot_lookup
|
||||
self.server = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self.server = grpc.aio.server()
|
||||
opc_gateway_pb2_grpc.add_OpcGatewayServicer_to_server(
|
||||
ReadTagServicer(self.active_read, self.logger, self.slot_lookup), self.server
|
||||
)
|
||||
self.server.add_insecure_port(f'[::]:{self.port}')
|
||||
await self.server.start()
|
||||
self.logger.info(f'GrpcServer started on port {self.port}')
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.server is None:
|
||||
return
|
||||
await self.server.stop(grace=5)
|
||||
96
tests/unit/adapters/test_grpc_server.py
Normal file
96
tests/unit/adapters/test_grpc_server.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
|
||||
from ingestor.adapters.grpc_server import GrpcServer, ReadTagServicer
|
||||
from ingestor.proto.opc_gateway_pb2 import ReadTagRequest
|
||||
|
||||
|
||||
def make_request(server_id='srv1', addr='ns=2;i=5'):
|
||||
return ReadTagRequest(server_id=server_id, addr=addr, data_type='float')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_tag_maps_full_result_onto_response():
|
||||
active_read = AsyncMock()
|
||||
active_read.read_now.return_value = {
|
||||
'value': 1.5,
|
||||
'opc_status_code': 'Good',
|
||||
'source_timestamp': '2026-07-15T10:00:00-03:00',
|
||||
'server_timestamp': '2026-07-15T10:00:01-03:00',
|
||||
'persisted': False,
|
||||
}
|
||||
servicer = ReadTagServicer(active_read, MagicMock())
|
||||
|
||||
response = await servicer.ReadTag(make_request(), AsyncMock())
|
||||
|
||||
assert response.value == '1.5'
|
||||
assert response.opc_status_code == 'Good'
|
||||
assert response.source_timestamp == '2026-07-15T10:00:00-03:00'
|
||||
assert response.server_timestamp == '2026-07-15T10:00:01-03:00'
|
||||
assert response.persisted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_tag_none_timestamps_become_empty_string():
|
||||
active_read = AsyncMock()
|
||||
active_read.read_now.return_value = {
|
||||
'value': 1.5,
|
||||
'opc_status_code': None,
|
||||
'source_timestamp': None,
|
||||
'server_timestamp': None,
|
||||
'persisted': False,
|
||||
}
|
||||
servicer = ReadTagServicer(active_read, MagicMock())
|
||||
|
||||
response = await servicer.ReadTag(make_request(), AsyncMock())
|
||||
|
||||
assert response.opc_status_code == ''
|
||||
assert response.source_timestamp == ''
|
||||
assert response.server_timestamp == ''
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_tag_aborts_with_internal_on_engine_failure():
|
||||
active_read = AsyncMock()
|
||||
active_read.read_now.side_effect = RuntimeError('boom')
|
||||
logger = MagicMock()
|
||||
servicer = ReadTagServicer(active_read, logger)
|
||||
context = AsyncMock()
|
||||
context.abort.side_effect = grpc.RpcError('aborted')
|
||||
|
||||
with pytest.raises(grpc.RpcError):
|
||||
await servicer.ReadTag(make_request(), context)
|
||||
|
||||
context.abort.assert_awaited_once()
|
||||
args = context.abort.call_args.args
|
||||
assert args[0] == grpc.StatusCode.INTERNAL
|
||||
logger.error.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_tag_uses_slot_lookup_names_when_provided():
|
||||
active_read = AsyncMock()
|
||||
active_read.read_now.return_value = {
|
||||
'value': 1,
|
||||
'opc_status_code': 'Good',
|
||||
'source_timestamp': None,
|
||||
'server_timestamp': None,
|
||||
'persisted': True,
|
||||
}
|
||||
names = [{'collection': 'c1', 'name': 't1'}]
|
||||
slot_lookup = MagicMock(return_value=names)
|
||||
servicer = ReadTagServicer(active_read, MagicMock(), slot_lookup=slot_lookup)
|
||||
|
||||
await servicer.ReadTag(make_request(server_id='srv1', addr='ns=2;i=5'), AsyncMock())
|
||||
|
||||
slot_lookup.assert_called_once_with('srv1', 'ns=2;i=5')
|
||||
active_read.read_now.assert_awaited_once_with('srv1', 'ns=2;i=5', names=names)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grpc_server_stop_before_start_does_not_raise():
|
||||
server = GrpcServer(active_read=AsyncMock(), logger=MagicMock())
|
||||
|
||||
await server.stop() # no raise
|
||||
Reference in New Issue
Block a user