Coverage for britney2/installability/solver.py: 98%

209 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-08-18 12:43 +0000

1# Copyright (C) 2012 Niels Thykier <niels@thykier.net> 

2# - Includes code by Paul Harrison 

3# (http://www.logarithmic.net/pfh-files/blog/01208083168/sort.py) 

4 

5# This program is free software; you can redistribute it and/or modify 

6# it under the terms of the GNU General Public License as published by 

7# the Free Software Foundation; either version 2 of the License, or 

8# (at your option) any later version. 

9 

10# This program is distributed in the hope that it will be useful, 

11# but WITHOUT ANY WARRANTY; without even the implied warranty of 

12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

13# GNU General Public License for more details. 

14 

15import logging 

16from collections import deque 

17from collections.abc import Iterable 

18from dataclasses import dataclass, field 

19from itertools import chain, tee 

20from typing import TYPE_CHECKING 

21 

22from more_itertools import iter_except 

23 

24from britney2.utils import ifilter_only 

25 

26if TYPE_CHECKING: 26 ↛ 27line 26 didn't jump to line 27 because the condition on line 26 was never true

27 from .. import BinaryPackageId 

28 from ..migrationitem import MigrationItem 

29 from .tester import InstallabilityTester 

30 from .universe import BinaryPackageUniverse 

31 

32 

33@dataclass(slots=True) 

34class OrderNode: 

35 after: set[str] = field(default_factory=set) 

36 before: set[str] = field(default_factory=set) 

37 

38 

39def compute_scc(graph: dict[str, OrderNode]) -> list[tuple[str, ...]]: 

40 """Iterative algorithm for strongly-connected components 

41 

42 Iterative variant of Tarjan's algorithm for finding strongly-connected 

43 components. 

44 

45 :param graph: dict of all nodes along which their edges (in "before" and "after") 

46 :return: List of components (each component is a list of items) 

47 """ 

48 result: list[tuple[str, ...]] = [] 

49 low: dict[str, int] = {} 

50 node_stack: list[str] = [] 

51 

52 def _cannot_be_a_scc(graph_node: str) -> bool: 

53 node = graph[graph_node] 

54 if not node.before or not node.after: 

55 # Short-cut obviously isolated component 

56 result.append((graph_node,)) 

57 # Set the item number so high that no other item might 

58 # mistakenly assume that they can form a component via 

59 # this item. 

60 # (Replaces the "is w on the stack check" for us from 

61 # the original algorithm) 

62 low[graph_node] = len(graph) + 1 

63 return True 

64 return False 

65 

66 def _handle_succ( 

67 parent: str, parent_num: int, successors_remaining: list[str] 

68 ) -> bool: 

69 while successors_remaining: 

70 succ = successors_remaining.pop() 

71 succ_num = low.get(succ, None) 

72 if succ_num is not None: 

73 if succ_num < parent_num: 

74 # These two nodes are part of the probably 

75 # same SSC (or succ is isolated 

76 low[parent] = parent_num = succ_num 

77 continue 

78 # It cannot be a part of a SCC if it does not have depends 

79 # or reverse depends. 

80 if _cannot_be_a_scc(succ): 

81 continue 

82 succ_num = len(low) 

83 low[succ] = succ_num 

84 work_stack.append((succ, len(node_stack), succ_num, graph[succ].before)) 

85 node_stack.append(succ) 

86 # "Recurse" into the child node first 

87 return True 

88 return False 

89 

90 # graph is a dict, shouldn't need sorting 

91 for n, n_data in graph.items(): 

92 if n in low: 

93 continue 

94 # It cannot be a part of a SCC if it does not have depends 

95 # or reverse depends. 

96 if _cannot_be_a_scc(n): 

97 continue 

98 

99 root_num = len(low) 

100 low[n] = root_num 

101 # DFS work-stack needed to avoid call recursion. It (more or less) 

102 # replaces the variables on the call stack in Tarjan's algorithm 

103 work_stack = [(n, len(node_stack), root_num, n_data.before)] 

104 node_stack.append(n) 

105 while work_stack: 

106 node, stack_idx, orig_node_num, successors = work_stack[-1] 

107 if successors and _handle_succ(node, low[node], sorted(successors)): 

108 # _handle_succ has pushed a new node on to work_stack 

109 # and we need to "restart" the loop to handle that first 

110 continue 

111 

112 # This node is done; remove it from the work stack 

113 work_stack.pop() 

114 

115 # This node is out of successor. Push up the "low" value 

116 # (Exception: root node has no parent) 

117 node_num = low[node] 

118 if work_stack: 

