Skip to content

Commit c83d26a

Browse files
authored
Merge pull request #153 from ChannelFinder/fix-recceiver-issues
Fix recCeiver issues
2 parents 6f69ced + 4db0d9d commit c83d26a

4 files changed

Lines changed: 170 additions & 32 deletions

File tree

server/recceiver/cfstore.py

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import socket
77
import time
88
from collections import defaultdict
9-
from dataclasses import dataclass, field
9+
from dataclasses import dataclass, field, fields
1010
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
1111

1212
from channelfinder import ChannelFinderClient
@@ -89,6 +89,15 @@ def loads(cls, conf: ConfigAdapter) -> "CFConfig":
8989
push_always_retry=conf.getboolean("pushAlwaysRetry", True),
9090
)
9191

92+
def __repr__(self) -> str:
93+
parts = []
94+
for f in fields(self):
95+
value = getattr(self, f.name)
96+
if f.name == "cf_password":
97+
value = "***" if value else None
98+
parts.append(f"{f.name}={value!r}")
99+
return f"CFConfig({', '.join(parts)})"
100+
92101

93102
@dataclass
94103
class CFProperty:
@@ -555,26 +564,41 @@ def _commit_with_thread(self, transaction: CommitTransaction):
555564
Args:
556565
transaction: The transaction to commit.
557566
"""
567+
host = transaction.source_address.host
568+
port = transaction.source_address.port
569+
558570
if not self.running:
559-
host = transaction.source_address.host
560-
port = transaction.source_address.port
561571
raise defer.CancelledError(f"CF Processor is not running (transaction: {host}:{port})")
562572

563573
_log.info("CF_COMMIT: %s", transaction)
564574
_log.debug("CF_COMMIT: transaction: %s", repr(transaction))
565575

576+
ioc_name = transaction.client_infos.get("IOCNAME")
577+
if not ioc_name:
578+
ioc_name = str(port)
579+
_log.debug("IOC at %s:%d did not send IOCNAME; using port as iocName", host, port)
580+
581+
owner = (
582+
transaction.client_infos.get(self.cf_config.env_owner_variable)
583+
or transaction.client_infos.get("CF_USERNAME")
584+
or self.cf_config.username
585+
)
586+
if owner == self.cf_config.username:
587+
_log.debug(
588+
"IOC at %s:%d did not send %s or CF_USERNAME; using service account as owner",
589+
host,
590+
port,
591+
self.cf_config.env_owner_variable,
592+
)
593+
566594
ioc_info = IocInfo(
567-
host=transaction.source_address.host,
568-
hostname=transaction.client_infos.get("HOSTNAME") or transaction.source_address.host,
569-
ioc_name=transaction.client_infos.get("IOCNAME") or str(transaction.source_address.port),
570-
ioc_IP=transaction.source_address.host,
571-
owner=(
572-
transaction.client_infos.get(self.cf_config.env_owner_variable)
573-
or transaction.client_infos.get("CF_USERNAME")
574-
or self.cf_config.username
575-
),
595+
host=host,
596+
hostname=transaction.client_infos.get("HOSTNAME") or host,
597+
ioc_name=ioc_name,
598+
ioc_IP=host,
599+
owner=owner,
576600
time=self.current_time(self.cf_config.timezone),
577-
port=transaction.source_address.port,
601+
port=port,
578602
)
579603

580604
record_infos = self.transaction_to_record_infos(ioc_info, transaction)
@@ -596,13 +620,16 @@ def remove_channel(self, recordName: str, iocid: str) -> None:
596620
iocid: The IOC ID of the record to remove from.
597621
"""
598622
self.channel_ioc_ids[recordName].remove(iocid)
599-
if iocid in self.iocs:
600-
self.iocs[iocid].channelcount -= 1
601-
if self.iocs[iocid].channelcount == 0:
623+
if iocid not in self.iocs:
624+
if len(self.channel_ioc_ids[recordName]) == 0:
625+
del self.channel_ioc_ids[recordName]
626+
return
627+
self.iocs[iocid].channelcount -= 1
628+
if self.iocs[iocid].channelcount <= 0:
629+
if self.iocs[iocid].channelcount < 0:
630+
_log.error("Channel count negative: %s", iocid)
602631
self.iocs.pop(iocid)
603-
elif self.iocs[iocid].channelcount < 0:
604-
_log.error("Channel count negative: %s", iocid)
605-
if len(self.channel_ioc_ids[recordName]) <= 0: # case: channel has no more iocs
632+
if len(self.channel_ioc_ids[recordName]) == 0:
606633
del self.channel_ioc_ids[recordName]
607634

