alissa-tools-github-revloop 0.31.24__tar.gz → 0.31.26__tar.gz

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 (40) hide show
  1. {alissa_tools_github_revloop-0.31.24/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.26}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/janitor.py +150 -23
  3. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/loop.py +4 -3
  4. alissa_tools_github_revloop-0.31.26/src/main/alissa/tools/github/revloop/version +1 -0
  5. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_revloop-0.31.24/src/main/alissa/tools/github/revloop/version +0 -1
  7. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/LICENSE +0 -0
  8. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/MANIFEST.in +0 -0
  9. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/NOTICE +0 -0
  10. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/README.md +0 -0
  11. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/requirements.txt +0 -0
  12. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/setup.cfg +0 -0
  13. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/setup.py +0 -0
  14. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  15. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  16. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  17. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  18. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  19. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/config.py +0 -0
  20. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  21. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  22. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  23. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/parking.py +0 -0
  24. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  25. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  26. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  27. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/state.py +0 -0
  28. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  29. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/version.py +0 -0
  30. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  31. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  32. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  33. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  34. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  35. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  36. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  40. {alissa_tools_github_revloop-0.31.24 → alissa_tools_github_revloop-0.31.26}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.24
3
+ Version: 0.31.26
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -117,6 +117,34 @@ HOLD_SKEW_SECONDS = 5 * 60
117
117
  # round window, and one WARNING names what holds the most bytes.
118
118
  BACKSTOP_PCT = 95.0
119
119
 
120
+ # How long a held folder's cached size is trusted (issue #187). The cache is
121
+ # keyed on the folder's newest mtime, which only sees the folder and its
122
+ # direct children: a write deeper down (an `npm ci` filling `node_modules`)
123
+ # leaves it unchanged. So an entry is re-walked once it is this old, mtime
124
+ # or not.
125
+ SIZE_CACHE_SECONDS = 60 * 60
126
+
127
+
128
+ @dataclass
129
+ class HeldWalk:
130
+ """A held folder's size walk that ran out of budget (issue #189), kept
131
+ so the next sweep resumes it instead of starting over: the folder's
132
+ newest mtime when the walk began, the sweep `now` it began at, the bytes
133
+ counted so far, and the directories still to read. A resumed walk's
134
+ total may count a directory removed since it was read; it never counts a
135
+ directory twice."""
136
+
137
+ mtime: float
138
+ started: float
139
+ total: int
140
+ stack: "list[str]"
141
+
142
+
143
+ # One held folder's cached size, by path: either a finished walk, as
144
+ # (newest mtime, bytes, the sweep `now` the walk began at), or a `HeldWalk`
145
+ # that the time budget cut short (issue #189).
146
+ SizeCache = dict[Path, "tuple[float, int, float] | HeldWalk"]
147
+
120
148
  # The two reasons a hub-wide hold gives, so the sweep summary groups them.
121
149
  HUB_HOLD_BOUNDED = "a live round in this hub names no review task and spawned before this checkout"
122
150
  HUB_HOLD_UNBOUNDED = "a live round in this hub names no review task or spawn time"
@@ -259,6 +287,13 @@ class SweepResult:
259
287
  # checkouts only it released from a hub-wide hold.
260
288
  held_bytes: "dict[str, int]" = field(default_factory=dict)
261
289
  held_unsized: int = 0
290
+ # Issue #187: held folders whose size came from the cache, not a walk
291
+ # this sweep, so the summary can say its figure may be up to
292
+ # SIZE_CACHE_SECONDS old.
293
+ held_cached: int = 0
294
+ # Issue #189: unsized held folders whose walk is kept to resume next
295
+ # sweep (a subset of `held_unsized`).
296
+ held_resuming: int = 0
262
297
  backstop: bool = False
263
298
  backstop_released: int = 0
264
299
 
@@ -294,13 +329,20 @@ HELD_REASONS_LOGGED = 10
294
329
  def held_summary(result: SweepResult) -> str:
295
330
  """The sweep summary's held-bytes tail (issue #185): every hub/reason
296
331
  holding bytes, largest first, at most HELD_REASONS_LOGGED of them, and
297
- how many held folders went unsized."""
332
+ how many held folders were sized from the cache or went unsized."""
298
333
  tail = held_reasons(result.top_held(HELD_REASONS_LOGGED))
299
334
  rest = len(result.held_bytes) - HELD_REASONS_LOGGED
300
335
  if rest > 0:
301
336
  tail += f", +{rest} more reason(s)"
337
+ if result.held_cached:
338
+ tail += (
339
+ f"; {result.held_cached} held size(s) cached, up to "
340
+ f"{SIZE_CACHE_SECONDS // 60} min old"
341
+ )
302
342
  if result.held_unsized:
303
343
  tail += f"; {result.held_unsized} held folder(s) not sized this sweep"
344
+ if result.held_resuming:
345
+ tail += f" ({result.held_resuming} walk(s) resume next sweep)"
304
346
  return tail
305
347
 
306
348
 
@@ -415,9 +457,35 @@ def dir_bytes(path: "Path | str") -> int:
415
457
  """Bytes allocated under `path` (st_blocks, the figure `du` reports and a
416
458
  removal frees), following no symlinks. Iterative, and every per-entry