119 parent = work_stack[-1][0] 

120 parent_num = low[parent] 

121 if node_num <= parent_num: 

122 # This node is a part of a component with its parent. 

123 # We update the parent's node number and push the 

124 # responsibility of building the component unto the 

125 # parent. 

126 low[parent] = node_num 

127 continue 

128 if node_num != orig_node_num: 128 ↛ 130line 128 didn't jump to line 130 because the condition on line 128 was never true

129 # The node is a part of an SCC with a ancestor (and parent) 

130 continue 

131 # We got a component 

132 component = tuple(node_stack[stack_idx:]) 

133 del node_stack[stack_idx:] 

134 result.append(component) 

135 # Re-number all items, so no other item might 

136 # mistakenly assume that they can form a component via 

137 # one of these items. 

138 # (Replaces the "is w on the stack check" for us from 

139 # the original algorithm) 

140 new_num = len(graph) + 1 

141 for item in component: 

142 low[item] = new_num 

143 

144 assert not node_stack 

145 

146 return result 

147 

148 

149def apply_order( 

150 key: str, 

151 other: str, 

152 order: dict[str, OrderNode], 

153 logger: logging.Logger, 

154 order_cause: str, 

155 invert: bool = False, 

156 order_sub_cause: str = "", 

157) -> None: 

158 if other == key: 

159 # "Self-relation" => ignore 

160 return 

161 order_key = order[key] 

162 if invert: 

163 order[other].after.add(key) 

164 order_set = order_key.before 

165 else: 

166 order[other].before.add(key) 

167 order_set = order_key.after 

168 if ( 

169 logger.isEnabledFor(logging.DEBUG) and other not in order_set 

170 ): # pragma: no cover 

171 if order_sub_cause: 

172 order_sub_cause = " (%s)" % order_sub_cause 

173 logger.debug( 

174 "%s induced order%s: %s before %s", order_cause, order_sub_cause, key, other 

175 ) 

176 # Defer adding until the end to ensure we only log the first time a dependency order is introduced. 

177 order_set.add(other) 

178 

179 

180class InstallabilitySolver: 

181 def __init__( 

182 self, universe: "BinaryPackageUniverse", inst_tester: "InstallabilityTester" 

183 ) -> None: 

184 """Create a new installability solver""" 

185 self._universe = universe 

186 self._inst_tester = inst_tester 

187 logger_name = ".".join((self.__class__.__module__, self.__class__.__name__)) 

188 self.logger = logging.getLogger(logger_name) 

189 

190 def _compute_group_order_rms( 

191 self, 

192 rms: Iterable["BinaryPackageId"], 

193 order: dict[str, OrderNode], 

194 key: str, 

195 ptable: dict["BinaryPackageId", str], 

196 going_out: set["BinaryPackageId"], 

197 ) -> None: 

198 sat_in_testing = self._inst_tester.any_of_these_are_in_the_suite 

199 universe = self._universe 

200 logger = self.logger 

201 for rdep in chain.from_iterable( 

202 universe.reverse_dependencies_of(r) for r in rms 

203 ): 

204 # The binaries have reverse dependencies in testing; 

205 # check if we can/should migrate them first. 

206 for depgroup in universe.dependencies_of(rdep): 

207 rigid = depgroup - going_out 

208 if sat_in_testing(rigid): 

209 # (partly) satisfied by testing, assume it is okay 

210 continue 

211 if (v := ptable.get(rdep)) is not None: 

212 apply_order(key, v, order, logger, "Removal") 

213 

214 def _compute_order_for_dependency( 

215 self, 

216 key: str, 

217 depgroup: frozenset["BinaryPackageId"], 

218 ptable: dict["BinaryPackageId", str], 

219 order: dict[str, OrderNode], 

220 going_in: set["BinaryPackageId"], 

221 ) -> None: 

222 # We got three cases: 

223 # - "swap" (replace existing binary with a newer version) 

224 # - "addition" (add new binary without removing any) 

225 # - "removal" (remove binary without providing a new) 

226 # 

227 # The problem is that only the two latter requires 

228 # an ordering. A "swap" (in itself) should not 

229 # affect us. 

230 other_adds = set() 

231 other_rms = set() 

232 logger = self.logger 

233 for d in ifilter_only(ptable, depgroup): 

234 other = ptable[d] 

235 if d in going_in: 

236 # "other" provides something "key" needs, 

237 # schedule accordingly. 

238 other_adds.add(other) 

239 else: 

240 # "other" removes something "key" needs, 

241 # schedule accordingly. 

242 other_rms.add(other) 

243 

244 for other in other_adds: 