608635
def clean_service(self) -> None:
@@ -1095,9 +1122,9 @@ def _update_channelfinder(
10951122

10961123
if iocid not in iocs:
10971124
_log.warning(
1098-
"IOC %s did not send an initial transaction to join IOC list: %s",
1125+
"IOC %s did not send an initial transaction to join IOC list (%d IOCs known)",
10991126
ioc_info,
1100-
[(ioc.ioc_name, ioc.ioc_id) for ioc in iocs.values()],
1127+
len(iocs),
11011128
)
11021129

11031130
if ioc_info.hostname is None or ioc_info.ioc_name is None:

server/recceiver/recast.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def __repr__(self):
255255

256256
class CollectionSession(object):
257257
timeout = 5.0
258-
trlimit = 0
258+
trlimit = 5000
259259

260260
def __init__(self, proto, endpoint):
261261
from twisted.internet import reactor
@@ -272,15 +272,11 @@ def __init__(self, proto, endpoint):
272272
def close(self):
273273
_log.info("Close session from {ep}".format(ep=self.ep))
274274

275-
def suppressCancelled(err):
276-
if not err.check(defer.CancelledError):
277-
return err
278-
_log.debug("Suppress the expected CancelledError")
279-
280-
self.C.addErrback(suppressCancelled).cancel()
281-
282-
# Clear the current transaction and
283-
# commit an empty one for disconnect.
275+
# Do not cancel self.C here. Any data commit that is still queued
276+
# behind the global lock must be allowed to complete so that channels
277+
# are registered as active in CF before the disconnect is processed.
278+
# The disconnect transaction is chained after self.C and will execute
279+
# once all preceding commits have finished.
284280
self.transaction = Transaction(self.ep, id(self))
285281
self.transaction.connected = False
286282
self.dirty = True
@@ -315,10 +311,12 @@ def abort(err):
315311
# between transactions. Only flush after Add or Del or Done message received.
316312
def flushSafely(self):
317313
if self.T and self.T <= time.time():
314+
_log.debug("flushSafely: timeout elapsed for %s", self.ep)
318315
self.flush()
319316
elif self.trlimit and self.trlimit <= (
320317
len(self.transaction.records_to_add) + len(self.transaction.records_to_delete)
321318
):
319+
_log.debug("flushSafely: trlimit %d reached for %s", self.trlimit, self.ep)
322320
self.flush()
323321

324322
def markDirty(self):

server/tests/test_cfstore.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from configparser import ConfigParser
22

3-
from recceiver.cfstore import CFConfig
3+
from recceiver.cfstore import CFConfig, CFProcessor, IocInfo
44
from recceiver.processors import ConfigAdapter
55

66

@@ -50,3 +50,60 @@ def test_alias_enabled_from_config(self):
5050
adapter = make_adapter(values={"alias": "true"})
5151
config = CFConfig.loads(adapter)
5252
assert config.alias_enabled is True
53+
54+
55+
def make_processor() -> CFProcessor:
56+
return CFProcessor("test", make_adapter())
57+
58+
59+
def make_ioc(channelcount: int = 1) -> IocInfo:
60+
return IocInfo(
61+
host="1.2.3.4",
62+
hostname="ioc1.example.com",
63+
ioc_name="IOC1",
64+
ioc_IP="1.2.3.4",
65+
owner="engineer",
66+
time="2026-01-01T00:00:00",
67+
port=5064,
68+
channelcount=channelcount,
69+
)
70+
71+
72+
class TestRemoveChannel:
73+
def test_missing_iocid_does_not_raise(self):
74+
proc = make_processor()
75+
iocid = "1.2.3.4:5064"
76+
proc.channel_ioc_ids["CHAN:1"].append(iocid)
77+
# iocid deliberately absent from proc.iocs
78+
proc.remove_channel("CHAN:1", iocid)
79+
assert "CHAN:1" not in proc.channel_ioc_ids
80+
81+
def test_missing_iocid_preserves_channel_when_other_iocs_remain(self):
82+
proc = make_processor()
83+
iocid = "1.2.3.4:5064"
84+
proc.channel_ioc_ids["CHAN:1"].append(iocid)
85+
proc.channel_ioc_ids["CHAN:1"].append("9.9.9.9:5064")
86+
proc.remove_channel("CHAN:1", iocid)
87+
assert "CHAN:1" in proc.channel_ioc_ids
88+
assert "9.9.9.9:5064" in proc.channel_ioc_ids["CHAN:1"]
89+
90+
def test_removes_ioc_when_channelcount_reaches_zero(self):
91+
proc = make_processor()
92+
ioc = make_ioc(channelcount=1)
93+
iocid = ioc.ioc_id
94+
proc.iocs[iocid] = ioc
95+
proc.channel_ioc_ids["CHAN:1"].append(iocid)
96+
proc.remove_channel("CHAN:1", iocid)
97+
assert iocid not in proc.iocs
98+
assert "CHAN:1" not in proc.channel_ioc_ids
99+
100+
def test_keeps_ioc_when_channelcount_still_positive(self):
101+
proc = make_processor()
102+
ioc = make_ioc(channelcount=2)
103+
iocid = ioc.ioc_id
104+
proc.iocs[iocid] = ioc
105+
proc.channel_ioc_ids["CHAN:1"].append(iocid)
106+
proc.channel_ioc_ids["CHAN:2"].append(iocid)
107+
proc.remove_channel("CHAN:1", iocid)
108+
assert iocid in proc.iocs
109+
assert proc.iocs[iocid].channelcount == 1

server/tests/test_recast.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from unittest.mock import MagicMock
2+
3+
from twisted.internet import defer
4+
5+
from recceiver.recast import CollectionSession
6+
7+
8+
def make_session() -> CollectionSession:
9+
proto = MagicMock()
10+
proto.transport = MagicMock()
11+
session = CollectionSession(proto, "test:1234")
12+
session.factory = MagicMock()
13+
return session
14+
15+
16+
class TestFlushSafely:
17+
def test_trlimit_triggers_flush_when_reached(self):
18+
session = make_session()
19+
session.trlimit = 3
20+
session.flush = MagicMock()
21+
session.markDirty()
22+
for i in range(3):
23+
session.transaction.records_to_add[i] = (f"REC:{i}", "ai")
24+
session.flushSafely()
25+
session.flush.assert_called_once()
26+
27+
def test_trlimit_does_not_flush_below_limit(self):
28+
session = make_session()
29+
session.trlimit = 3
30+
session.flush = MagicMock()
31+
session.markDirty()
32+
for i in range(2):
33+
session.transaction.records_to_add[i] = (f"REC:{i}", "ai")
34+
session.flushSafely()
35+
session.flush.assert_not_called()
36+
37+
def test_trlimit_zero_never_triggers_flush(self):
38+
session = make_session()
39+
session.trlimit = 0
40+
session.flush = MagicMock()
41+
session.markDirty()
42+
for i in range(10000):
43+
session.transaction.records_to_add[i] = (f"REC:{i}", "ai")
44+
session.flushSafely()
45+
session.flush.assert_not_called()
46+
47+
48+
class TestCollectionSessionClose:
49+
def test_close_does_not_cancel_pending_commit(self):
50+
session = make_session()
51+
pending = defer.Deferred()
52+
cancelled_errors = []
53+
pending.addErrback(lambda f: cancelled_errors.append(f.type) or f)
54+
session.C = pending
55+
session.close()
56+
assert cancelled_errors == [], "close() must not cancel a queued data commit"

0 commit comments

Comments
 (0)