load.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. from itertools import cycle
  2. from py.log import Producer
  3. from _pytest.runner import CollectReport
  4. from xdist.workermanage import parse_spec_config
  5. from xdist.report import report_collection_diff
  6. class LoadScheduling(object):
  7. """Implement load scheduling across nodes.
  8. This distributes the tests collected across all nodes so each test
  9. is run just once. All nodes collect and submit the test suite and
  10. when all collections are received it is verified they are
  11. identical collections. Then the collection gets divided up in
  12. chunks and chunks get submitted to nodes. Whenever a node finishes
  13. an item, it calls ``.mark_test_complete()`` which will trigger the
  14. scheduler to assign more tests if the number of pending tests for
  15. the node falls below a low-watermark.
  16. When created, ``numnodes`` defines how many nodes are expected to
  17. submit a collection. This is used to know when all nodes have
  18. finished collection or how large the chunks need to be created.
  19. Attributes:
  20. :numnodes: The expected number of nodes taking part. The actual
  21. number of nodes will vary during the scheduler's lifetime as
  22. nodes are added by the DSession as they are brought up and
  23. removed either because of a dead node or normal shutdown. This
  24. number is primarily used to know when the initial collection is
  25. completed.
  26. :node2collection: Map of nodes and their test collection. All
  27. collections should always be identical.
  28. :node2pending: Map of nodes and the indices of their pending
  29. tests. The indices are an index into ``.pending`` (which is
  30. identical to their own collection stored in
  31. ``.node2collection``).
  32. :collection: The one collection once it is validated to be
  33. identical between all the nodes. It is initialised to None
  34. until ``.schedule()`` is called.
  35. :pending: List of indices of globally pending tests. These are
  36. tests which have not yet been allocated to a chunk for a node
  37. to process.
  38. :log: A py.log.Producer instance.
  39. :config: Config object, used for handling hooks.
  40. """
  41. def __init__(self, config, log=None):
  42. self.numnodes = len(parse_spec_config(config))
  43. self.node2collection = {}
  44. self.node2pending = {}
  45. self.pending = []
  46. self.collection = None
  47. if log is None:
  48. self.log = Producer("loadsched")
  49. else:
  50. self.log = log.loadsched
  51. self.config = config
  52. @property
  53. def nodes(self):
  54. """A list of all nodes in the scheduler."""
  55. return list(self.node2pending.keys())
  56. @property
  57. def collection_is_completed(self):
  58. """Boolean indication initial test collection is complete.
  59. This is a boolean indicating all initial participating nodes
  60. have finished collection. The required number of initial
  61. nodes is defined by ``.numnodes``.
  62. """
  63. return len(self.node2collection) >= self.numnodes
  64. @property
  65. def tests_finished(self):
  66. """Return True if all tests have been executed by the nodes."""
  67. if not self.collection_is_completed:
  68. return False
  69. if self.pending:
  70. return False
  71. for pending in self.node2pending.values():
  72. if len(pending) >= 2:
  73. return False
  74. return True
  75. @property
  76. def has_pending(self):
  77. """Return True if there are pending test items
  78. This indicates that collection has finished and nodes are
  79. still processing test items, so this can be thought of as
  80. "the scheduler is active".
  81. """
  82. if self.pending:
  83. return True
  84. for pending in self.node2pending.values():
  85. if pending:
  86. return True
  87. return False
  88. def add_node(self, node):
  89. """Add a new node to the scheduler.
  90. From now on the node will be allocated chunks of tests to
  91. execute.
  92. Called by the ``DSession.worker_workerready`` hook when it
  93. successfully bootstraps a new node.
  94. """
  95. assert node not in self.node2pending
  96. self.node2pending[node] = []
  97. def add_node_collection(self, node, collection):
  98. """Add the collected test items from a node
  99. The collection is stored in the ``.node2collection`` map.
  100. Called by the ``DSession.worker_collectionfinish`` hook.
  101. """
  102. assert node in self.node2pending
  103. if self.collection_is_completed:
  104. # A new node has been added later, perhaps an original one died.
  105. # .schedule() should have
  106. # been called by now
  107. assert self.collection
  108. if collection != self.collection:
  109. other_node = next(iter(self.node2collection.keys()))
  110. msg = report_collection_diff(
  111. self.collection, collection, other_node.gateway.id, node.gateway.id
  112. )
  113. self.log(msg)
  114. return
  115. self.node2collection[node] = list(collection)
  116. def mark_test_complete(self, node, item_index, duration=0):
  117. """Mark test item as completed by node
  118. The duration it took to execute the item is used as a hint to
  119. the scheduler.
  120. This is called by the ``DSession.worker_testreport`` hook.
  121. """
  122. self.node2pending[node].remove(item_index)
  123. self.check_schedule(node, duration=duration)
  124. def check_schedule(self, node, duration=0):
  125. """Maybe schedule new items on the node
  126. If there are any globally pending nodes left then this will
  127. check if the given node should be given any more tests. The
  128. ``duration`` of the last test is optionally used as a
  129. heuristic to influence how many tests the node is assigned.
  130. """
  131. if node.shutting_down:
  132. return
  133. if self.pending:
  134. # how many nodes do we have?
  135. num_nodes = len(self.node2pending)
  136. # if our node goes below a heuristic minimum, fill it out to
  137. # heuristic maximum
  138. items_per_node_min = max(2, len(self.pending) // num_nodes // 4)
  139. items_per_node_max = max(2, len(self.pending) // num_nodes // 2)
  140. node_pending = self.node2pending[node]
  141. if len(node_pending) < items_per_node_min:
  142. if duration >= 0.1 and len(node_pending) >= 2:
  143. # seems the node is doing long-running tests
  144. # and has enough items to continue
  145. # so let's rather wait with sending new items
  146. return
  147. num_send = items_per_node_max - len(node_pending)
  148. self._send_tests(node, num_send)
  149. self.log("num items waiting for node:", len(self.pending))
  150. def remove_node(self, node):
  151. """Remove a node from the scheduler
  152. This should be called either when the node crashed or at
  153. shutdown time. In the former case any pending items assigned
  154. to the node will be re-scheduled. Called by the
  155. ``DSession.worker_workerfinished`` and
  156. ``DSession.worker_errordown`` hooks.
  157. Return the item which was being executing while the node
  158. crashed or None if the node has no more pending items.
  159. """
  160. pending = self.node2pending.pop(node)
  161. if not pending:
  162. return
  163. # The node crashed, reassing pending items
  164. crashitem = self.collection[pending.pop(0)]
  165. self.pending.extend(pending)
  166. for node in self.node2pending:
  167. self.check_schedule(node)
  168. return crashitem
  169. def schedule(self):
  170. """Initiate distribution of the test collection
  171. Initiate scheduling of the items across the nodes. If this
  172. gets called again later it behaves the same as calling
  173. ``.check_schedule()`` on all nodes so that newly added nodes
  174. will start to be used.
  175. This is called by the ``DSession.worker_collectionfinish`` hook
  176. if ``.collection_is_completed`` is True.
  177. """
  178. assert self.collection_is_completed
  179. # Initial distribution already happened, reschedule on all nodes
  180. if self.collection is not None:
  181. for node in self.nodes:
  182. self.check_schedule(node)
  183. return
  184. # XXX allow nodes to have different collections
  185. if not self._check_nodes_have_same_collection():
  186. self.log("**Different tests collected, aborting run**")
  187. return
  188. # Collections are identical, create the index of pending items.
  189. self.collection = list(self.node2collection.values())[0]
  190. self.pending[:] = range(len(self.collection))
  191. if not self.collection:
  192. return
  193. # Send a batch of tests to run. If we don't have at least two
  194. # tests per node, we have to send them all so that we can send
  195. # shutdown signals and get all nodes working.
  196. initial_batch = max(len(self.pending) // 4, 2 * len(self.nodes))
  197. # distribute tests round-robin up to the batch size
  198. # (or until we run out)
  199. nodes = cycle(self.nodes)
  200. for i in range(initial_batch):
  201. self._send_tests(next(nodes), 1)
  202. if not self.pending:
  203. # initial distribution sent all tests, start node shutdown
  204. for node in self.nodes:
  205. node.shutdown()
  206. def _send_tests(self, node, num):
  207. tests_per_node = self.pending[:num]
  208. if tests_per_node:
  209. del self.pending[:num]
  210. self.node2pending[node].extend(tests_per_node)
  211. node.send_runtest_some(tests_per_node)
  212. def _check_nodes_have_same_collection(self):
  213. """Return True if all nodes have collected the same items.
  214. If collections differ, this method returns False while logging
  215. the collection differences and posting collection errors to
  216. pytest_collectreport hook.
  217. """
  218. node_collection_items = list(self.node2collection.items())
  219. first_node, col = node_collection_items[0]
  220. same_collection = True
  221. for node, collection in node_collection_items[1:]:
  222. msg = report_collection_diff(
  223. col, collection, first_node.gateway.id, node.gateway.id
  224. )
  225. if msg:
  226. same_collection = False
  227. self.log(msg)
  228. if self.config is not None:
  229. rep = CollectReport(
  230. node.gateway.id, "failed", longrepr=msg, result=[]
  231. )
  232. self.config.hook.pytest_collectreport(report=rep)
  233. return same_collection