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.
Files changed (53) hide show
  1. parsl/configs/osg.py +1 -1
  2. parsl/dataflow/dflow.py +14 -4
  3. parsl/executors/base.py +19 -9
  4. parsl/executors/flux/executor.py +2 -0
  5. parsl/executors/globus_compute.py +2 -0
  6. parsl/executors/high_throughput/executor.py +22 -15
  7. parsl/executors/high_throughput/interchange.py +173 -191
  8. parsl/executors/high_throughput/mpi_executor.py +14 -4
  9. parsl/executors/high_throughput/probe.py +4 -4
  10. parsl/executors/high_throughput/process_worker_pool.py +88 -94
  11. parsl/executors/radical/executor.py +3 -0
  12. parsl/executors/taskvine/executor.py +11 -3
  13. parsl/executors/taskvine/manager.py +3 -1
  14. parsl/executors/threads.py +19 -3
  15. parsl/executors/workqueue/executor.py +11 -3
  16. parsl/monitoring/errors.py +4 -4
  17. parsl/monitoring/monitoring.py +26 -88
  18. parsl/monitoring/radios/base.py +63 -2
  19. parsl/monitoring/radios/filesystem.py +19 -4
  20. parsl/monitoring/radios/filesystem_router.py +22 -3
  21. parsl/monitoring/radios/htex.py +22 -13
  22. parsl/monitoring/radios/multiprocessing.py +22 -2
  23. parsl/monitoring/radios/udp.py +57 -19
  24. parsl/monitoring/radios/udp_router.py +119 -25
  25. parsl/monitoring/radios/zmq_router.py +9 -10
  26. parsl/monitoring/remote.py +19 -40
  27. parsl/providers/local/local.py +12 -13
  28. parsl/tests/configs/htex_local_alternate.py +0 -1
  29. parsl/tests/conftest.py +7 -4
  30. parsl/tests/test_htex/test_interchange_exit_bad_registration.py +5 -7
  31. parsl/tests/test_htex/test_zmq_binding.py +5 -6
  32. parsl/tests/test_monitoring/test_basic.py +12 -10
  33. parsl/tests/test_monitoring/{test_fuzz_zmq.py → test_htex_fuzz_zmq.py} +7 -2
  34. parsl/tests/test_monitoring/test_htex_init_blocks_vs_monitoring.py +0 -1
  35. parsl/tests/test_monitoring/test_radio_filesystem.py +48 -0
  36. parsl/tests/test_monitoring/test_radio_multiprocessing.py +44 -0
  37. parsl/tests/test_monitoring/test_radio_udp.py +204 -0
  38. parsl/tests/test_monitoring/test_stdouterr.py +1 -3
  39. parsl/tests/test_scaling/test_worker_interchange_bad_messages_3262.py +3 -7
  40. parsl/tests/test_shutdown/test_kill_monitoring.py +1 -1
  41. parsl/version.py +1 -1
  42. {parsl-2025.6.16.data → parsl-2025.6.30.data}/scripts/interchange.py +173 -191
  43. {parsl-2025.6.16.data → parsl-2025.6.30.data}/scripts/process_worker_pool.py +88 -94
  44. {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/METADATA +2 -2
  45. {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/RECORD +51 -50
  46. parsl/tests/configs/local_threads_monitoring.py +0 -10
  47. parsl/tests/manual_tests/test_udp_simple.py +0 -51
  48. {parsl-2025.6.16.data → parsl-2025.6.30.data}/scripts/exec_parsl_function.py +0 -0
  49. {parsl-2025.6.16.data → parsl-2025.6.30.data}/scripts/parsl_coprocess.py +0 -0
  50. {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/LICENSE +0 -0
  51. {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/WHEEL +0 -0
  52. {parsl-2025.6.16.dist-info → parsl-2025.6.30.dist-info}/entry_points.txt +0 -0
  53. {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
- worker_ports: Optional[Tuple[int, int]],
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
- worker_ports : tuple(int, int)
75
- The specific two ports at which workers will connect to the Interchange.
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 worker_ports option is set.
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.worker_ports = worker_ports
143
- self.worker_port_range = worker_port_range
141
+ self.manager_sock = self.zmq_context.socket(zmq.ROUTER)
142
+ self.manager_sock.set_hwm(0)
144
143
 
145
- self.task_outgoing = self.zmq_context.socket(zmq.ROUTER)
146
- self.task_outgoing.set_hwm(0)
147
- self.results_incoming = self.zmq_context.socket(zmq.ROUTER)
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
- self.worker_task_port = self.task_outgoing.bind_to_random_port(tcp_url(self.interchange_address),
159
- min_port=worker_port_range[0],
160
- max_port=worker_port_range[1], max_tries=100)
161
- self.worker_result_port = self.results_incoming.bind_to_random_port(tcp_url(self.interchange_address),
162
- min_port=worker_port_range[0],
163
- max_port=worker_port_range[1], max_tries=100)
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 ports {},{} for incoming worker connections".format(
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 == "WORKER_PORTS":
280
- reply = (self.worker_task_port, self.worker_result_port)
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.task_outgoing, zmq.POLLIN)
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.process_task_outgoing_incoming(interesting_managers, monitoring_radio, kill_event)
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 process_task_outgoing_incoming(
357
- self,
358
- interesting_managers: Set[bytes],
359
- monitoring_radio: Optional[MonitoringRadioSender],
360
- kill_event: threading.Event
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 task_outgoing channel.
363
- Note that this message flow is in contradiction to the name of the
364
- channel - it is not an outgoing message and it is not a task.
365
- """
366
- if self.task_outgoing in self.socks and self.socks[self.task_outgoing] == zmq.POLLIN:
367
- logger.debug("starting task_outgoing section")
368
- message = self.task_outgoing.recv_multipart()
369
- manager_id = message[0]
370
-
371
- try:
372
- msg = json.loads(message[1].decode('utf-8'))
373
- except Exception:
374
- logger.warning(f"Got Exception reading message from manager: {manager_id!r}", exc_info=True)
375
- logger.debug("Message:\n %r\n", message[1])
376
- return
377
-
378
- # perform a bit of validation on the structure of the deserialized
379
- # object, at least enough to behave like a deserialization error
380
- # in obviously malformed cases
381
- if not isinstance(msg, dict) or 'type' not in msg:
382
- logger.error(f"JSON message was not correctly formatted from manager: {manager_id!r}")
383
- logger.debug("Message:\n %r\n", message[1])
384
- return
385
-
386
- if msg['type'] == 'registration':
387
- ix_minor_py = self.current_platform['python_v'].rsplit(".", 1)[0]
388
- ix_parsl_v = self.current_platform['parsl_v']
389
- mgr_minor_py = msg['python_v'].rsplit(".", 1)[0]
390
- mgr_parsl_v = msg['parsl_v']
391
-
392
- m = ManagerRecord(
393
- block_id=None,
394
- start_time=msg['start_time'],
395
- tasks=[],
396
- worker_count=0,
397
- max_capacity=0,
398
- active=True,
399
- draining=False,
400
- last_heartbeat=time.time(),
401
- idle_since=time.time(),
402
- parsl_version=mgr_parsl_v,
403
- python_version=msg['python_v'],
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
- # m is a ManagerRecord, but msg is a dict[Any,Any] and so can
407
- # contain arbitrary fields beyond those in ManagerRecord (and
408
- # indeed does - for example, python_v) which are then ignored
409
- # later.
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
- logger.info(f"Registration info for manager {manager_id!r}: {msg}")
413
- self._send_monitoring_info(monitoring_radio, m)
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
- if (mgr_minor_py, mgr_parsl_v) != (ix_minor_py, ix_parsl_v):
416
- kill_event.set()
417
- e = VersionMismatch(
418
- f"py.v={ix_minor_py} parsl.v={ix_parsl_v}",
419
- f"py.v={mgr_minor_py} parsl.v={mgr_parsl_v}",
420
- )
421
- result_package = {
422
- 'type': 'result',
423
- 'task_id': -1,
424
- 'exception': serialize_object(e),
425
- }
426
- pkl_package = pickle.dumps(result_package)
427
- self.results_outgoing.send(pkl_package)
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
- "Manager has incompatible version info with the interchange;"
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
- else:
436
- # We really should update the associated data structure; but not
437
- # at this time. *kicks can down the road*
438
- assert m['block_id'] is not None, "Verified externally currently"
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
- # set up entry only if we accept the registration
441
- self._ready_managers[manager_id] = m
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
- interesting_managers.add(manager_id)
490
+ if len(m['tasks']) == 0 and m['idle_since'] is None:
491
+ m['idle_since'] = time.time()
445
492
 
446
- logger.info(
447
- f"Registered manager {manager_id!r} (py{mgr_minor_py},"
448
- f" {mgr_parsl_v}) and added to ready queue"
449
- )
450
- logger.debug("Manager %r -> %s", manager_id, m)
451
-
452
- elif msg['type'] == 'heartbeat':
453
- manager = self._ready_managers.get(manager_id)
454
- if manager:
455
- manager['last_heartbeat'] = time.time()
456
- logger.debug("Manager %r sent heartbeat via tasks connection", manager_id)
457
- self.task_outgoing.send_multipart([manager_id, b'', PKL_HEARTBEAT_CODE])
458
- else:
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.task_outgoing.send_multipart([manager_id, b'', PKL_DRAINED_CODE])
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.task_outgoing.send_multipart([manager_id, b'', pickle.dumps(tasks)])
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]