parsl 2025.6.16__py3-none-any.whl → 2025.6.30__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parsl/configs/osg.py +1 -1
- parsl/dataflow/dflow.py +14 -4
- parsl/executors/base.py +19 -9
- parsl/executors/flux/executor.py +2 -0
- parsl/executors/globus_compute.py +2 -0
- parsl/executors/high_throughput/executor.py +22 -15
- parsl/executors/high_throughput/interchange.py +173 -191
- parsl/executors/high_throughput/mpi_executor.py +14 -4
- parsl/executors/high_throughput/probe.py +4 -4
- parsl/executors/high_throughput/process_worker_pool.py +88 -94
- parsl/executors/radical/executor.py +3 -0
- parsl/executors/taskvine/executor.py +11 -3
- parsl/executors/taskvine/manager.py +3 -1
- parsl/executors/threads.py +19 -3
- parsl/executors/workqueue/executor.py +11 -3
- parsl/monitoring/errors.py +4 -4
- parsl/monitoring/monitoring.py +26 -88
- parsl/monitoring/radios/base.py +63 -2
- parsl/monitoring/radios/filesystem.py +19 -4
- parsl/monitoring/radios/filesystem_router.py +22 -3
- parsl/monitoring/radios/htex.py +22 -13
- parsl/monitoring/radios/multiprocessing.py +22 -2
- parsl/monitoring/radios/udp.py +57 -19
- parsl/monitoring/radios/udp_router.py +119 -25
- parsl/monitoring/radios/zmq_router.py +9 -10
- parsl/monitoring/remote.py +19 -40
- parsl/providers/local/local.py +12 -13
- parsl/tests/configs/htex_local_alternate.py +0 -1
- parsl/tests/conftest.py +7 -4
- parsl/tests/test_htex/test_interchange_exit_bad_registration.py +5 -7
- parsl/tests/test_htex/test_zmq_binding.py +5 -6
- parsl/tests/test_monitoring/test_basic.py +12 -10
- parsl/tests/test_monitoring/{test_fuzz_zmq.py → test_htex_fuzz_zmq.py} +7 -2
- parsl/tests/test_monitoring/test_htex_init_blocks_vs_monitoring.py +0 -1
- parsl/tests/test_monitoring/test_radio_filesystem.py +48 -0
- parsl/tests/test_monitoring/test_radio_multiprocessing.py +44 -0
- parsl/tests/test_monitoring/test_radio_udp.py +204 -0
- parsl/tests/test_monitoring/test_stdouterr.py +1 -3
- parsl/tests/test_scaling/test_worker_interchange_bad_messages_3262.py +3 -7
- parsl/tests/test_shutdown/test_kill_monitoring.py +1 -1
- parsl/version.py +1 -1
- {parsl-2025.6.16.data → parsl-2025.6.30.data}/scripts/interchange.py +173 -191
- {parsl-2025.6.16.data → parsl-2025.6.30.data}/scripts/process_worker_pool.py +88 -94
- {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/METADATA +2 -2
- {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/RECORD +51 -50
- parsl/tests/configs/local_threads_monitoring.py +0 -10
- parsl/tests/manual_tests/test_udp_simple.py +0 -51
- {parsl-2025.6.16.data → parsl-2025.6.30.data}/scripts/exec_parsl_function.py +0 -0
- {parsl-2025.6.16.data → parsl-2025.6.30.data}/scripts/parsl_coprocess.py +0 -0
- {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/LICENSE +0 -0
- {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/WHEEL +0 -0
- {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/entry_points.txt +0 -0
- {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,5 @@
|
|
1
1
|
#!python
|
2
2
|
import datetime
|
3
|
-
import json
|
4
3
|
import logging
|
5
4
|
import os
|
6
5
|
import pickle
|
@@ -46,7 +45,7 @@ class Interchange:
|
|
46
45
|
client_address: str,
|
47
46
|
interchange_address: Optional[str],
|
48
47
|
client_ports: Tuple[int, int, int],
|
49
|
-
|
48
|
+
worker_port: Optional[int],
|
50
49
|
worker_port_range: Tuple[int, int],
|
51
50
|
hub_address: Optional[str],
|
52
51
|
hub_zmq_port: Optional[int],
|
@@ -71,12 +70,12 @@ class Interchange:
|
|
71
70
|
client_ports : tuple(int, int, int)
|
72
71
|
The ports at which the client can be reached
|
73
72
|
|
74
|
-
|
75
|
-
The specific
|
73
|
+
worker_port : int
|
74
|
+
The specific port to which workers will connect to the Interchange.
|
76
75
|
|
77
76
|
worker_port_range : tuple(int, int)
|
78
77
|
The interchange picks ports at random from the range which will be used by workers.
|
79
|
-
This is overridden when the
|
78
|
+
This is overridden when the worker_port option is set.
|
80
79
|
|
81
80
|
hub_address : str
|
82
81
|
The IP address at which the interchange can send info about managers to when monitoring is enabled.
|
@@ -139,31 +138,23 @@ class Interchange:
|
|
139
138
|
# count of tasks that have been sent out to worker pools
|
140
139
|
self.count = 0
|
141
140
|
|
142
|
-
self.
|
143
|
-
self.
|
141
|
+
self.manager_sock = self.zmq_context.socket(zmq.ROUTER)
|
142
|
+
self.manager_sock.set_hwm(0)
|
144
143
|
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
self.results_incoming.set_hwm(0)
|
149
|
-
|
150
|
-
if self.worker_ports:
|
151
|
-
self.worker_task_port = self.worker_ports[0]
|
152
|
-
self.worker_result_port = self.worker_ports[1]
|
153
|
-
|
154
|
-
self.task_outgoing.bind(tcp_url(self.interchange_address, self.worker_task_port))
|
155
|
-
self.results_incoming.bind(tcp_url(self.interchange_address, self.worker_result_port))
|
144
|
+
if worker_port:
|
145
|
+
task_addy = tcp_url(self.interchange_address, worker_port)
|
146
|
+
self.manager_sock.bind(task_addy)
|
156
147
|
|
157
148
|
else:
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
149
|
+
worker_port = self.manager_sock.bind_to_random_port(
|
150
|
+
tcp_url(self.interchange_address),
|
151
|
+
min_port=worker_port_range[0],
|
152
|
+
max_port=worker_port_range[1],
|
153
|
+
max_tries=100,
|
154
|
+
)
|
155
|
+
self.worker_port = worker_port
|
164
156
|
|
165
|
-
logger.info("Bound to
|
166
|
-
self.worker_task_port, self.worker_result_port))
|
157
|
+
logger.info(f"Bound to port {worker_port} for incoming worker connections")
|
167
158
|
|
168
159
|
self._ready_managers: Dict[bytes, ManagerRecord] = {}
|
169
160
|
self.connected_block_history: List[str] = []
|
@@ -276,8 +267,8 @@ class Interchange:
|
|
276
267
|
|
277
268
|
reply = None
|
278
269
|
|
279
|
-
elif command_req == "
|
280
|
-
reply =
|
270
|
+
elif command_req == "WORKER_BINDS":
|
271
|
+
reply = self.worker_port
|
281
272
|
|
282
273
|
else:
|
283
274
|
logger.error(f"Received unknown command: {command_req}")
|
@@ -307,8 +298,7 @@ class Interchange:
|
|
307
298
|
kill_event = threading.Event()
|
308
299
|
|
309
300
|
poller = zmq.Poller()
|
310
|
-
poller.register(self.
|
311
|
-
poller.register(self.results_incoming, zmq.POLLIN)
|
301
|
+
poller.register(self.manager_sock, zmq.POLLIN)
|
312
302
|
poller.register(self.task_incoming, zmq.POLLIN)
|
313
303
|
poller.register(self.command_channel, zmq.POLLIN)
|
314
304
|
|
@@ -323,8 +313,7 @@ class Interchange:
|
|
323
313
|
|
324
314
|
self.process_command(monitoring_radio)
|
325
315
|
self.process_task_incoming()
|
326
|
-
self.
|
327
|
-
self.process_results_incoming(interesting_managers, monitoring_radio)
|
316
|
+
self.process_manager_socket_message(interesting_managers, monitoring_radio, kill_event)
|
328
317
|
self.expire_bad_managers(interesting_managers, monitoring_radio)
|
329
318
|
self.expire_drained_managers(interesting_managers, monitoring_radio)
|
330
319
|
self.process_tasks_to_send(interesting_managers, monitoring_radio)
|
@@ -353,116 +342,167 @@ class Interchange:
|
|
353
342
|
self.task_counter += 1
|
354
343
|
logger.debug(f"Fetched {self.task_counter} tasks so far")
|
355
344
|
|
356
|
-
def
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
345
|
+
def process_manager_socket_message(
|
346
|
+
self,
|
347
|
+
interesting_managers: Set[bytes],
|
348
|
+
monitoring_radio: Optional[MonitoringRadioSender],
|
349
|
+
kill_event: threading.Event,
|
361
350
|
) -> None:
|
362
|
-
"""Process one message from manager on the
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
manager_id =
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
351
|
+
"""Process one message from manager on the manager_sock channel."""
|
352
|
+
if not self.socks.get(self.manager_sock) == zmq.POLLIN:
|
353
|
+
return
|
354
|
+
|
355
|
+
logger.debug('starting worker message section')
|
356
|
+
msg_parts = self.manager_sock.recv_multipart()
|
357
|
+
try:
|
358
|
+
manager_id, meta_b, *msgs = msg_parts
|
359
|
+
meta = pickle.loads(meta_b)
|
360
|
+
mtype = meta['type']
|
361
|
+
except Exception as e:
|
362
|
+
logger.warning(
|
363
|
+
f'Failed to read manager message ([{type(e).__name__}] {e})'
|
364
|
+
)
|
365
|
+
logger.debug('Message:\n %r\n', msg_parts, exc_info=e)
|
366
|
+
return
|
367
|
+
|
368
|
+
logger.debug(
|
369
|
+
'Processing message type %r from manager %r', mtype, manager_id
|
370
|
+
)
|
371
|
+
|
372
|
+
if mtype == 'registration':
|
373
|
+
ix_minor_py = self.current_platform['python_v'].rsplit('.', 1)[0]
|
374
|
+
ix_parsl_v = self.current_platform['parsl_v']
|
375
|
+
mgr_minor_py = meta['python_v'].rsplit('.', 1)[0]
|
376
|
+
mgr_parsl_v = meta['parsl_v']
|
377
|
+
|
378
|
+
new_rec = ManagerRecord(
|
379
|
+
block_id=None,
|
380
|
+
start_time=meta['start_time'],
|
381
|
+
tasks=[],
|
382
|
+
worker_count=0,
|
383
|
+
max_capacity=0,
|
384
|
+
active=True,
|
385
|
+
draining=False,
|
386
|
+
last_heartbeat=time.time(),
|
387
|
+
idle_since=time.time(),
|
388
|
+
parsl_version=mgr_parsl_v,
|
389
|
+
python_version=meta['python_v'],
|
390
|
+
)
|
391
|
+
|
392
|
+
# m is a ManagerRecord, but meta is a dict[Any,Any] and so can
|
393
|
+
# contain arbitrary fields beyond those in ManagerRecord (and
|
394
|
+
# indeed does - for example, python_v) which are then ignored
|
395
|
+
# later.
|
396
|
+
new_rec.update(meta)
|
397
|
+
|
398
|
+
logger.info(f'Registration info for manager {manager_id!r}: {meta}')
|
399
|
+
self._send_monitoring_info(monitoring_radio, new_rec)
|
400
|
+
|
401
|
+
if (mgr_minor_py, mgr_parsl_v) != (ix_minor_py, ix_parsl_v):
|
402
|
+
kill_event.set()
|
403
|
+
vm_exc = VersionMismatch(
|
404
|
+
f"py.v={ix_minor_py} parsl.v={ix_parsl_v}",
|
405
|
+
f"py.v={mgr_minor_py} parsl.v={mgr_parsl_v}",
|
406
|
+
)
|
407
|
+
result_package = {
|
408
|
+
'type': 'result',
|
409
|
+
'task_id': -1,
|
410
|
+
'exception': serialize_object(vm_exc),
|
411
|
+
}
|
412
|
+
pkl_package = pickle.dumps(result_package)
|
413
|
+
self.results_outgoing.send(pkl_package)
|
414
|
+
logger.error(
|
415
|
+
'Manager has incompatible version info with the interchange;'
|
416
|
+
' sending failure reports and shutting down:'
|
417
|
+
f'\n Interchange: {vm_exc.interchange_version}'
|
418
|
+
f'\n Manager: {vm_exc.manager_version}'
|
404
419
|
)
|
405
420
|
|
406
|
-
|
407
|
-
#
|
408
|
-
#
|
409
|
-
|
410
|
-
m.update(msg) # type: ignore[typeddict-item]
|
421
|
+
else:
|
422
|
+
# We really should update the associated data structure; but not
|
423
|
+
# at this time. *kicks can down the road*
|
424
|
+
assert new_rec['block_id'] is not None, 'Verified externally'
|
411
425
|
|
412
|
-
|
413
|
-
self.
|
426
|
+
# set up entry only if we accept the registration
|
427
|
+
self._ready_managers[manager_id] = new_rec
|
428
|
+
self.connected_block_history.append(new_rec['block_id'])
|
414
429
|
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
430
|
+
interesting_managers.add(manager_id)
|
431
|
+
|
432
|
+
logger.info(
|
433
|
+
f"Registered manager {manager_id!r} (py{mgr_minor_py},"
|
434
|
+
f" {mgr_parsl_v}) and added to ready queue"
|
435
|
+
)
|
436
|
+
logger.debug("Manager %r -> %s", manager_id, new_rec)
|
437
|
+
|
438
|
+
return
|
439
|
+
|
440
|
+
if not (m := self._ready_managers.get(manager_id)):
|
441
|
+
logger.warning(f"Ignoring message from unknown manager: {manager_id!r}")
|
442
|
+
return
|
443
|
+
|
444
|
+
if mtype == 'result':
|
445
|
+
logger.debug("Number of results in batch: %d", len(msgs))
|
446
|
+
b_messages_to_send = []
|
447
|
+
|
448
|
+
for p_message in msgs:
|
449
|
+
r = pickle.loads(p_message)
|
450
|
+
r_type = r['type']
|
451
|
+
if r_type == 'result':
|
452
|
+
# process this for task ID and forward to executor
|
453
|
+
tid = r['task_id']
|
454
|
+
logger.debug("Removing task %s from manager", tid)
|
455
|
+
try:
|
456
|
+
m['tasks'].remove(tid)
|
457
|
+
b_messages_to_send.append(p_message)
|
458
|
+
except Exception:
|
459
|
+
logger.exception(
|
460
|
+
'Ignoring exception removing task_id %s from manager'
|
461
|
+
' task list %s',
|
462
|
+
tid,
|
463
|
+
m['tasks']
|
464
|
+
)
|
465
|
+
elif r_type == 'monitoring':
|
466
|
+
# the monitoring code makes the assumption that no
|
467
|
+
# monitoring messages will be received if monitoring
|
468
|
+
# is not configured, and that monitoring_radio will only
|
469
|
+
# be None when monitoring is not configurated.
|
470
|
+
assert monitoring_radio is not None
|
471
|
+
|
472
|
+
monitoring_radio.send(r['payload'])
|
473
|
+
|
474
|
+
else:
|
428
475
|
logger.error(
|
429
|
-
|
430
|
-
" sending failure reports and shutting down:"
|
431
|
-
f"\n Interchange: {e.interchange_version}"
|
432
|
-
f"\n Manager: {e.manager_version}"
|
476
|
+
f'Discarding result message of unknown type: {r_type}'
|
433
477
|
)
|
434
478
|
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
479
|
+
if b_messages_to_send:
|
480
|
+
logger.debug(
|
481
|
+
'Sending messages (%d) on results_outgoing',
|
482
|
+
len(b_messages_to_send),
|
483
|
+
)
|
484
|
+
self.results_outgoing.send_multipart(b_messages_to_send)
|
485
|
+
logger.debug('Sent messages on results_outgoing')
|
439
486
|
|
440
|
-
|
441
|
-
|
442
|
-
self.connected_block_history.append(m['block_id'])
|
487
|
+
# At least one result received, so manager now has idle capacity
|
488
|
+
interesting_managers.add(manager_id)
|
443
489
|
|
444
|
-
|
490
|
+
if len(m['tasks']) == 0 and m['idle_since'] is None:
|
491
|
+
m['idle_since'] = time.time()
|
445
492
|
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
logger.warning("Received heartbeat via tasks connection for not-registered manager %r", manager_id)
|
460
|
-
elif msg['type'] == 'drain':
|
461
|
-
self._ready_managers[manager_id]['draining'] = True
|
462
|
-
logger.debug("Manager %r requested drain", manager_id)
|
463
|
-
else:
|
464
|
-
logger.error(f"Unexpected message type received from manager: {msg['type']}")
|
465
|
-
logger.debug("leaving task_outgoing section")
|
493
|
+
self._send_monitoring_info(monitoring_radio, m)
|
494
|
+
|
495
|
+
elif mtype == 'heartbeat':
|
496
|
+
m['last_heartbeat'] = time.time()
|
497
|
+
self.manager_sock.send_multipart([manager_id, PKL_HEARTBEAT_CODE])
|
498
|
+
|
499
|
+
elif mtype == 'drain':
|
500
|
+
m['draining'] = True
|
501
|
+
|
502
|
+
else:
|
503
|
+
logger.error(f"Unexpected message type received from manager: {mtype}")
|
504
|
+
|
505
|
+
logger.debug("leaving worker message section")
|
466
506
|
|
467
507
|
def expire_drained_managers(self, interesting_managers: Set[bytes], monitoring_radio: Optional[MonitoringRadioSender]) -> None:
|
468
508
|
|
@@ -472,7 +512,7 @@ class Interchange:
|
|
472
512
|
m = self._ready_managers[manager_id]
|
473
513
|
if m['draining'] and len(m['tasks']) == 0:
|
474
514
|
logger.info(f"Manager {manager_id!r} is drained - sending drained message to manager")
|
475
|
-
self.
|
515
|
+
self.manager_sock.send_multipart([manager_id, PKL_DRAINED_CODE])
|
476
516
|
interesting_managers.remove(manager_id)
|
477
517
|
self._ready_managers.pop(manager_id)
|
478
518
|
|
@@ -500,7 +540,7 @@ class Interchange:
|
|
500
540
|
if real_capacity and m["active"] and not m["draining"]:
|
501
541
|
tasks = self.get_tasks(real_capacity)
|
502
542
|
if tasks:
|
503
|
-
self.
|
543
|
+
self.manager_sock.send_multipart([manager_id, pickle.dumps(tasks)])
|
504
544
|
task_count = len(tasks)
|
505
545
|
self.count += task_count
|
506
546
|
tids = [t['task_id'] for t in tasks]
|
@@ -520,64 +560,6 @@ class Interchange:
|
|
520
560
|
interesting_managers.remove(manager_id)
|
521
561
|
logger.debug("leaving _ready_managers section, with %s managers still interesting", len(interesting_managers))
|
522
562
|
|
523
|
-
def process_results_incoming(self, interesting_managers: Set[bytes], monitoring_radio: Optional[MonitoringRadioSender]) -> None:
|
524
|
-
# Receive any results and forward to client
|
525
|
-
if self.results_incoming in self.socks and self.socks[self.results_incoming] == zmq.POLLIN:
|
526
|
-
logger.debug("entering results_incoming section")
|
527
|
-
manager_id, *all_messages = self.results_incoming.recv_multipart()
|
528
|
-
if manager_id not in self._ready_managers:
|
529
|
-
logger.warning(f"Received a result from a un-registered manager: {manager_id!r}")
|
530
|
-
else:
|
531
|
-
logger.debug("Got %s result items in batch from manager %r", len(all_messages), manager_id)
|
532
|
-
|
533
|
-
m = self._ready_managers[manager_id]
|
534
|
-
b_messages_to_send = []
|
535
|
-
|
536
|
-
for p_message in all_messages:
|
537
|
-
r = pickle.loads(p_message)
|
538
|
-
if r['type'] == 'result':
|
539
|
-
# process this for task ID and forward to executor
|
540
|
-
logger.debug("Removing task %s from manager record %r", r["task_id"], manager_id)
|
541
|
-
try:
|
542
|
-
m['tasks'].remove(r['task_id'])
|
543
|
-
b_messages_to_send.append(p_message)
|
544
|
-
except Exception:
|
545
|
-
logger.exception(
|
546
|
-
"Ignoring exception removing task_id %s for manager %r with task list %s",
|
547
|
-
r['task_id'],
|
548
|
-
manager_id,
|
549
|
-
m["tasks"]
|
550
|
-
)
|
551
|
-
elif r['type'] == 'monitoring':
|
552
|
-
# the monitoring code makes the assumption that no
|
553
|
-
# monitoring messages will be received if monitoring
|
554
|
-
# is not configured, and that monitoring_radio will only
|
555
|
-
# be None when monitoring is not configurated.
|
556
|
-
assert monitoring_radio is not None
|
557
|
-
|
558
|
-
monitoring_radio.send(r['payload'])
|
559
|
-
elif r['type'] == 'heartbeat':
|
560
|
-
logger.debug("Manager %r sent heartbeat via results connection", manager_id)
|
561
|
-
else:
|
562
|
-
logger.error("Interchange discarding result_queue message of unknown type: %s", r["type"])
|
563
|
-
|
564
|
-
if b_messages_to_send:
|
565
|
-
logger.debug("Sending messages on results_outgoing")
|
566
|
-
self.results_outgoing.send_multipart(b_messages_to_send)
|
567
|
-
logger.debug("Sent messages on results_outgoing")
|
568
|
-
|
569
|
-
# At least one result received, so manager now has idle capacity
|
570
|
-
interesting_managers.add(manager_id)
|
571
|
-
|
572
|
-
if len(m['tasks']) == 0 and m['idle_since'] is None:
|
573
|
-
m['idle_since'] = time.time()
|
574
|
-
|
575
|
-
self._send_monitoring_info(monitoring_radio, m)
|
576
|
-
|
577
|
-
logger.debug("Current tasks on manager %r: %s", manager_id, m["tasks"])
|
578
|
-
|
579
|
-
logger.debug("leaving results_incoming section")
|
580
|
-
|
581
563
|
def expire_bad_managers(self, interesting_managers: Set[bytes], monitoring_radio: Optional[MonitoringRadioSender]) -> None:
|
582
564
|
bad_managers = [(manager_id, m) for (manager_id, m) in self._ready_managers.items() if
|
583
565
|
time.time() - m['last_heartbeat'] > self.heartbeat_threshold]
|