245 if other in other_rms: 

246 continue 

247 apply_order(key, other, order, logger, "Dependency", order_sub_cause="add") 

248 for other in other_rms: 

249 if other in other_adds: 

250 continue 

251 apply_order( 

252 key, 

253 other, 

254 order, 

255 logger, 

256 "Dependency", 

257 order_sub_cause="remove", 

258 invert=True, 

259 ) 

260 

261 def _compute_group_order_adds( 

262 self, 

263 adds: Iterable["BinaryPackageId"], 

264 order: dict[str, OrderNode], 

265 key: str, 

266 ptable: dict["BinaryPackageId", str], 

267 going_out: set["BinaryPackageId"], 

268 going_in: set["BinaryPackageId"], 

269 ) -> None: 

270 sat_in_testing = self._inst_tester.any_of_these_are_in_the_suite 

271 universe = self._universe 

272 for depgroup in chain.from_iterable(universe.dependencies_of(a) for a in adds): 

273 # Check if this item should migrate before others 

274 # (e.g. because they depend on a new [version of a] 

275 # binary provided by this item). 

276 rigid = depgroup - going_out 

277 if sat_in_testing(rigid): 

278 # (partly) satisfied by testing, assume it is okay 

279 continue 

280 self._compute_order_for_dependency(key, depgroup, ptable, order, going_in) 

281 

282 def _compute_group_order( 

283 self, 

284 groups: Iterable[ 

285 tuple[ 

286 "MigrationItem", 

287 Iterable["BinaryPackageId"], 

288 Iterable["BinaryPackageId"], 

289 ] 

290 ], 

291 key2item: dict[str, "MigrationItem"], 

292 ) -> dict[str, OrderNode]: 

293 universe = self._universe 

294 ptable = {} 

295 order: dict[str, OrderNode] = {} 

296 going_out: set["BinaryPackageId"] = set() 

297 going_in: set["BinaryPackageId"] = set() 

298 logger = self.logger 

299 debug_solver = logger.isEnabledFor(logging.DEBUG) 

300 

301 first_it, debug_it, second_it = tee(groups, 3) 

302 

303 # Build the tables 

304 for item, adds, rms in first_it: 

305 key = str(item) 

306 key2item[key] = item 

307 order[key] = OrderNode() 

308 going_in.update(adds) 

309 going_out.update(rms) 

310 for x in chain(adds, rms): 

311 ptable[x] = key 

312 

313 if debug_solver: # pragma: no cover 

314 self._dump_groups(debug_it) 

315 

316 # This large loop will add ordering constrains on each "item" 

317 # that migrates based on various rules. 

318 for item, adds, rms in second_it: 

319 key = str(item) 

320 oldcons = set( 

321 chain.from_iterable(universe.negative_dependencies_of(r) for r in rms) 

322 ) 

323 oldcons.difference_update( 

324 chain.from_iterable(universe.negative_dependencies_of(a) for a in adds) 

325 ) 

326 # Some of the old binaries have "conflicts" that will 

327 # be removed. 

328 for o in ifilter_only(ptable, oldcons): 

329 # "key" removes a conflict with one of 

330 # "other"'s binaries, so it is probably a good 

331 # idea to migrate "key" before "other" 

332 apply_order(key, ptable[o], order, logger, "Conflict", invert=True) 

333 

334 self._compute_group_order_rms(rms, order, key, ptable, going_out) 

335 self._compute_group_order_adds( 

336 adds, order, key, ptable, going_out, going_in 

337 ) 

338 

339 return order 

340 

341 def _merge_items_into_components( 

342 self, comps: list[tuple[str, ...]], order: dict[str, OrderNode] 

343 ) -> dict[str, tuple[str, ...]]: 

344 merged = {} 

345 scc: dict[str, tuple[str, ...]] = {} 

346 debug_solver = self.logger.isEnabledFor(logging.DEBUG) 

347 for com in comps: 

348 scc_id = com[0] 

349 scc[scc_id] = com 

350 merged[scc_id] = scc_id 

351 if len(com) < 2: 

352 # Trivial case 

353 continue 

354 so_before = order[scc_id].before 

355 so_after = order[scc_id].after 

356 for n in com: 

357 if n == scc_id: 

358 continue 

359 so_before.update(order[n].before) 

360 so_after.update(order[n].after) 

361 merged[n] = scc_id 

362 del order[n] 

363 if debug_solver: # pragma: no cover 

364 self.logger.debug("SCC: %s -- %s", scc_id, sorted(com)) 

365 

366 for com in comps: 

367 node = com[0] 

368 nbefore = {merged[b] for b in order[node].before} 

369 nafter = {merged[b] for b in order[node].after} 

370 

371 # Drop self-relations (usually caused by the merging) 

372 nbefore.discard(node) 

373 nafter.discard(node) 

374 order[node].before = nbefore 

375 order[node].after = nafter 

376 

377 for com in comps: 

378 scc_id = com[0] 

379 

380 for other_scc_id in order[scc_id].before: 

381 order[other_scc_id].after.add(scc_id) 

382 for other_scc_id in order[scc_id].after: 

383 order[other_scc_id].before.add(scc_id) 

384 

385 return scc 

386 

387 def solve_groups( 

388 self, 

389 groups: Iterable[ 

390 tuple[ 

391 "MigrationItem", 

392 Iterable["BinaryPackageId"], 

393 Iterable["BinaryPackageId"], 

394 ] 

395 ], 

396 ) -> list[list["MigrationItem"]]: 

397 """ 

398 :param groups: iterable of tuples. The first element is a 

399 MigrationItem, the second element is a collection of BinaryPackageId 

400 reflecting requested updates and the third element is a collection of 

401 BinaryPackageId reflecting requested removals. 

402 """ 

403 result: list[list["MigrationItem"]] = [] 

404 emitted: set[str] = set() 

405 queue: deque[str] = deque() 

406 key2item: dict[str, "MigrationItem"] = {} 

407 debug_solver = self.logger.isEnabledFor(logging.DEBUG) 

408 

409 order = self._compute_group_order(groups, key2item) 

410 

411 # === MILESTONE: Partial-order constrains computed === 

412 

413 # At this point, we have computed all the partial-order 

414 # constrains needed. Some of these may have created strongly 

415 # connected components (SSC) [of size 2 or greater], which 

416 # represents a group of items that (we believe) must migrate 

417 # together. 

418 # 

419 # Each one of those components will become an "easy" hint. 

420 

421 comps = compute_scc(order) 

422 # Now that we got the SSCs (in comps), we select on item from 

423 # each SSC to represent the group and become an ID for that 

424 # SSC. 

425 # * scc_keys[ssc_id] => All the item-keys in that SSC 

426 # 

427 # We also "repair" the ordering, so we know in which order the 

428 # hints should be emitted. 

429 scc_keys = self._merge_items_into_components(comps, order) 

430 

431 if debug_solver: # pragma: no cover 

432 self.logger.debug("-- PARTIAL ORDER --") 

433 

434 initial_round = [] 

435 for com in sorted(order): 

436 if debug_solver and order[com].before: # pragma: no cover 

437 self.logger.debug("N: %s <= %s", com, sorted(order[com].before)) 

438 if not order[com].after: 

439 # This component can be scheduled immediately, add it 

440 # to the queue 

441 initial_round.append(com) 

442 elif debug_solver: # pragma: no cover 

443 self.logger.debug("N: %s >= %s", com, sorted(order[com].after)) 

444 

445 queue.extend(sorted(initial_round, key=len)) 

446 del initial_round 

447 

448 if debug_solver: # pragma: no cover 

449 self.logger.debug("-- END PARTIAL ORDER --") 

450 self.logger.debug("-- LINEARIZED ORDER --") 

451 

452 for cur in iter_except(queue.popleft, IndexError): 

453 if order[cur].after <= emitted and cur not in emitted: 

454 # This item is ready to be emitted right now 

455 if debug_solver: # pragma: no cover 

456 self.logger.debug("%s -- %s", cur, sorted(scc_keys[cur])) 

457 emitted.add(cur) 

458 result.append([key2item[x] for x in scc_keys[cur]]) 

459 if order[cur].before: 

460 # There are components that come after this one. 

461 # Add it to queue: 

462 # - if it is ready, it will be emitted. 

463 # - else, it will be dropped and re-added later. 

464 queue.extend(sorted(order[cur].before - emitted, key=len)) 

465 

466 if debug_solver: # pragma: no cover 

467 self.logger.debug("-- END LINEARIZED ORDER --") 

468 

469 return result 

470 

471 def _dump_groups( 

472 self, 

473 groups: Iterable[ 

474 tuple[ 

475 "MigrationItem", 

476 Iterable["BinaryPackageId"], 

477 Iterable["BinaryPackageId"], 

478 ] 

479 ], 

480 ) -> None: # pragma: no cover 

481 self.logger.debug("=== Groups ===") 

482 for item, adds, rms in groups: 

483 self.logger.debug("%s => A: %s, R: %s", item, adds, rms) 

484 self.logger.debug("=== END Groups ===")