417
459
  error is skipped -- devloop's `dir_size` walk, counting blocks."""
418
- total = 0
419
- stack = [os.fspath(path)]
460
+ return _walk_bytes(path, None) or 0
461
+
462
+
463
+ def held_bytes_of(
464
+ path: "Path | str",
465
+ stop: "Callable[[], bool]",
466
+ progress: "HeldWalk | None" = None,
467
+ ) -> "int | None":
468
+ """`dir_bytes` for a held folder (issue #187): `stop` is asked before
469
+ each directory is read, and the walk gives up -- None, never a partial
470
+ figure -- the moment it answers True, so one large tree cannot run the
471
+ sweep past its time budget. With `progress` (issue #189) the walk starts
472
+ from its `total` and `stack` rather than from `path`, and an abandoned
473
+ walk leaves its place there, so a later call carries on from it."""
474
+ return _walk_bytes(path, stop, progress)
475
+
476
+
477
+ def _walk_bytes(
478
+ path: "Path | str",
479
+ stop: "Callable[[], bool] | None",
480
+ progress: "HeldWalk | None" = None,
481
+ ) -> "int | None":
482
+ total = progress.total if progress is not None else 0
483
+ stack = progress.stack if progress is not None else [os.fspath(path)]
420
484
  while stack:
485
+ if stop is not None and stop():
486
+ if progress is not None:
487
+ progress.total = total # `stack` is progress.stack, in place
488
+ return None
421
489
  current = stack.pop()
422
490
  try:
423
491
  with os.scandir(current) as entries:
@@ -528,7 +596,7 @@ def sweep(
528
596
  pressure_grace_seconds: "float | None" = None,
529
597
  under_pressure: "Callable[[], bool] | None" = None,
530
598
  stale_round_seconds: "float | None" = None,
531
- sizes: "dict[Path, tuple[float, int]] | None" = None,
599
+ sizes: "SizeCache | None" = None,
532
600
  ) -> SweepResult:
533
601
  """Remove every candidate under `hubs` older than `grace_seconds` that
534
602
  `guard` does not hold, oldest first, then prune each hub's worktree
@@ -559,10 +627,18 @@ def sweep(
559
627
  checkout older than that window: no live round can own a checkout older
560
628
  than the stale window. `backstop_released` counts what only it freed.
561
629
 
562
- Every held folder is sized into `held_bytes` per hub and reason. `sizes`
563
- (path -> (mtime, bytes)), when given, caches those walks across sweeps
564
- and is re-keyed to this sweep's held folders; sizing stops at the time
565
- budget too, and what was left unsized is counted in `held_unsized`."""
630
+ Every held folder is sized into `held_bytes` per hub and reason, AFTER
631
+ the removal loop and on whatever is left of the time budget (issue
632
+ #187): removal never pays for observability. A walk that reaches the
633
+ budget mid-tree is abandoned, and every folder left unsized is counted in
634
+ `held_unsized`. `sizes` (a `SizeCache`), when given, caches those walks
635
+ across sweeps and is re-keyed to this sweep's held folders; an entry is
636
+ reused while the folder's mtime is unchanged AND it is younger than
637
+ SIZE_CACHE_SECONDS by `now`, and each reuse is counted in `held_cached`.
638
+ An abandoned walk is kept there too (issue #189): the next sweep sizes
639
+ every other held folder first and then resumes it where it stopped, so
640
+ one large tree neither starves the folders behind it nor restarts from
641
+ scratch every sweep."""
566
642
  result = SweepResult()
567
643
  started = clock()
568
644
  bounded = False
@@ -576,7 +652,7 @@ def sweep(
576
652
  if stale_round_seconds is not None:
577
653
  result.backstop = True
578
654
  void_before = now - stale_round_seconds
579
- seen_sizes: "dict[Path, tuple[float, int]]" = {}
655
+ held_queue: "list[tuple[float, Candidate, str]]" = []
580
656
  aged: "list[tuple[float, Candidate]]" = []
581
657
  for hub in hub_list:
582
658
  result.hubs += 1
@@ -603,17 +679,7 @@ def sweep(
603
679
  if why is not None:
604
680
  log.debug("janitor: keeping %s — %s", candidate.path, why)
605
681
  result.held.append((candidate.path, why))
606
- key = f"{candidate.hub_slug}: {why}"
607
- cached = (sizes or {}).get(candidate.path)
608
- if cached is not None and cached[0] == mtime:
609
- size = cached[1]
610
- elif clock() - started >= max_seconds:
611
- result.held_unsized += 1
612
- continue
613
- else:
614
- size = dir_bytes(candidate.path)
615
- seen_sizes[candidate.path] = (mtime, size)
616
- result.held_bytes[key] = result.held_bytes.get(key, 0) + size
682
+ held_queue.append((mtime, candidate, f"{candidate.hub_slug}: {why}"))
617
683
  continue
618
684
  early = age < grace_seconds
619
685
  if early and not dry_run and not (under_pressure and under_pressure()):
@@ -650,9 +716,8 @@ def sweep(
650
716
  result.removed.append((candidate.path, size))
651
717
  if early:
652
718
  result.pressure_removed += 1
653
- if sizes is not None:
654
- sizes.clear()
655
- sizes.update(seen_sizes)
719
+ _size_held(held_queue, result, sizes, now=now,
720
+ out_of_time=lambda: clock() - started >= max_seconds)
656
721
  if dry_run:
657
722
  return result
658
723
  for hub in hub_list:
@@ -662,3 +727,65 @@ def sweep(
662
727
  except (CommandError, OSError) as exc:
663
728
  log.warning("janitor: git worktree prune failed in %s (%s)", hub, exc)
664
729
  return result
730
+
731
+
732
+ def _size_held(
733
+ queue: "list[tuple[float, Candidate, str]]",
734
+ result: SweepResult,
735
+ sizes: "SizeCache | None",
736
+ *,
737
+ now: float,
738
+ out_of_time: "Callable[[], bool]",
739
+ ) -> None:
740
+ """Size the sweep's held folders into `result.held_bytes`, after the
741
+ removals (issue #187). A fresh cache entry costs nothing and is always
742
+ used; anything else is walked only while `out_of_time()` answers False,
743
+ and a walk is abandoned the moment it does.
744
+
745
+ An abandoned walk is kept in `sizes` as a `HeldWalk` (issue #189). Next
746
+ sweep its folder goes to the back of the queue, behind every folder
747
+ whose walk was not cut short, and its walk resumes from where it stopped
748
+ -- from scratch if the folder's mtime moved. So a tree too big for one
749
+ sweep's leftover budget no longer leaves the folders behind it unsized
750
+ on every sweep, and still gets its figure after a few. A finished
751
+ resumed walk is cached as of the sweep it began in, so the hourly
752
+ re-walk counts from the oldest directory it read.
753
+
754
+ `sizes` is then re-keyed to this sweep's held folders: entries for gone
755
+ folders drop out, and so do unsized folders whose walk never began."""
756
+ previous = sizes or {}
757
+
758
+ def cut_short(item: "tuple[float, Candidate, str]") -> bool:
759
+ return isinstance(previous.get(item[1].path), HeldWalk)
760
+
761
+ seen: SizeCache = {}
762
+ # Stable: oldest first within each group, as the sweep queued them.
763
+ for mtime, candidate, key in sorted(queue, key=cut_short):
764
+ cached = previous.get(candidate.path)
765
+ if (isinstance(cached, tuple) and cached[0] == mtime
766
+ and now - cached[2] < SIZE_CACHE_SECONDS):
767
+ size = cached[1]
768
+ seen[candidate.path] = cached
769
+ result.held_cached += 1
770
+ result.held_bytes[key] = result.held_bytes.get(key, 0) + size
771
+ continue
772
+ resumable = isinstance(cached, HeldWalk)
773
+ if isinstance(cached, HeldWalk) and cached.mtime == mtime:
774
+ progress = cached
775
+ else:
776
+ progress = HeldWalk(mtime, now, 0, [os.fspath(candidate.path)])
777
+ walked = None
778
+ if not out_of_time():
779
+ walked = held_bytes_of(candidate.path, out_of_time, progress)
780
+ resumable = resumable or walked is None
781
+ if walked is None:
782
+ result.held_unsized += 1
783
+ if resumable:
784
+ seen[candidate.path] = progress
785
+ result.held_resuming += 1
786
+ continue
787
+ seen[candidate.path] = (mtime, walked, progress.started)
788
+ result.held_bytes[key] = result.held_bytes.get(key, 0) + walked
789
+ if sizes is not None:
790
+ sizes.clear()
791
+ sizes.update(seen)
@@ -2847,9 +2847,10 @@ class ReviewWatcher:
2847
2847
  # under the line closes it. In memory, so a restart under pressure
2848
2848
  # opens (and reports) a new episode.
2849
2849
  self._janitor_pressure_since: "float | None" = None
2850
- # Held folders' sizes from the last sweep, path -> (mtime, bytes)
2851
- # (issue #185): the held-bytes summary re-walks only what changed.
2852
- self._janitor_sizes: "dict[Path, tuple[float, int]]" = {}
2850
+ # Held folders' sizes from the last sweep, path -> (mtime, bytes,
2851
+ # sized at) (issues #185, #187): the held-bytes summary re-walks only
2852
+ # what changed or is older than janitor_mod.SIZE_CACHE_SECONDS.
2853
+ self._janitor_sizes: "janitor_mod.SizeCache" = {}
2853
2854
  # The (repo, number, round) whose alive session the stale-round probe
2854
2855
  # just killed as `wedged:first-run-dialog` (issue #136), so the
2855
2856
  # respawn site logs its re-enqueue at INFO instead of a second
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.24
3
+ Version: 0.31.26
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera