44 KiB
OPC Gateway Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Evolve sientia-dataops-opc-ingestor into an OPC Gateway that reads by subscription (multi-name fan-out), reads actively (scheduled + on-demand via gRPC), writes OPC via NATS, manages certs via MinIO+Mongo, and emits detailed metrics — following the OPC best practices of SIENTIAPDE-1879.
Architecture: Approach B — engines per capability over a shared OpcConnectionPool (connections keyed by server, not by slot). Input adapters (NatsWriteConsumer, GrpcServer) feed the engines; a DataSink handles egress (MongoDB reads + Postgres write_status; the legacy Kafka egress is removed). Coordination (slot lease/heartbeat) is reused from the existing Redis layer.
Tech Stack: Python 3.11, asyncua, nats-py (Core NATS), grpcio/grpcio-tools, prometheus_client, sientia_do dataops-library (Mongo/Redis/MinIO/Postgres), pytest + pytest-asyncio, ruff (line-length 100, single quotes).
Spec: docs/superpowers/specs/2026-07-13-opc-gateway-design.md (read it first — contracts C1–C4, metrics, §13 1879 checklist).
Scope: This plan is the Gateway only (repo sientia-dataops-opc-ingestor). Orchestrator (1974) and Laborious (1975) are parallel tracks with their own plans in their own repos; here they appear only as the contracts C1 (slot v2), C2 (NATS message), C3 (write_status).
Conventions (match the repo):
- Managers/engines extend
SientiaMonitoring(sientia_do.observability.sientia_monitoring) → givesself.logger,self.pod_id,await self.emit_metric(...),await self.send_notification_async(...). - Metrics defined in
ingestor/metrics.pyviaprometheus_client. - Package stays
ingestor/(do not rename — the Helm health probe and GiteaPYTHON_APPentrypoint depend oningestor.app). New capabilities go in new subpackages. - Test command everywhere:
python -m pytest <path> -v. Lint:ruff check ingestor tests.
File Structure
New subpackages under ingestor/ (one clear responsibility each; keep files < ~500 lines, functions < ~20 lines per 1879):
ingestor/connection/pool.py—OpcConnectionPool:asyncuaclients keyed byserver_id, lazy open, reuse, idle-eviction, reconnect.ingestor/connection/security.py—build_security(...):Basic256Sha256,secure_channel_timeout = int(session_timeout*1.25), cert file paths.ingestor/connection/certs.py—CertProvider: readplatform_certificates(Mongo) + download cert/key from MinIO (sientia-dataopsbucket) to anemptyDir/tmpfs path.ingestor/connection/clock.py—MonotonicClock: singletime.monotonic()reference + optional NTP;elapsed(),now_wall().ingestor/converters.py—to_variant_value(raw, data_type): safe type coercion (bool-string bug fix +try/except).ingestor/engines/subscription.py—SubscriptionEngine: slot v2 → subscription → multi-name fan-out toDataSink.ingestor/engines/active_read.py—ActiveReadEngine: scheduled reads +read_now()for on-demand.ingestor/engines/write.py—WriteEngine: OPC write with status_code/timestamp/quality flags, rate-limit, error mapping, →DataSink.update_write_status.ingestor/io/slots.py—load_slot(redis, slot_id): parse slot v2 (C1) into typed structures.ingestor/io/data_sink.py—DataSink:publish_reading(...)(MongoDB fan-out) +update_write_status(...)(Postgres UPDATE bytimestamp+model_id).ingestor/adapters/nats_consumer.py—NatsWriteConsumer: subscribeopc.write(queue groupgateway-writers) →WriteEngine.ingestor/adapters/grpc_server.py—GrpcServer:ReadTagRPC →ActiveReadEngine.read_now.ingestor/proto/opc_gateway.proto(+ generated*_pb2.py,*_pb2_grpc.py).ingestor/config.py— extend with new env (NATS, gRPC, cert bucket, NTP).ingestor/metrics.py— extend with new metrics (§9 of spec).
Reused as-is / lightly touched: ingestor/managers/resource_manager.py (lease/heartbeat), ingestor/managers/data_manager.py (MongoDB egress — wrapped by DataSink; the legacy Kafka path is removed in Task 9b), ingestor/app.py (wire new engines in F4).
Tests mirror source: tests/unit/connection/…, tests/unit/engines/…, tests/unit/io/…, tests/unit/adapters/…, tests/unit/test_converters.py. Add an __init__.py to each new test dir (the repo's existing test dirs use them for collection, e.g. tests/unit/managers/__init__.py).
Phase Delivery & Commit Ledger
Delivery model: one PR per phase (F0…F4), opened only at the very end. To avoid one giant PR, each phase is delivered and reviewed as its own PR. But no PR is opened until every phase is complete, tested, and working — we build all phases on this feature branch first, then split them into per-phase PRs at the end. This section is the ledger that makes that split possible.
Phase close-out (the LAST task of every phase — Tasks 7.end, 10.end, 13.end, 16.end, 21.end)
At the end of each phase Fx, before starting the next:
- Confirm the phase is green: full
python -m pytest tests -vpasses andruff check ingestor testsis clean. - Record the phase's commit SHAs (in order) in the ledger below, with the phase's base (parent of its first commit).
- Do NOT open the PR yet — PRs are opened only after F4 closes (see "Final: open per-phase PRs").
Commit ledger
F0 — Connection core — base 498030d (merge-base with main). Status: ✅ complete, 162 unit tests green, ruff clean.
0694f6achore(gateway): add nats-py, grpcio deps (Task 1)3dd4f60feat(gateway): add write/active-read/error metrics (Task 2)55f6721feat(gateway): safe type converter (1879 BUG-1/BUG-2) (Task 3)d6cb1e4feat(gateway): monotonic clock helper (1879) (Task 4)b05c2fdfeat(gateway): Basic256Sha256 + channel>session lifetime (1879) (Task 5)ec2a324feat(gateway): cert provider from MinIO+Mongo (sientia-dataops) (Task 6)5d30a5cfeat(gateway): OpcConnectionPool (per-server, reuse, idle-evict) (Task 7)0b29857fix(gateway): per-server lock in OpcConnectionPool to avoid duplicate connects (Task 7)b57be9frefactor(gateway): drop dead error_stack and unused metadata from OpcConnectionPool (ponytail) (Task 7)- Also fixed a pre-existing test flake in
a08fd88(gc.collect() in test_ingestor_manager) — include in the F0 code PR. - ⚠️ The branch also carries docs commits (design/spec/plan) interleaved in time (
6f6f3bf,c980092,524f4d6,8a05ff7,941c6c0,7cffc26,bbe10a4,09e4290, plus the gRPC/Kafka doc updates). Those are not F0 code — the aig-docs equivalents already ship in PR #76; the repodocs/superpowers/**commits can go in a separate small docs PR or ride with F0. Keep them out of the F0 code PR scope.
F1 — Subscription reads (C1) — base 00f919c. Status: ✅ complete, 168 unit tests green, ruff clean.
44df022feat(gateway): slot v2 loader (contract C1) (Task 8)ae88ac6feat(gateway): DataSink.publish_reading multi-name fan-out (Task 9)323bf24refactor(gateway): remove legacy Kafka egress (MongoDB-only) (Task 9b)00c74e5feat(gateway): SubscriptionEngine multi-name fan-out (C1) (Task 10)- Carry-forward to F4/later (from F1 reviews):
- Task 18 (wiring): decide how many
SubscriptionEngineinstances the app builds. Current code keeps a single engine-widenon_receive_countandcheck_opc_listenning(server_name)takes a param — safe only if one engine per OPC server. If one engine is shared across servers, a datachange on server A masks silence on server B → must partition cycle/non-receive state per server. Make this deliberate at wiring time. - Task 12 (DataSink): drop the currently-unused
metadataparam fromDataSink.__init__while addingupdate_write_status. - F4 cleanup:
README.mdandtests/functional/test_single_node.py/conftest.pystill reference Kafka (stale/commented dead code) — remove during F4 hardening (Task 21 sweep). F2 — Active write via NATS (C2, C3): (fill at F2 close-out) F3 — Active reads + gRPC (C4): (fill at F3 close-out) F4 — Wiring, health, deploy, hardening: (fill at F4 close-out)
- Task 18 (wiring): decide how many
Final: open per-phase PRs (only after F4 is complete, tested, working)
Since all phases live on one feature branch, split them at the end using the recorded SHA ranges: for each phase in order F0→F4, create a phase branch off main (or stacked on the previous phase branch) containing only that phase's code commits (cherry-pick the ledger's SHA range), then open one PR per phase targeting main, in phase order. This keeps each PR small and reviewable instead of one giant diff.
Phase F0 — Foundations (deps, config, metrics, connection pool)
Task 1: Add dependencies
Files:
-
Modify:
requirements.txt(andpyproject.tomlif deps are declared there) -
Step 1: Add libs
Add to requirements.txt:
nats-py>=2.6
grpcio>=1.60
grpcio-tools>=1.60
(asyncua, prometheus_client, sientia_do, pandas already present. Bump sientia_do to the version that ships the generic Mongo methods — feature/SIENTIAPDE-1590 / ≥ current pin.)
- Step 2: Install & verify import
Run: pip install -r requirements.txt && python -c "import nats, grpc; print('ok')"
Expected: ok
- Step 3: Commit
git add requirements.txt pyproject.toml
git commit -m "chore(gateway): add nats-py, grpcio deps"
Task 2: New metrics (spec §9)
Files:
-
Modify:
ingestor/metrics.py -
Test:
tests/unit/test_metrics.py(extend existing) -
Step 1: Write failing test — assert the new metric objects exist and accept labels.
# tests/unit/test_metrics.py
import ingestor.metrics as m
def test_gateway_metrics_exist():
m.OPC_WRITE_TOTAL.labels(pod_id='p', server_name='s', model_id='1', result='success')
m.OPC_WRITE_LATENCY.labels(pod_id='p', server_name='s')
m.OPC_ACTIVE_READ_TOTAL.labels(pod_id='p', server_name='s', result='success')
m.OPC_ACTIVE_READ_LATENCY.labels(pod_id='p', server_name='s')
m.OPC_WRITE_STATUS_BY_MODEL.labels(pod_id='p', model_id='1', status='ok')
m.OPC_TAG_VALUE.labels(pod_id='p', tag_name='t', model_id='1')
m.OPC_READ_LAG.labels(pod_id='p', server_name='s')
m.OPC_ERROR_BY_TYPE.labels(pod_id='p', server_name='s', error_type='BadTimeout')
m.OPC_DISCONNECTIONS_TOTAL.labels(pod_id='p', server_name='s')
-
Step 2: Run — expect FAIL (
AttributeError). Run:python -m pytest tests/unit/test_metrics.py -v -
Step 3: Implement — append to
ingestor/metrics.py:
# --- Gateway: Write metrics ---
OPC_WRITE_TOTAL = Counter(
'opc_write_total', 'OPC active write attempts',
['pod_id', 'server_name', 'model_id', 'result'], # result: success|error
)
OPC_WRITE_LATENCY = Histogram(
'opc_write_latency_seconds', 'OPC active write latency', ['pod_id', 'server_name'],
)
OPC_WRITE_STATUS_BY_MODEL = Gauge(
'opc_write_status_by_model', 'Last write status per model (1=ok,0=error)',
['pod_id', 'model_id', 'status'],
)
# --- Gateway: Active read metrics ---
OPC_ACTIVE_READ_TOTAL = Counter(
'opc_active_read_total', 'OPC active read attempts',
['pod_id', 'server_name', 'result'],
)
OPC_ACTIVE_READ_LATENCY = Histogram(
'opc_active_read_latency_seconds', 'OPC active read latency', ['pod_id', 'server_name'],
)
OPC_READ_LAG = Gauge(
'opc_read_lag_seconds', 'Lag between OPC source timestamp and machine time',
['pod_id', 'server_name'],
)
OPC_TAG_VALUE = Gauge(
'opc_tag_value', 'Last value read per tag/model', ['pod_id', 'tag_name', 'model_id'],
)
# --- Gateway: connection/error metrics ---
OPC_DISCONNECTIONS_TOTAL = Counter(
'opc_disconnections_total', 'OPC disconnections', ['pod_id', 'server_name'],
)
OPC_ERROR_BY_TYPE = Counter(
'opc_error_by_type_total', 'OPC errors by type', ['pod_id', 'server_name', 'error_type'],
)
-
Step 4: Run — expect PASS.
python -m pytest tests/unit/test_metrics.py -v -
Step 5: Commit —
git commit -am "feat(gateway): add write/active-read/error metrics"
Task 3: Safe type converter (1879 BUG-1 & BUG-2)
Files:
-
Create:
ingestor/converters.py -
Test:
tests/unit/test_converters.py -
Step 1: Write failing tests (the bool-string bug is the whole point):
# tests/unit/test_converters.py
import pytest
from ingestor.converters import to_typed_value, ConversionError
@pytest.mark.parametrize('raw,expected', [
('false', False), ('False', False), ('0', False), ('no', False),
('off', False), ('', False), ('true', True), ('1', True), (True, True),
])
def test_bool_from_json_string(raw, expected):
assert to_typed_value(raw, 'boolean') is expected
def test_float_and_int():
assert to_typed_value('42.5', 'float') == 42.5
assert to_typed_value('7', 'int') == 7
@pytest.mark.parametrize('raw,dtype', [
(None, 'int'), ('abc', 'float'), ('123.123', 'int'), (None, 'float'),
])
def test_invalid_raises_conversion_error(raw, dtype):
with pytest.raises(ConversionError):
to_typed_value(raw, dtype)
-
Step 2: Run — expect FAIL (module missing).
python -m pytest tests/unit/test_converters.py -v -
Step 3: Implement
ingestor/converters.py:
"""Safe coercion of JSON-transported values into typed OPC write values.
Fixes 1879 BUG-1 (bool('false') is True in Python) and BUG-2 (uncaught
TypeError/ValueError from int/float on bad input).
"""
_FALSE_STRINGS = {'false', '0', 'no', 'off', ''}
class ConversionError(ValueError):
"""Raised when a raw value cannot be coerced to the requested type."""
def _to_bool(raw) -> bool:
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
return str(raw).strip().lower() not in _FALSE_STRINGS
def to_typed_value(raw, data_type: str):
"""Coerce raw (often a JSON string) to the given data_type.
data_type: 'boolean' | 'int' | 'float' | 'double' | 'string'.
Raises ConversionError on any invalid input (never a bare TypeError).
"""
dtype = data_type.strip().lower()
try:
if dtype in ('bool', 'boolean'):
return _to_bool(raw)
if dtype in ('int', 'integer'):
if isinstance(raw, str) and ('.' in raw or raw.strip() == ''):
raise ConversionError(f'invalid int: {raw!r}')
return int(raw)
if dtype in ('float', 'double', 'real'):
return float(raw)
if dtype in ('str', 'string'):
return str(raw)
except (TypeError, ValueError) as e:
raise ConversionError(f'cannot convert {raw!r} to {data_type}: {e}') from e
raise ConversionError(f'unknown data_type: {data_type!r}')
-
Step 4: Run — expect PASS.
python -m pytest tests/unit/test_converters.py -v -
Step 5: Commit —
git commit -am "feat(gateway): safe type converter (1879 BUG-1/BUG-2)"
Task 4: Monotonic clock helper (1879 §2)
Files:
-
Create:
ingestor/connection/clock.py,ingestor/connection/__init__.py -
Test:
tests/unit/connection/test_clock.py -
Step 1: Failing test
# tests/unit/connection/test_clock.py
from ingestor.connection.clock import MonotonicClock
def test_elapsed_is_non_negative_and_monotonic():
clk = MonotonicClock()
a = clk.elapsed()
b = clk.elapsed()
assert a >= 0 and b >= a # never negative even if wall clock jumps back
-
Step 2: Run — expect FAIL.
-
Step 3: Implement
ingestor/connection/clock.py:
import time
class MonotonicClock:
"""Single monotonic reference for latency/timeout math (1879 §2).
Wall-clock (`now_wall`) is only for human-facing timestamps; all elapsed/
timeout math must use `elapsed()` so NTP re-sync can't produce negatives.
"""
def __init__(self):
self._start = time.monotonic()
def elapsed(self) -> float:
return time.monotonic() - self._start
@staticmethod
def now_wall() -> float:
return time.time()
- Step 4: Run — expect PASS.
- Step 5: Commit —
git commit -am "feat(gateway): monotonic clock helper (1879)"
Note (calibration): NTP sync + wall-clock quality downgrade (
Good_Local_Override 0xD8) are flag-driven and belong to the WriteEngine (Task 11). Leave a# ponytail:marker here noting NTP option is deferred until a real clock-drift case appears.
Task 5: Security builder (1879 §1, §3)
Files:
-
Create:
ingestor/connection/security.py -
Test:
tests/unit/connection/test_security.py -
Step 1: Failing test — assert channel timeout is 1.25× session and policy is Sha256.
# tests/unit/connection/test_security.py
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.connection.security import apply_security, SESSION_TIMEOUT_MS
@pytest.mark.asyncio
async def test_apply_security_sets_sha256_and_channel_lifetime():
client = MagicMock()
client.set_security = AsyncMock()
await apply_security(client, 'urn:x', 'c.pem', 'k.pem', None)
assert client.session_timeout == SESSION_TIMEOUT_MS
assert client.secure_channel_timeout == int(SESSION_TIMEOUT_MS * 1.25)
args, kwargs = client.set_security.call_args
from asyncua.crypto.security_policies import SecurityPolicyBasic256Sha256
assert args[0] is SecurityPolicyBasic256Sha256
-
Step 2: Run — expect FAIL.
-
Step 3: Implement
ingestor/connection/security.py:
from asyncua.crypto.security_policies import SecurityPolicyBasic256Sha256
SESSION_TIMEOUT_MS = 600_000 # 10 min; tune per server (Samarco: long sessions degrade)
async def apply_security(client, server_uri, cert_path, private_key_path, server_cert_path):
"""Apply Basic256Sha256 + certificate auth (1879 §1) and set channel > session
lifetime (1879 §3: secure_channel_timeout = session_timeout * 1.25)."""
client.application_uri = server_uri
await client.set_security(
SecurityPolicyBasic256Sha256,
certificate=cert_path,
private_key=private_key_path,
server_certificate=server_cert_path,
)
client.session_timeout = SESSION_TIMEOUT_MS
client.secure_channel_timeout = int(SESSION_TIMEOUT_MS * 1.25)
- Step 4: Run — expect PASS.
- Step 5: Commit —
git commit -am "feat(gateway): Basic256Sha256 + channel>session lifetime (1879)"
Task 6: Certificate provider (MinIO + Mongo)
Files:
-
Create:
ingestor/connection/certs.py -
Test:
tests/unit/connection/test_certs.py -
Step 1: Failing test — reads
platform_certificatesdoc, downloads both objects, returns local paths.
# tests/unit/connection/test_certs.py
from unittest.mock import AsyncMock
import pytest
from ingestor.connection.certs import CertProvider
@pytest.mark.asyncio
async def test_fetch_returns_local_paths(tmp_path):
mongo = AsyncMock()
mongo.find_one.return_value = {
'client_cert': {'minio_object_name': 'platform/certificates/client_cert_c.pem'},
'private_key': {'minio_object_name': 'platform/certificates/private_key_k.pem'},
}
minio = AsyncMock()
provider = CertProvider(mongo, minio, cache_dir=str(tmp_path), bucket='sientia-dataops')
cert, key = await provider.fetch()
assert cert.endswith('c.pem') and key.endswith('k.pem')
assert minio.download_file.await_count == 2
-
Step 2: Run — expect FAIL.
-
Step 3: Implement
ingestor/connection/certs.py— read the singleplatform_certificatesdoc viamongo.find_one({}, 'platform_certificates'),await minio.download_file(bucket, object_name, local_path)intocache_dir(must be a writableemptyDir/tmpfs — root FS is read-only in the pod, spec §12). Return(cert_path, key_path). Keep it < 20 lines. -
Step 4: Run — expect PASS.
-
Step 5: Commit —
git commit -am "feat(gateway): cert provider from MinIO+Mongo (sientia-dataops)"
Confirm the exact
MongoDBRepository.find_one/MinioRepository.download_filesignatures against the pinnedsientia_do; adapt the calls if they differ.
Task 7: OpcConnectionPool
Files:
-
Create:
ingestor/connection/pool.py -
Test:
tests/unit/connection/test_pool.py -
Step 1: Failing tests — (a) same
server_idreturns the same client (reuse); (b) a connect failure incrementsOPC_CONNECTIONS_FAILEDand re-raises.
# tests/unit/connection/test_pool.py
from unittest.mock import AsyncMock, patch
import pytest
from ingestor.connection.pool import OpcConnectionPool
@pytest.mark.asyncio
async def test_get_reuses_connection_per_server(monkeypatch):
pool = OpcConnectionPool(cert_provider=AsyncMock(), metadata={}, logger=AsyncMock())
fake_client = AsyncMock()
with patch('ingestor.connection.pool.Client', return_value=fake_client):
c1 = await pool.get('srv', {'url': 'opc.tcp://h:4840', 'use_security': False})
c2 = await pool.get('srv', {'url': 'opc.tcp://h:4840', 'use_security': False})
assert c1 is c2
assert fake_client.connect.await_count == 1
-
Step 2: Run — expect FAIL.
-
Step 3: Implement
ingestor/connection/pool.py—OpcConnectionPoolkeyed byserver_id:async get(server_id, server_cfg): if a live client exists, return it; else createClient(url, timeout=10), ifuse_securitycallapply_security(...)with certs fromcert_provider.fetch(),await client.connect(), store, emitOPC_CONNECTIONS_TOTAL/OPC_CONNECTION_STATUS. On failure emitOPC_CONNECTIONS_FAILED, drop entry, re-raise.async release_idle(max_idle_s): disconnect clients unused past TTL (track last-use viaMonotonicClock), emitOPC_DISCONNECTIONS_TOTAL.async close_all().- Reuse the existing
OpcManager.disconnection_fallbackpattern for robust disconnect.
-
Step 4: Run — expect PASS.
-
Step 5: Commit —
git commit -am "feat(gateway): OpcConnectionPool (per-server, reuse, idle-evict)"
Task 7.end: F0 close-out (see Phase Delivery)
- Full
pytest testsgreen +ruffclean (162 passed). - F0 commits recorded in the Commit Ledger.
- PR intentionally NOT opened yet — opened only after F4 closes.
Phase F1 — Subscription reads with multi-name fan-out (contract C1)
Task 8: Slot v2 loader
Files:
-
Create:
ingestor/io/slots.py,ingestor/io/__init__.py -
Test:
tests/unit/io/test_slots.py -
Step 1: Failing test — parse the C1 structure incl.
names[]andactive_read.
# tests/unit/io/test_slots.py
from ingestor.io.slots import parse_slot
RAW = {'server1': {'server_id': '1', 'url': 'opc.tcp://h:4840', 'use_security': True,
'subscription_period_ms': 1000, 'tags': {'ns=2;i=2': {'frequency': '15000',
'active_read': {'enabled': True, 'cycle_multiple': 3},
'names': [{'collection': 'raw_x', 'tag_name': 'Counter', 'model_name': 'm1', 'model_id': '1'},
{'collection': 'raw_y', 'tag_name': 'Contador', 'model_name': 'm2', 'model_id': '2'}]}}}}
def test_parse_slot_multi_name():
slot = parse_slot(RAW)
tag = slot['server1']['tags']['ns=2;i=2']
assert len(tag['names']) == 2
assert tag['active_read']['cycle_multiple'] == 3
- Step 2–4: implement
parse_slot(raw)(validate shape, defaultactive_read={'enabled': False}, ensurenamesis a list); run to green. - Step 5: Commit —
git commit -am "feat(gateway): slot v2 loader (contract C1)"
Task 9: DataSink.publish_reading (MongoDB fan-out)
Files:
-
Create:
ingestor/io/data_sink.py -
Test:
tests/unit/io/test_data_sink_publish.py -
Step 1: Failing test — one reading with N names publishes N documents (one per collection), each carrying
tag_name/model_id.
# tests/unit/io/test_data_sink_publish.py
from unittest.mock import AsyncMock
import pytest
from ingestor.io.data_sink import DataSink
@pytest.mark.asyncio
async def test_publish_reading_fans_out_per_name():
dm = AsyncMock()
sink = DataSink(data_manager=dm, postgres=AsyncMock(), logger=AsyncMock())
names = [{'collection': 'raw_x', 'tag_name': 'Counter', 'model_id': '1', 'model_name': 'm1'},
{'collection': 'raw_y', 'tag_name': 'Contador', 'model_id': '2', 'model_name': 'm2'}]
await sink.publish_reading(tag='ns=2;i=2', value=42, timestamp='2026-07-13T00:00:00Z', names=names)
assert dm.publish.await_count == 2
- Step 2–4: implement
DataSink.publish_reading(tag, value, timestamp, names)— loopnames, build{tag, name: tag_name, model_id, timestamp, value}, callself.data_manager.publish(collection, doc)(reuses existing MongoDB egress). EmitOPC_TAG_VALUE. Run to green. - Step 5: Commit —
git commit -am "feat(gateway): DataSink.publish_reading multi-name fan-out"
Task 9b: Remove the legacy Kafka egress
Simplification requested by the feature owner (2026-07-14): the Gateway persists reads to MongoDB only.
Files:
-
Modify:
ingestor/managers/data_manager.py(remove theKafkaProducer, the Kafka branch ofpublish, andshutdownof the producer) -
Modify:
ingestor/metrics.py(removeKAFKA_MESSAGES_SENT,KAFKA_MESSAGES_ERRORS,KAFKA_CONNECTION_STATUS) -
Modify:
.env.example(removeEXPORT_TO_KAFKA,KAFKA_SERVERS) -
Modify: affected tests (
tests/unit/managers/test_data_manager.py, any metrics test referencingkafka_*) -
Step 1: grep for
kafkaacrossingestor/andtests/to enumerate every reference. -
Step 2: remove the Kafka producer/branch/config/metrics; keep the MongoDB path intact.
-
Step 3: update/trim the affected tests so they no longer assert Kafka behavior.
-
Step 4: run the full suite (
python -m pytest tests -v) andruff check— all green. -
Step 5: Commit —
git commit -am "refactor(gateway): remove legacy Kafka egress (MongoDB-only)"
Task 10: SubscriptionEngine (fan-out on datachange)
Files:
-
Create:
ingestor/engines/subscription.py,ingestor/engines/__init__.py -
Test:
tests/unit/engines/test_subscription.py -
Step 1: Failing test — a datachange for an address with 2 names calls
DataSink.publish_readingonce with both names; resets cycle/non-receive counters; recordsOPC_READ_LAG.
# tests/unit/engines/test_subscription.py
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.engines.subscription import SubscriptionEngine
@pytest.mark.asyncio
async def test_datachange_fans_out():
sink = AsyncMock()
eng = SubscriptionEngine(pool=AsyncMock(), sink=sink, logger=AsyncMock(), metadata={})
eng.nodes['ns=2;i=2'] = {'names': [{'tag_name': 'a'}, {'tag_name': 'b'}],
'cycle_rule': {'cycle_increment': 1, 'cycle_count': 3}}
node = MagicMock(); node.__str__ = lambda self: 'ns=2;i=2'
data = MagicMock()
data.monitored_item.Value.Value.Value = 42
await eng.datachange_notification(node, 42, data)
assert sink.publish_reading.await_count == 1
_, kwargs = sink.publish_reading.call_args
assert len(kwargs['names']) == 2
-
Step 2: Run — expect FAIL.
-
Step 3: Implement
SubscriptionEngineby adaptingingestor/managers/opc_manager.py:- Subscriptions created via
pool.get(...)+client.create_subscription(period, self)(engine is the callback handler). datachange_notificationextracts value +SourceTimestamp, computes lag (OPC_READ_LAG= wall_now − source_ts) and callsawait self.sink.publish_reading(tag, value, ts, names=self.nodes[tag]['names'])(fan-out — replaces the old per-topicsloop).- Keep
check_cycles/check_opc_listenningbehavior. - Slot lease/heartbeat: reuse
ResourceManager(unchanged); the engine consumesparse_slot(...)output.
- Subscriptions created via
-
Step 4: Run — expect PASS.
-
Step 5: Commit —
git commit -am "feat(gateway): SubscriptionEngine multi-name fan-out (C1)"
Task 10.end: F1 close-out (see Phase Delivery)
- Full
pytest testsgreen (168) +ruffclean. - F1 commit SHAs recorded in the Commit Ledger (base
00f919c). - PR intentionally NOT opened yet — opened only after F4 closes.
Phase F2 — Active write via NATS (contracts C2, C3)
Task 11: WriteEngine
Files:
-
Create:
ingestor/engines/write.py -
Test:
tests/unit/engines/test_write.py -
Step 1: Failing tests — (a) writes a
DataValuewithStatusCodeGood +SourceTimestampwhen flags on; (b) a converter failure returns an error result (not an exception) and incrementsOPC_ERROR_BY_TYPE; (c) rate-limit enforces a min pause between writes.
# tests/unit/engines/test_write.py
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.engines.write import WriteEngine, WriteResult
@pytest.mark.asyncio
async def test_write_ok_sets_status_and_timestamp():
node = AsyncMock()
client = MagicMock(); client.get_node.return_value = node
pool = AsyncMock(); pool.get.return_value = client
eng = WriteEngine(pool=pool, sink=AsyncMock(), logger=AsyncMock(), metadata={},
write_timestamp=True, write_quality=True, min_pause_s=0)
res = await eng.write({'server_id': 's', 'addr': 'ns=2;i=5', 'value': '42.5',
'data_type': 'float', 'model_id': '1', 'timestamp': '2026-07-13T00:00:00Z',
'schema': 'sientia_data', 'table_name': 't'})
assert isinstance(res, WriteResult) and res.ok
node.write_value.assert_awaited() # DataValue passed
@pytest.mark.asyncio
async def test_write_bad_type_returns_error_not_raise():
client = MagicMock(); client.get_node.return_value = AsyncMock()
pool = AsyncMock(); pool.get.return_value = client
eng = WriteEngine(pool=pool, sink=AsyncMock(), logger=AsyncMock(), metadata={}, min_pause_s=0)
res = await eng.write({'server_id': 's', 'addr': 'ns=2;i=5', 'value': 'abc',
'data_type': 'int', 'model_id': '1', 'timestamp': 't', 'schema': 'x', 'table_name': 't'})
assert not res.ok and res.status == 'error'
-
Step 2: Run — expect FAIL.
-
Step 3: Implement
ingestor/engines/write.py:WriteResultdataclass:ok: bool,status: str(ok|error|session_bad),opc_status_code: str,message: str.- Per-message flags (C2):
write_timestamp/write_qualityarrive on each NATS message; resolve ascmd.get('write_timestamp', self.write_timestamp)(per-message wins; config is the fallback default). Do not hard-code engine-global-only behavior. async write(cmd): rate-limit (min pause viaMonotonicClock);value = to_typed_value(cmd['value'], cmd['data_type'])inside try/except → onConversionErrorreturn errorWriteResult+OPC_ERROR_BY_TYPE; buildua.DataValue(ua.Variant(value, ua_type), StatusCode_=ua.StatusCode(ua.StatusCodes.Good) if write_quality else None, SourceTimestamp=parse(ts) if write_timestamp else None);node = client.get_node(addr); await node.write_value(dv); map OPC exceptions →error_typeforOPC_ERROR_BY_TYPE; emitOPC_WRITE_TOTAL/OPC_WRITE_LATENCY/OPC_WRITE_STATUS_BY_MODEL; thenawait self.sink.update_write_status(cmd, res).- Quality downgrade
Good_Local_Override (0xD8)when a clock problem is detected — leave a# ponytail:marker; wire only when NTP/clock detection is added.
-
Step 4: Run — expect PASS.
-
Step 5: Commit —
git commit -am "feat(gateway): WriteEngine (status_code/timestamp/flags, safe converter, rate-limit)"
Task 12: DataSink.update_write_status (Postgres, contract C3)
Files:
-
Modify:
ingestor/io/data_sink.py -
Test:
tests/unit/io/test_data_sink_status.py -
Step 1: Failing test — issues an
UPDATE <schema>.<table> SET write_status=... WHERE timestamp=... AND model_id=....
# tests/unit/io/test_data_sink_status.py
from unittest.mock import AsyncMock
import pytest
from ingestor.io.data_sink import DataSink
from ingestor.engines.write import WriteResult
@pytest.mark.asyncio
async def test_update_write_status_targets_model_row():
pg = AsyncMock()
sink = DataSink(data_manager=AsyncMock(), postgres=pg, logger=AsyncMock())
cmd = {'schema': 'sientia_data', 'table_name': 'pred_m1',
'model_id': '1', 'timestamp': '2026-07-13T00:00:00Z'}
await sink.update_write_status(cmd, WriteResult(ok=True, status='ok', opc_status_code='Good', message=''))
assert pg.execute.await_count == 1
sql = pg.execute.call_args[0][0]
assert 'update' in sql.lower() and 'write_status' in sql.lower()
- Step 2–4: implement
update_write_status(cmd, result)— parametrized UPDATE oncmd['schema'].cmd['table_name']filtered bytimestamp+model_id; use thesientia_doPostgres client (self.postgres). Never f-string the values (SQL injection) — bind params; the identifiers (schema/table) are validated against[A-Za-z0-9_]+. Run to green. - Step 5: Commit —
git commit -am "feat(gateway): DataSink.update_write_status Postgres (C3)"
Cross-repo note (1975): the column only appears on new per-model tables once Laborious's predictions DataFrame includes
write_status; existing tables need the Flyway backfillALTER TABLE ... ADD COLUMN IF NOT EXISTS write_status. Both are 1975/migrations work — this task only writes the value.
Task 13: NatsWriteConsumer (contract C2)
Files:
-
Create:
ingestor/adapters/nats_consumer.py,ingestor/adapters/__init__.py -
Test:
tests/unit/adapters/test_nats_consumer.py -
Step 1: Failing test — an incoming NATS message is JSON-decoded and dispatched to
WriteEngine.write; malformed JSON is logged and dropped (no raise).
# tests/unit/adapters/test_nats_consumer.py
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.adapters.nats_consumer import NatsWriteConsumer
@pytest.mark.asyncio
async def test_message_dispatched_to_write_engine():
we = AsyncMock()
consumer = NatsWriteConsumer(nats_url='nats://x:4222', write_engine=we, logger=AsyncMock())
msg = MagicMock(); msg.data = json.dumps({'server_id': 's', 'addr': 'ns=2;i=5'}).encode()
await consumer._handle(msg)
we.write.assert_awaited_once()
@pytest.mark.asyncio
async def test_bad_json_does_not_raise():
consumer = NatsWriteConsumer(nats_url='x', write_engine=AsyncMock(), logger=AsyncMock())
msg = MagicMock(); msg.data = b'not-json'
await consumer._handle(msg) # must not raise
-
Step 2: Run — expect FAIL.
-
Step 3: Implement
NatsWriteConsumer:async start():self.nc = await nats.connect(self.nats_url);await self.nc.subscribe('opc.write', queue='gateway-writers', cb=self._handle)(queue group → exactly one instance handles each message).async _handle(msg):json.loadsin try/except (drop + log on error);await self.write_engine.write(payload)(fire-and-forget — no reply).async stop(): drain/close.
-
Step 4: Run — expect PASS.
-
Step 5: Commit —
git commit -am "feat(gateway): NatsWriteConsumer (Core NATS, queue group) (C2)"
Task 13.end: F2 close-out (see Phase Delivery)
- Full
pytest testsgreen +ruffclean. - Record F2 commit SHAs (base + range) in the Commit Ledger.
- Do NOT open the PR yet.
Phase F3 — Active reads: scheduled + on-demand gRPC (contract C4)
Task 14: ActiveReadEngine
Files:
-
Create:
ingestor/engines/active_read.py -
Test:
tests/unit/engines/test_active_read.py -
Step 1: Failing test —
read_now(server_id, addr)reads via pool, persists viaDataSink.publish_reading, and returns value+status+timestamps.
# tests/unit/engines/test_active_read.py
from unittest.mock import AsyncMock, MagicMock
import pytest
from ingestor.engines.active_read import ActiveReadEngine
@pytest.mark.asyncio
async def test_read_now_persists_and_returns():
dv = MagicMock(); dv.Value.Value = 42
node = AsyncMock(); node.read_data_value.return_value = dv
client = MagicMock(); client.get_node.return_value = node
pool = AsyncMock(); pool.get.return_value = client
sink = AsyncMock()
eng = ActiveReadEngine(pool=pool, sink=sink, logger=AsyncMock(), metadata={})
res = await eng.read_now('s', 'ns=2;i=2', names=[{'collection': 'c', 'tag_name': 't', 'model_id': '1'}])
assert res['value'] == 42 and res['persisted'] is True
sink.publish_reading.assert_awaited_once()
- Step 2: Run — expect FAIL.
- Step 3: Implement —
read_now(...)(read vianode.read_data_value(), emitOPC_ACTIVE_READ_*, persist, return dict) andscheduled_tick(slot)(iterate tags withactive_read.enabled, read everycycle_multipleticks, persist). Run to green. - Step 5: Commit —
git commit -am "feat(gateway): ActiveReadEngine (scheduled + read_now)"
Task 15: gRPC proto + codegen
Files:
-
Create:
ingestor/proto/opc_gateway.proto -
Generate:
ingestor/proto/opc_gateway_pb2.py,_pb2_grpc.py -
Step 1: Write proto (contract C4):
syntax = "proto3";
package opcgateway;
service OpcGateway {
rpc ReadTag(ReadTagRequest) returns (ReadTagResponse);
}
message ReadTagRequest { string server_id = 1; string addr = 2; string data_type = 3; }
message ReadTagResponse {
string value = 1; string opc_status_code = 2;
string source_timestamp = 3; string server_timestamp = 4; bool persisted = 5;
}
- Step 2: Generate stubs
Run: python -m grpc_tools.protoc -I ingestor/proto --python_out=ingestor/proto --grpc_python_out=ingestor/proto ingestor/proto/opc_gateway.proto
Expected: opc_gateway_pb2.py and opc_gateway_pb2_grpc.py created. Fix the generated import to be package-relative if needed.
- Step 3: Commit —
git commit -am "feat(gateway): gRPC proto + generated stubs (C4)"
Task 16: GrpcServer
Files:
-
Create:
ingestor/adapters/grpc_server.py -
Test:
tests/unit/adapters/test_grpc_server.py -
Step 1: Failing test —
ReadTagdelegates toActiveReadEngine.read_nowand maps the dict to the response message.
# tests/unit/adapters/test_grpc_server.py
from unittest.mock import AsyncMock
import pytest
from ingestor.adapters.grpc_server import ReadTagServicer
from ingestor.proto import opc_gateway_pb2 as pb
@pytest.mark.asyncio
async def test_readtag_maps_response():
eng = AsyncMock()
eng.read_now.return_value = {'value': '42', 'opc_status_code': 'Good',
'source_timestamp': 'ts', 'server_timestamp': 'ts2', 'persisted': True}
servicer = ReadTagServicer(active_read=eng, slot_lookup=AsyncMock())
resp = await servicer.ReadTag(pb.ReadTagRequest(server_id='s', addr='ns=2;i=2'), None)
assert resp.value == '42' and resp.persisted is True
- Step 2–4: implement
ReadTagServicer(async grpc.aio) +GrpcServer.start()/stop()bindinggrpc.aio.server()on the configured port; look upnamesfor the address from the slot layer (or accept none). Run to green. - Step 5: Commit —
git commit -am "feat(gateway): gRPC server ReadTag (C4)"
Task 16.end: F3 close-out (see Phase Delivery)
- Full
pytest testsgreen +ruffclean. - Record F3 commit SHAs (base + range) in the Commit Ledger.
- Do NOT open the PR yet.
Phase F4 — Wiring, health, deploy, hardening
Task 17: Config additions
Files:
-
Modify:
ingestor/config.py(or wherever env is read),.env.example -
Test:
tests/unit/test_config.py -
Step 1–4 (TDD): add env:
NATS_URL,NATS_WRITE_SUBJECT(defaultopc.write),GRPC_PORT(default50051),GRPC_ENABLED,CERT_BUCKET(defaultsientia-dataops),CERT_CACHE_DIR(default/tmp/opc-certs),WRITE_TIMESTAMP/WRITE_QUALITY/WRITE_MIN_PAUSE_S,NTP_ENABLED. Test defaults. Run to green. -
Step 5: Commit —
git commit -am "feat(gateway): config for NATS/gRPC/certs/write flags"
Task 18: Wire engines + adapters into app
Files:
-
Modify:
ingestor/app.py,ingestor/ingestor.py -
Test:
tests/unit/test_app.py(extend) -
Step 1: Failing test — app startup constructs pool + engines + adapters and sets
APP_UP=1; shutdown stops adapters, closes pool, setsAPP_UP=0. -
Step 2: Run — expect FAIL.
-
Step 3: Implement — in startup: build
OpcConnectionPool,DataSink,SubscriptionEngine,ActiveReadEngine,WriteEngine, startNatsWriteConsumerand (ifGRPC_ENABLED)GrpcServer; register them in the main loop and signal handlers. Reuse the existingstart_http_server(HTTP_METRICS_PORT)for/metrics. -
Step 4: Run — expect PASS.
-
Step 5: Commit —
git commit -am "feat(gateway): wire pool+engines+adapters into app"
Task 19: Health probe alignment (spec §9/§10)
Files:
-
Modify:
ingestor/app.py(ensureAPP_UPis set on the:9090/metricsendpoint), deploy values (values/opc-gateway.yamlif present in this repo; otherwise note for the Helm repo). -
Step 1: confirm
app_up{pod_id=...} 1is exposed on:9090/metrics(already emitted; verify the probe stringcurl -sf http://localhost:9090/metrics | grep -q '^app_up{.*} 1'matches). -
Step 2: if a
values/opc-gateway.yamllives here, set the exec probe +waitForDependencies(Gitea, MongoDB, Redis, Postgres, MinIO, NATS) + ServiceMonitor labelrelease: kube-prometheus-stack. Otherwise add adocs/deploy-notes.mdcapturing these for the Helm repo. -
Step 3: Commit —
git commit -am "chore(gateway): app_up health probe + deploy notes"
Task 20: Dockerfile / entrypoint
Files:
-
Modify:
Dockerfile(or note forsientia-container-images) -
Step 1: ensure the image runs
ingestor.app, non-root,readOnlyRootFilesystem-compatible (writable/tmpforCERT_CACHE_DIR); pin base image; nolatest. Hadolint clean. -
Step 2: Run
hadolint Dockerfile(or note). -
Step 3: Commit —
git commit -am "chore(gateway): dockerfile entrypoint + read-only-fs cert cache"
Task 21: Full test + lint sweep and 1879 checklist verification
Files: all
- Step 1:
python -m pytest tests -v→ all green. - Step 2:
ruff check ingestor tests→ clean. - Step 3: Walk spec §13 (1879) and check each box in code: Sha256, monotonic, status_code+timestamp, timestamp/quality flags, rate-limit, BUG-1/BUG-2, channel>session, OPC error typing, file/function size. Add any missing micro-test.
- Step 4: Commit —
git commit -am "test(gateway): full sweep + 1879 checklist verification"
Task 21.end: F4 close-out + open per-phase PRs (see Phase Delivery)
- Full
pytest testsgreen +ruffclean; all F0–F4 phases complete, tested, working. - Record F4 commit SHAs (base + range) in the Commit Ledger.
- Now open one PR per phase (F0→F4) from the recorded SHA ranges, in order — this is the only point where PRs are opened.
Deferred / out of this plan (tracked in spec §17)
- NATS provisioning in air-gapped Helm (platform).
- Moving 1569 cert write path to
sientia-dataops(platform + 1569 app). - gRPC caller identity / auth model (Vitor) → may add auth to
GrpcServerlater. - Flyway
write_statuscolumn migration + Laborious DataFrame change (1975 plan). - JetStream durability (revisit if Core NATS loss becomes real).
Open validation notes for the implementer
- Verify exact
sientia_dosignatures (MongoDBRepository.find_one,MinioRepository.download_file, Postgresexecute) against the pinned version; adapt calls. asyncuaua.DataValue/ua.Variant/StatusCodeconstruction: confirm the constructor kwargs on the pinnedasyncua(1.1.5) before Task 11.- The functional test against
sientia-dataops-opc_simulator(sibling repo) is a good end-to-end smoke once F0–F3 land.