Skip to content

Commit cd0cc7a

Browse files
authored
Merge pull request #168 from ChannelFinder/fix/1.9.6-prod-observations
Fix/1.9.6 prod observations
2 parents d088ea2 + a4f7245 commit cd0cc7a

10 files changed

Lines changed: 566 additions & 329 deletions

File tree

server/demo.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,5 @@
133133
#pushMaxRetries = 10
134134

135135
# Whether to retry polling indefinitely until success. Default is False.
136-
# Enabling this holds the per-IOC lock until CF recovers for that IOC; other IOCs are unaffected.
136+
# Enabling this holds the global commit lock until CF recovers; all other IOC commits are blocked.
137137
#pushAlwaysRetry = False

server/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
optional-dependencies.metrics = [ "prometheus-client" ]
4040
optional-dependencies.test = [ "pytest>=8.3,<8.4", "pytest-cov>=6,<7", "testcontainers>=4.8.2,<4.9" ]
4141
urls.Repository = "https://github.com/ChannelFinder/recsync"
42+
scripts.recceiver-clean = "recceiver.clean_tool:main"
4243

4344
[tool.setuptools]
4445
packages = [ "recceiver", "recceiver.cf", "recceiver.protocol", "twisted.plugins" ]

server/recceiver/application.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,15 @@ def privilegedStartService(self):
136136
self._statusLoop.start(self.statusInterval, now=False)
137137

138138
def _logStatus(self):
139-
metrics.connections_active.set(self.tcpFactory.NActive)
139+
nactive = self.tcpFactory.NActive
140+
if nactive < 0:
141+
log.warning("NActive is %d — connection accounting is corrupted", nactive)
142+
nactive = max(0, nactive)
143+
metrics.connections_active.set(nactive)
140144
metrics.connections_waiting.set(len(self.tcpFactory.Wait))
141145
log.info(
142146
"status: connections active=%d/%d queued=%d",
143-
self.tcpFactory.NActive,
147+
nactive,
144148
self.tcpFactory.maxActive,
145149
len(self.tcpFactory.Wait),
146150
)

server/recceiver/cf/processor.py

Lines changed: 166 additions & 169 deletions
Large diffs are not rendered by default.

server/recceiver/clean_tool.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""recceiver-clean — sweep Active CF channels to Inactive for a given recceiver ID.
2+
3+
Run after a RecCeiver restart (with cleanOnStart=False) to mark channels Inactive
4+
whose IOCs have not reconnected.
5+
6+
Usage:
7+
recceiver-clean -f /path/to/recceiver.conf [--recceiver-id ID] [--dry-run]
8+
"""
9+
10+
import argparse
11+
import configparser
12+
import logging
13+
import sys
14+
15+
from requests import RequestException
16+
17+
from recceiver.cf.adapter import PyCFClientAdapter
18+
from recceiver.cf.config import CFConfig
19+
from recceiver.cf.model import CFProperty, CFPropertyName, PVStatus
20+
from recceiver.processors import ConfigAdapter
21+
22+
log = logging.getLogger(__name__)
23+
24+
25+
def run_clean(cf_config: CFConfig, client=None, dry_run: bool = False) -> int:
26+
"""Mark all Active channels for cf_config.recceiver_id Inactive.
27+
28+
Returns the total count of channels swept. Pass a pre-built client for testing.
29+
"""
30+
if client is None:
31+
from channelfinder import ChannelFinderClient
32+
33+
client = PyCFClientAdapter(
34+
ChannelFinderClient(
35+
BaseURL=cf_config.base_url,
36+
username=cf_config.cf_username,
37+
password=cf_config.cf_password,
38+
verify_ssl=cf_config.verify_ssl,
39+
),
40+
size_limit=int(cf_config.cf_query_limit),
41+
)
42+
43+
total = 0
44+
# find_active_for_recceiver is paginated (bounded by size_limit). In live mode each
45+
# update_property call marks the current page Inactive, so the next query returns a
46+
# fresh batch; the loop drains all pages. In dry-run mode nothing is deactivated so
47+
# the same batch would come back on every iteration — break after the first page.
48+
while True:
49+
channels = client.find_active_for_recceiver(cf_config.recceiver_id)
50+
if not channels:
51+
break
52+
log.info(
53+
"Found %d active channels for recceiver_id=%r%s",
54+
len(channels),
55+
cf_config.recceiver_id,
56+
" (dry-run)" if dry_run else "",
57+
)
58+
if not dry_run:
59+
client.update_property(
60+
CFProperty(CFPropertyName.PV_STATUS.value, cf_config.username, PVStatus.INACTIVE.value),
61+
[ch.name for ch in channels],
62+
)
63+
total += len(channels)
64+
if dry_run:
65+
break
66+
return total
67+
68+
69+
def main(argv=None):
70+
parser = argparse.ArgumentParser(description="Mark active CF channels Inactive for a given recceiver ID.")
71+
parser.add_argument("-f", "--config", required=True, help="Path to recceiver config file")
72+
parser.add_argument("--recceiver-id", default=None, help="Override recceiver ID from config")
73+
parser.add_argument("--dry-run", action="store_true", help="Print count without modifying CF")
74+
args = parser.parse_args(argv)
75+
76+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
77+
78+
conf = configparser.ConfigParser()
79+
conf.read(args.config)
80+
cf_conf = CFConfig.loads(ConfigAdapter(conf, "cf"))
81+
82+
if args.recceiver_id:
83+
cf_conf.recceiver_id = args.recceiver_id
84+
85+
if cf_conf.base_url is None:
86+
print("ERROR: baseUrl must be configured in [cf] section", file=sys.stderr)
87+
sys.exit(1)
88+
89+
try:
90+
count = run_clean(cf_conf, dry_run=args.dry_run)
91+
except RequestException as err:
92+
print(f"ERROR: CF request failed: {err}", file=sys.stderr)
93+
sys.exit(2)
94+
95+
action = "Would mark" if args.dry_run else "Marked"
96+
print(f"{action} {count} channels Inactive for recceiver_id={cf_conf.recceiver_id!r}")
97+
98+
99+
if __name__ == "__main__":
100+
main()

server/recceiver/recast.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def __init__(self, active=True):
2929

3030
self.sess, self.active = None, active
3131
self.uploadSize, self.uploadStart = 0, 0
32+
self._ping_timer = None # connectionLost guards on this; connectionMade may not set it
3233

3334
self.rxfn = collections.defaultdict(self.dfact)
3435

@@ -58,7 +59,7 @@ def connectionLost(self, reason=protocol.connectionDone):
5859
self.factory.isDone(self, self.active)
5960
if self._ping_timer and self._ping_timer.active():
6061
self._ping_timer.cancel()
61-
del self._ping_timer
62+
self._ping_timer = None
6263
if self.sess:
6364
self.sess.close()
6465
del self.sess
@@ -178,6 +179,7 @@ def recvDone(self, body):
178179
log.error("Ignoring done update")
179180
return self.getInitialState()
180181
self.factory.isDone(self, self.active)
182+
self.active = False # slot freed; connectionLost must not free it again
181183
self.sess.done()
182184
if self.phase == 1:
183185
self.writePing()
@@ -361,16 +363,18 @@ class CastFactory(protocol.ServerFactory):
361363
maxActive = 3
362364

363365
def __init__(self):
364-
# Throttle concurrent uploading connections to control CF commit load.
365-
# "Active" means currently uploading records; connections become
366-
# "inactive" via isDone() once the upload completes.
366+
# Flow control by limiting the number of concurrent
367+
# "active" connections. Active means dumping lots of records.
368+
# Connections become "inactive" by calling isDone().
367369
self.NActive = 0
368370
self.Wait = []
369371

370372
def isDone(self, proto, active):
371373
if not active:
372-
# connection closed before activation
373-
self.Wait.remove(proto)
374+
# connection closed before activation; guard: proto may no longer be in Wait
375+
# if recvDone already freed the slot and cleared self.active
376+
if proto in self.Wait:
377+
self.Wait.remove(proto)
374378
elif len(self.Wait) > 0:
375379
# Others are waiting
376380
waiting = self.Wait.pop(0)

0 commit comments

Comments
 (0)