cronphase 0.2.1__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.
cronphase/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """
2
+ cronphase — Fleet-wide cron thundering-herd detector & deterministic phase-shift solver.
3
+ """
4
+
5
+ from .parser import CronExpression, ScheduledJob, parse_manifest
6
+ from .engine import SimulationResult, simulate_day
7
+ from .solver import SolverConfig, optimize_fleet
8
+ from .diff import generate_unified_diff, apply_rewrites
9
+
10
+ __version__ = "0.2.1"
11
+ __all__ = [
12
+ "CronExpression",
13
+ "ScheduledJob",
14
+ "parse_manifest",
15
+ "SimulationResult",
16
+ "simulate_day",
17
+ "SolverConfig",
18
+ "optimize_fleet",
19
+ "generate_unified_diff",
20
+ "apply_rewrites",
21
+ ]
cronphase/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
cronphase/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ """
2
+ Command-Line Interface for `cronphase`.
3
+ Usage:
4
+ python3 -m cronphase examples/prod.crontab --optimize --diff
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from typing import List, Optional
12
+ from .parser import parse_manifest
13
+ from .engine import format_hhmm, simulate_day
14
+ from .solver import SolverConfig, optimize_fleet
15
+ from .diff import apply_rewrites, generate_unified_diff
16
+
17
+
18
+ def _ascii_sparkline(values: List[float], width: int = 48) -> str:
19
+ blocks = " ▂▃▄▅▆▇█"
20
+ if not values:
21
+ return ""
22
+ step = max(1, len(values) // width)
23
+ bucketed = [max(values[i : i + step]) for i in range(0, len(values), step)][:width]
24
+ hi = max(bucketed) if bucketed else 1.0
25
+ if hi <= 0:
26
+ return blocks[0] * len(bucketed)
27
+ return "".join(blocks[min(len(blocks) - 1, int((v / hi) * (len(blocks) - 1)))] for v in bucketed)
28
+
29
+
30
+ def main(argv: Optional[List[str]] = None) -> int:
31
+ parser = argparse.ArgumentParser(
32
+ prog="cronphase",
33
+ description="Fleet-wide cron thundering-herd analyzer & deterministic phase-shift compiler.",
34
+ )
35
+ parser.add_argument("manifest", help="Path to crontab, Kubernetes CronJob YAML, or GitHub Actions YAML ('-' for stdin)")
36
+ parser.add_argument("--optimize", "-o", action="store_true", help="Run constrained phase-shift solver")
37
+ parser.add_argument(
38
+ "--strategy",
39
+ choices=["anneal", "prime-splay", "fnv-hash"],
40
+ default="anneal",
41
+ help="Phase-shift solver algorithm (default: anneal)",
42
+ )
43
+ parser.add_argument("--diff", "-d", action="store_true", help="Output unified git diff patch")
44
+ parser.add_argument("--json", action="store_true", help="Output machine-readable JSON report")
45
+ parser.add_argument(
46
+ "--max-crest-factor",
47
+ type=float,
48
+ default=None,
49
+ help="Exit with code 2 if Crest Factor (Peak/Mean load) exceeds threshold (for CI linting)",
50
+ )
51
+
52
+ args = parser.parse_args(argv)
53
+ if args.manifest == "-":
54
+ raw = sys.stdin.read()
55
+ fname = "stdin.crontab"
56
+ else:
57
+ with open(args.manifest, "r", encoding="utf-8") as f:
58
+ raw = f.read()
59
+ fname = args.manifest.rsplit("/", 1)[-1]
60
+
61
+ jobs = parse_manifest(raw)
62
+ if not jobs:
63
+ print("cronphase: no valid cron schedules found in input.", file=sys.stderr)
64
+ return 1
65
+
66
+ cfg = SolverConfig(strategy=args.strategy)
67
+ if args.optimize or args.diff:
68
+ opt_jobs, rewrites, before_sim, after_sim = optimize_fleet(jobs, cfg)
69
+ else:
70
+ before_sim = simulate_day(jobs)
71
+ opt_jobs, rewrites, after_sim = jobs, [], before_sim
72
+
73
+ if args.json:
74
+ payload = {
75
+ "job_count": len(jobs),
76
+ "before": {
77
+ "peak_concurrency": before_sim.peak_concurrency,
78
+ "peak_load": before_sim.peak_load,
79
+ "peak_time": format_hhmm(before_sim.peak_minute),
80
+ "mean_load": before_sim.mean_load,
81
+ "crest_factor": before_sim.crest_factor,
82
+ "collision_minutes": before_sim.collision_minutes_count,
83
+ "harmonics": before_sim.harmonic_buckets,
84
+ },
85
+ "after": {
86
+ "peak_concurrency": after_sim.peak_concurrency,
87
+ "peak_load": after_sim.peak_load,
88
+ "peak_time": format_hhmm(after_sim.peak_minute),
89
+ "mean_load": after_sim.mean_load,
90
+ "crest_factor": after_sim.crest_factor,
91
+ "collision_minutes": after_sim.collision_minutes_count,
92
+ "harmonics": after_sim.harmonic_buckets,
93
+ },
94
+ "rewrites": [
95
+ {
96
+ "job_id": r.job_id,
97
+ "job_name": r.job_name,
98
+ "old_expr": r.old_expr,
99
+ "new_expr": r.new_expr,
100
+ "phase_shift_minutes": r.phase_shift_minutes,
101
+ }
102
+ for r in rewrites
103
+ ],
104
+ }
105
+ print(json.dumps(payload, indent=2))
106
+ else:
107
+ print(f"CRONPHASE // FLEET HARMONIC & COLLISION REPORT ({len(jobs)} jobs parsed)")
108
+ print("=" * 74)
109
+ b_loads = [s.load for s in before_sim.timeline]
110
+ print(
111
+ f"BASELINE | Peak Load: {before_sim.peak_load:5.1f} ({before_sim.peak_concurrency} jobs @ {format_hhmm(before_sim.peak_minute)}) "
112
+ f"| Crest: {before_sim.crest_factor:4.2f}x | Collisions: {before_sim.collision_minutes_count}m"
113
+ )
114
+ print(f"WAVEFORM | [{_ascii_sparkline(b_loads, 48)}] (00:00..23:59)")
115
+ if args.optimize or args.diff:
116
+ a_loads = [s.load for s in after_sim.timeline]
117
+ drop_pct = ((before_sim.peak_load - after_sim.peak_load) / before_sim.peak_load * 100.0) if before_sim.peak_load else 0.0
118
+ print(
119
+ f"OPTIMIZED | Peak Load: {after_sim.peak_load:5.1f} ({after_sim.peak_concurrency} jobs @ {format_hhmm(after_sim.peak_minute)}) "
120
+ f"| Crest: {after_sim.crest_factor:4.2f}x | Peak Drop: -{drop_pct:.1f}%"
121
+ )
122
+ print(f"DE-PHASED | [{_ascii_sparkline(a_loads, 48)}] ({len(rewrites)} schedules shifted)")
123
+
124
+ if args.diff and rewrites:
125
+ print("\n" + "-" * 74)
126
+ rewritten_text = apply_rewrites(raw, jobs, rewrites)
127
+ print(generate_unified_diff(raw, rewritten_text, filename=fname).rstrip())
128
+
129
+ active_sim = after_sim if (args.optimize or args.diff) else before_sim
130
+ if args.max_crest_factor is not None and active_sim.crest_factor > args.max_crest_factor:
131
+ return 2
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
cronphase/diff.py ADDED
@@ -0,0 +1,51 @@
1
+ """
2
+ Unified Diff & Rewritten Manifest Generator for Crontab, Kubernetes YAML, and GitHub Actions.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import difflib
7
+ from typing import Dict, List
8
+ from .parser import ScheduledJob
9
+ from .solver import JobRewrite
10
+
11
+
12
+ def apply_rewrites(original_text: str, jobs: List[ScheduledJob], rewrites: List[JobRewrite]) -> str:
13
+ """Applies phase-shifted cron expressions back to the original manifest line-by-line."""
14
+ rewrite_by_id: Dict[str, JobRewrite] = {r.job_id: r for r in rewrites}
15
+ line_to_rewrite: Dict[int, JobRewrite] = {}
16
+ for j in jobs:
17
+ if j.id in rewrite_by_id and j.source_line > 0:
18
+ line_to_rewrite[j.source_line] = rewrite_by_id[j.id]
19
+
20
+ out_lines: List[str] = []
21
+ for idx, line in enumerate(original_text.splitlines(keepends=True), start=1):
22
+ rw = line_to_rewrite.get(idx)
23
+ if rw is not None:
24
+ # Replace only inside the schedule portion before any trailing comment
25
+ comment_idx = line.find(" #")
26
+ if comment_idx != -1:
27
+ head = line[:comment_idx].replace(rw.old_expr, rw.new_expr, 1)
28
+ replaced = head + line[comment_idx:]
29
+ else:
30
+ replaced = line.replace(rw.old_expr, rw.new_expr, 1)
31
+ out_lines.append(replaced)
32
+ else:
33
+ out_lines.append(line)
34
+ return "".join(out_lines)
35
+
36
+
37
+ def generate_unified_diff(
38
+ original_text: str,
39
+ rewritten_text: str,
40
+ filename: str = "crontab",
41
+ ) -> str:
42
+ orig_lines = [ln if ln.endswith("\n") else ln + "\n" for ln in original_text.splitlines()]
43
+ new_lines = [ln if ln.endswith("\n") else ln + "\n" for ln in rewritten_text.splitlines()]
44
+ diff = difflib.unified_diff(
45
+ orig_lines,
46
+ new_lines,
47
+ fromfile=f"a/{filename}",
48
+ tofile=f"b/{filename}",
49
+ lineterm="\n",
50
+ )
51
+ return "".join(diff)
cronphase/engine.py ADDED
@@ -0,0 +1,167 @@
1
+ """
2
+ 1,440-Minute Diurnal Concurrency, Resource Load & Harmonic Spectrum Simulator.
3
+ Accurately models multi-minute job durations, midnight wrap-around spillover,
4
+ crest factor (Peak / Mean), and pairwise job collision matrices.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import dataclasses
9
+ import math
10
+ from typing import Dict, List, Tuple
11
+ from .parser import ScheduledJob
12
+
13
+
14
+ @dataclasses.dataclass
15
+ class MinuteSlice:
16
+ minute: int # 0..1439
17
+ concurrency: int
18
+ load: float
19
+ active_job_ids: List[str]
20
+ resource_loads: Dict[str, float]
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class CollisionSpike:
25
+ minute: int
26
+ time_label: str
27
+ concurrency: int
28
+ total_load: float
29
+ dominant_resource: str
30
+ job_names: List[str]
31
+
32
+
33
+ @dataclasses.dataclass
34
+ class SimulationResult:
35
+ timeline: List[MinuteSlice]
36
+ peak_concurrency: int
37
+ peak_load: float
38
+ peak_minute: int
39
+ mean_load: float
40
+ p95_load: float
41
+ l2_energy: float
42
+ crest_factor: float
43
+ collision_minutes_count: int
44
+ harmonic_buckets: Dict[str, int]
45
+ top_spikes: List[CollisionSpike]
46
+ pairwise_overlaps: Dict[Tuple[str, str], int]
47
+
48
+
49
+ def format_hhmm(minute: int) -> str:
50
+ m = minute % 1440
51
+ return f"{m // 60:02d}:{m % 60:02d}"
52
+
53
+
54
+ def simulate_day(jobs: List[ScheduledJob], collision_threshold: int = 3) -> SimulationResult:
55
+ timeline: List[MinuteSlice] = [
56
+ MinuteSlice(minute=m, concurrency=0, load=0.0, active_job_ids=[], resource_loads={})
57
+ for m in range(1440)
58
+ ]
59
+
60
+ harmonic_buckets: Dict[str, int] = {
61
+ ":00": 0,
62
+ ":15": 0,
63
+ ":30": 0,
64
+ ":45": 0,
65
+ "5m-grid": 0,
66
+ "prime/off-grid": 0,
67
+ }
68
+
69
+ for job in jobs:
70
+ starts = job.cron.firing_minutes_in_day()
71
+ dur = max(1, job.duration_min)
72
+ for start_min in starts:
73
+ mod_m = start_min % 60
74
+ if mod_m == 0:
75
+ harmonic_buckets[":00"] += 1
76
+ elif mod_m == 15:
77
+ harmonic_buckets[":15"] += 1
78
+ elif mod_m == 30:
79
+ harmonic_buckets[":30"] += 1
80
+ elif mod_m == 45:
81
+ harmonic_buckets[":45"] += 1
82
+ elif mod_m % 5 == 0:
83
+ harmonic_buckets["5m-grid"] += 1
84
+ else:
85
+ harmonic_buckets["prime/off-grid"] += 1
86
+
87
+ for offset in range(dur):
88
+ m_idx = (start_min + offset) % 1440
89
+ sl = timeline[m_idx]
90
+ if job.id not in sl.active_job_ids:
91
+ sl.active_job_ids.append(job.id)
92
+ sl.concurrency += 1
93
+ sl.load = round(sl.load + job.weight, 4)
94
+ sl.resource_loads[job.resource] = round(
95
+ sl.resource_loads.get(job.resource, 0.0) + job.weight, 4
96
+ )
97
+
98
+ job_map = {j.id: j for j in jobs}
99
+ loads = [sl.load for sl in timeline]
100
+ concurrencies = [sl.concurrency for sl in timeline]
101
+
102
+ peak_load = max(loads) if loads else 0.0
103
+ peak_concurrency = max(concurrencies) if concurrencies else 0
104
+ peak_minute = 0
105
+ for sl in timeline:
106
+ if sl.load == peak_load:
107
+ peak_minute = sl.minute
108
+ break
109
+
110
+ mean_load = sum(loads) / 1440.0 if loads else 0.0
111
+ sorted_loads = sorted(loads)
112
+ p95_load = sorted_loads[int(1440 * 0.95)] if sorted_loads else 0.0
113
+ l2_energy = math.sqrt(sum(x * x for x in loads) / 1440.0) if loads else 0.0
114
+ crest_factor = (peak_load / mean_load) if mean_load > 1e-6 else 1.0
115
+ collision_minutes_count = sum(1 for sl in timeline if sl.concurrency >= collision_threshold)
116
+
117
+ pairwise: Dict[Tuple[str, str], int] = {}
118
+ for sl in timeline:
119
+ active = sl.active_job_ids
120
+ n = len(active)
121
+ if n > 1:
122
+ for i in range(n):
123
+ for k in range(i + 1, n):
124
+ pair = (active[i], active[k]) if active[i] < active[k] else (active[k], active[i])
125
+ pairwise[pair] = pairwise.get(pair, 0) + 1
126
+
127
+ candidate_slices = sorted(
128
+ [sl for sl in timeline if sl.concurrency >= 2],
129
+ key=lambda s: (s.load, s.concurrency, -s.minute),
130
+ reverse=True,
131
+ )
132
+ top_spikes: List[CollisionSpike] = []
133
+ used_minutes: List[int] = []
134
+ for sl in candidate_slices:
135
+ if any(min((sl.minute - u) % 1440, (u - sl.minute) % 1440) < 5 for u in used_minutes):
136
+ continue
137
+ used_minutes.append(sl.minute)
138
+ dom_res = "none"
139
+ if sl.resource_loads:
140
+ dom_res = max(sl.resource_loads.items(), key=lambda kv: kv[1])[0]
141
+ top_spikes.append(
142
+ CollisionSpike(
143
+ minute=sl.minute,
144
+ time_label=format_hhmm(sl.minute),
145
+ concurrency=sl.concurrency,
146
+ total_load=round(sl.load, 2),
147
+ dominant_resource=dom_res,
148
+ job_names=[job_map[jid].name for jid in sl.active_job_ids],
149
+ )
150
+ )
151
+ if len(top_spikes) >= 8:
152
+ break
153
+
154
+ return SimulationResult(
155
+ timeline=timeline,
156
+ peak_concurrency=peak_concurrency,
157
+ peak_load=round(peak_load, 2),
158
+ peak_minute=peak_minute,
159
+ mean_load=round(mean_load, 2),
160
+ p95_load=round(p95_load, 2),
161
+ l2_energy=round(l2_energy, 2),
162
+ crest_factor=round(crest_factor, 2),
163
+ collision_minutes_count=collision_minutes_count,
164
+ harmonic_buckets=harmonic_buckets,
165
+ top_spikes=top_spikes,
166
+ pairwise_overlaps=pairwise,
167
+ )
cronphase/parser.py ADDED
@@ -0,0 +1,413 @@
1
+ """
2
+ Multi-format parser for UNIX Crontab, Kubernetes CronJob YAML, and GitHub Actions workflows.
3
+ Supports standard 5-field cron expressions, @hourly/@daily/@weekly/@midnight macros,
4
+ and inline or heuristic job metadata (@duration, @weight, @window, @lock, @resource).
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import dataclasses
9
+ import re
10
+ from typing import Dict, List, Optional, Set, Tuple
11
+
12
+ MACROS: Dict[str, str] = {
13
+ "@yearly": "0 0 1 1 *",
14
+ "@annually": "0 0 1 1 *",
15
+ "@monthly": "0 0 1 * *",
16
+ "@weekly": "0 0 * * 0",
17
+ "@daily": "0 0 * * *",
18
+ "@midnight": "0 0 * * *",
19
+ "@hourly": "0 * * * *",
20
+ }
21
+
22
+ MONTH_NAMES = {
23
+ "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
24
+ "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
25
+ }
26
+
27
+ DOW_NAMES = {
28
+ "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
29
+ }
30
+
31
+
32
+ @dataclasses.dataclass(frozen=True)
33
+ class CronExpression:
34
+ raw: str
35
+ minute_field: str
36
+ hour_field: str
37
+ dom_field: str
38
+ month_field: str
39
+ dow_field: str
40
+ minutes: Tuple[int, ...]
41
+ hours: Tuple[int, ...]
42
+ doms: Tuple[int, ...]
43
+ months: Tuple[int, ...]
44
+ dows: Tuple[int, ...]
45
+ dom_restricted: bool
46
+ dow_restricted: bool
47
+
48
+ @classmethod
49
+ def parse(cls, expr: str) -> "CronExpression":
50
+ cleaned = expr.strip()
51
+ if cleaned.lower() in MACROS:
52
+ cleaned = MACROS[cleaned.lower()]
53
+ parts = cleaned.split()
54
+ if len(parts) != 5:
55
+ raise ValueError(f"Expected 5 cron fields, got {len(parts)} in '{expr}'")
56
+
57
+ m_str, h_str, dom_str, mon_str, dow_str = parts
58
+ minutes = tuple(sorted(_parse_field(m_str, 0, 59)))
59
+ hours = tuple(sorted(_parse_field(h_str, 0, 23)))
60
+ doms = tuple(sorted(_parse_field(dom_str, 1, 31)))
61
+ months = tuple(sorted(_parse_field(mon_str, 1, 12, MONTH_NAMES)))
62
+ dows_raw = _parse_field(dow_str, 0, 7, DOW_NAMES)
63
+ dows = tuple(sorted({0 if d == 7 else d for d in dows_raw}))
64
+
65
+ return cls(
66
+ raw=expr.strip(),
67
+ minute_field=m_str,
68
+ hour_field=h_str,
69
+ dom_field=dom_str,
70
+ month_field=mon_str,
71
+ dow_field=dow_str,
72
+ minutes=minutes,
73
+ hours=hours,
74
+ doms=doms,
75
+ months=months,
76
+ dows=dows,
77
+ dom_restricted=(dom_str != "*"),
78
+ dow_restricted=(dow_str != "*"),
79
+ )
80
+
81
+ def matches_minute_of_day(
82
+ self,
83
+ minute_of_day: int,
84
+ day_of_week: int = 0,
85
+ day_of_month: int = 1,
86
+ month: int = 1,
87
+ ) -> bool:
88
+ if month not in self.months:
89
+ return False
90
+ # Standard Vixie cron semantics: if both DOM and DOW are restricted (not '*'),
91
+ # match if EITHER matches; otherwise both must match.
92
+ if self.dom_restricted and self.dow_restricted:
93
+ day_match = (day_of_month in self.doms) or (day_of_week in self.dows)
94
+ else:
95
+ day_match = (day_of_month in self.doms) and (day_of_week in self.dows)
96
+ if not day_match:
97
+ return False
98
+
99
+ h = (minute_of_day // 60) % 24
100
+ m = minute_of_day % 60
101
+ return (h in self.hours) and (m in self.minutes)
102
+
103
+ def firing_minutes_in_day(self) -> List[int]:
104
+ """Returns sorted list of minutes [0..1439] in a representative active day."""
105
+ out: List[int] = []
106
+ for h in self.hours:
107
+ base = h * 60
108
+ for m in self.minutes:
109
+ out.append(base + m)
110
+ return out
111
+
112
+ def format_canonical(self) -> str:
113
+ return f"{self.minute_field} {self.hour_field} {self.dom_field} {self.month_field} {self.dow_field}"
114
+
115
+
116
+ def _parse_field(
117
+ field: str,
118
+ min_val: int,
119
+ max_val: int,
120
+ names: Optional[Dict[str, int]] = None,
121
+ ) -> Set[int]:
122
+ text = field.strip().lower()
123
+ if names:
124
+ for k, v in names.items():
125
+ text = re.sub(rf"\b{k}\b", str(v), text)
126
+
127
+ result: Set[int] = set()
128
+ for part in text.split(","):
129
+ part = part.strip()
130
+ if not part:
131
+ continue
132
+ step = 1
133
+ if "/" in part:
134
+ range_part, step_str = part.split("/", 1)
135
+ step = int(step_str)
136
+ if step <= 0:
137
+ raise ValueError(f"Step must be positive in '{field}'")
138
+ else:
139
+ range_part = part
140
+
141
+ if range_part == "*":
142
+ start, end = min_val, max_val
143
+ elif "-" in range_part:
144
+ s_str, e_str = range_part.split("-", 1)
145
+ start, end = int(s_str), int(e_str)
146
+ else:
147
+ val = int(range_part)
148
+ if "/" in part:
149
+ start, end = val, max_val
150
+ else:
151
+ start, end = val, val
152
+
153
+ if start < min_val or end > max_val or start > end:
154
+ raise ValueError(
155
+ f"Field '{field}' value out of bounds [{min_val}..{max_val}]"
156
+ )
157
+ for v in range(start, end + 1, step):
158
+ result.add(v)
159
+
160
+ if not result:
161
+ raise ValueError(f"Empty cron field '{field}'")
162
+ return result
163
+
164
+
165
+ @dataclasses.dataclass
166
+ class ScheduledJob:
167
+ id: str
168
+ name: str
169
+ cron: CronExpression
170
+ command: str
171
+ duration_min: int = 3
172
+ weight: float = 2.0
173
+ resource: str = "db-primary"
174
+ locked: bool = False
175
+ window_start_hour: Optional[int] = None
176
+ window_end_hour: Optional[int] = None
177
+ source_line: int = 0
178
+ source_format: str = "crontab" # 'crontab' | 'k8s' | 'github'
179
+
180
+
181
+ def infer_job_profile(name: str, command: str) -> Tuple[int, float, str]:
182
+ """Heuristically estimates duration (minutes), load weight (1-10), and primary resource."""
183
+ text = f"{name} {command}".lower()
184
+ if any(k in text for k in ("pg_dump", "mysqldump", "backup", "snapshot", "xtrabackup")):
185
+ return 12, 8.5, "db-replica"
186
+ if any(k in text for k in ("vacuum", "reindex", "analyze", "optimize", "migration")):
187
+ return 9, 9.0, "db-primary"
188
+ if any(k in text for k in ("dbt", "warehouse", "snowflake", "bigquery", "spark", "etl", "rollup")):
189
+ return 8, 7.0, "analytics-db"
190
+ if any(k in text for k in ("billing", "invoice", "stripe", "ledger", "payout", "reconcile")):
191
+ return 6, 6.5, "db-primary"
192
+ if any(k in text for k in ("rsync", "s3", "rclone", "export", "sitemap", "feed")):
193
+ return 5, 4.5, "storage-io"
194
+ if any(k in text for k in ("pytest", "playwright", "cypress", "e2e", "audit", "trivy", "codeql")):
195
+ return 7, 5.5, "ci-pool"
196
+ if any(k in text for k in ("cache", "redis", "flush", "warm", "prune", "gc", "cleanup")):
197
+ return 3, 4.0, "redis-cluster"
198
+ if any(k in text for k in ("health", "ping", "heartbeat", "metrics", "exporter")):
199
+ return 1, 1.2, "api-gateway"
200
+ return 3, 3.0, "db-primary"
201
+
202
+
203
+ _CRONTAB_LINE_RE = re.compile(
204
+ r"^\s*(?:(@(?:yearly|annually|monthly|weekly|daily|midnight|hourly))|"
205
+ r"(\S+\s+\S+\s+\S+\s+\S+\s+\S+))\s+(.+?)$",
206
+ re.IGNORECASE,
207
+ )
208
+
209
+
210
+ def _extract_annotations(text: str) -> Dict[str, str]:
211
+ """Parses @duration=5m @weight=4 @resource=db @window=01-05 @lock tags from comments."""
212
+ meta: Dict[str, str] = {}
213
+ for m in re.finditer(r"@(duration|weight|resource|window|name|id)=([^\s#]+)", text):
214
+ meta[m.group(1).lower()] = m.group(2)
215
+ if re.search(r"@lock\b", text):
216
+ meta["lock"] = "true"
217
+ return meta
218
+
219
+
220
+ def parse_manifest(content: str) -> List[ScheduledJob]:
221
+ """Auto-detects and parses UNIX crontab, Kubernetes CronJob YAML, or GitHub Actions YAML."""
222
+ lines = content.splitlines()
223
+ is_yaml = any(
224
+ re.match(r"^\s*(apiVersion:|kind:\s*CronJob|on:|schedule:|-\s*cron:)", ln)
225
+ for ln in lines
226
+ )
227
+ if is_yaml:
228
+ return _parse_yaml_schedules(lines)
229
+ return _parse_crontab(lines)
230
+
231
+
232
+ def _parse_crontab(lines: List[str]) -> List[ScheduledJob]:
233
+ jobs: List[ScheduledJob] = []
234
+ pending_comment = ""
235
+ for idx, raw_line in enumerate(lines, start=1):
236
+ stripped = raw_line.strip()
237
+ if not stripped:
238
+ pending_comment = ""
239
+ continue
240
+ if stripped.startswith("#"):
241
+ pending_comment += " " + stripped[1:].strip()
242
+ continue
243
+ if re.match(r"^[A-Za-z_][A-Za-z0-9_]*\s*=", stripped):
244
+ continue
245
+
246
+ m = _CRONTAB_LINE_RE.match(stripped)
247
+ if not m:
248
+ continue
249
+ expr_str = m.group(1) or m.group(2)
250
+ rest = m.group(3).strip()
251
+
252
+ if " #" in rest:
253
+ cmd_part, inline_comment = rest.split(" #", 1)
254
+ else:
255
+ cmd_part, inline_comment = rest, ""
256
+
257
+ tokens = cmd_part.split()
258
+ if len(tokens) >= 2 and tokens[0] in ("root", "ubuntu", "postgres", "www-data", "app", "cron"):
259
+ cmd_clean = " ".join(tokens[1:])
260
+ else:
261
+ cmd_clean = cmd_part.strip()
262
+
263
+ full_comment = f"{pending_comment} {inline_comment}".strip()
264
+ pending_comment = ""
265
+
266
+ try:
267
+ cron = CronExpression.parse(expr_str)
268
+ except ValueError:
269
+ continue
270
+
271
+ meta = _extract_annotations(full_comment)
272
+ job_id = f"job-{len(jobs) + 1:02d}"
273
+ name = meta.get("name") or _derive_job_name(cmd_clean, full_comment, len(jobs) + 1)
274
+ dur, weight, resource = infer_job_profile(name, cmd_clean)
275
+
276
+ if "duration" in meta:
277
+ dur_val = re.sub(r"[^0-9]", "", meta["duration"])
278
+ if dur_val:
279
+ dur = max(1, min(180, int(dur_val)))
280
+ if "weight" in meta:
281
+ try:
282
+ weight = max(0.5, min(25.0, float(meta["weight"])))
283
+ except ValueError:
284
+ pass
285
+ if "resource" in meta:
286
+ resource = meta["resource"]
287
+
288
+ win_start, win_end = None, None
289
+ if "window" in meta:
290
+ wm = re.match(r"(\d{1,2})(?::\d{2})?-(\d{1,2})(?::\d{2})?", meta["window"])
291
+ if wm:
292
+ win_start, win_end = int(wm.group(1)) % 24, int(wm.group(2)) % 24
293
+
294
+ jobs.append(
295
+ ScheduledJob(
296
+ id=job_id,
297
+ name=name,
298
+ cron=cron,
299
+ command=cmd_clean,
300
+ duration_min=dur,
301
+ weight=weight,
302
+ resource=resource,
303
+ locked=("lock" in meta),
304
+ window_start_hour=win_start,
305
+ window_end_hour=win_end,
306
+ source_line=idx,
307
+ source_format="crontab",
308
+ )
309
+ )
310
+ return jobs
311
+
312
+
313
+ _YAML_NAME_RE = re.compile(r"""^name:\s*['"]?([A-Za-z0-9_.-]+(?:\s+[A-Za-z0-9_.-]+)*)['"]?""")
314
+ _YAML_SCHED_RE = re.compile(
315
+ r"""^(?:schedule|-\s*cron):\s*['"]?(@[a-zA-Z]+|[^'"#]+?)['"]?\s*(?:#(.*))?$"""
316
+ )
317
+
318
+
319
+ def _parse_yaml_schedules(lines: List[str]) -> List[ScheduledJob]:
320
+ """Parses Kubernetes CronJob manifests and GitHub Actions workflow schedules."""
321
+ jobs: List[ScheduledJob] = []
322
+ current_name: Optional[str] = None
323
+ current_cmd: str = ""
324
+ current_comment: str = ""
325
+
326
+ for idx, raw_line in enumerate(lines, start=1):
327
+ stripped = raw_line.strip()
328
+ if stripped.startswith("#"):
329
+ current_comment += " " + stripped[1:].strip()
330
+ continue
331
+ if stripped == "---":
332
+ current_name = None
333
+ current_cmd = ""
334
+ current_comment = ""
335
+ continue
336
+
337
+ name_m = _YAML_NAME_RE.match(stripped)
338
+ if name_m and not current_name:
339
+ current_name = name_m.group(1).strip()
340
+
341
+ sched_m = _YAML_SCHED_RE.match(stripped)
342
+ if sched_m:
343
+ expr_str = sched_m.group(1).strip()
344
+ inline_c = (sched_m.group(2) or "").strip()
345
+ fmt = "github" if stripped.startswith("-") or "cron:" in stripped else "k8s"
346
+ try:
347
+ cron = CronExpression.parse(expr_str)
348
+ except ValueError:
349
+ continue
350
+
351
+ full_meta_text = f"{current_comment} {inline_c}".strip()
352
+ meta = _extract_annotations(full_meta_text)
353
+ job_idx = len(jobs) + 1
354
+ j_name = meta.get("name") or current_name or f"{fmt}-job-{job_idx:02d}"
355
+ dur, weight, resource = infer_job_profile(j_name, current_cmd or full_meta_text)
356
+ if "duration" in meta:
357
+ d_str = re.sub(r"[^0-9]", "", meta["duration"])
358
+ if d_str:
359
+ dur = max(1, int(d_str))
360
+ if "weight" in meta:
361
+ try:
362
+ weight = float(meta["weight"])
363
+ except ValueError:
364
+ pass
365
+ if "resource" in meta:
366
+ resource = meta["resource"]
367
+
368
+ win_start, win_end = None, None
369
+ if "window" in meta:
370
+ wm = re.match(r"(\d{1,2})(?::\d{2})?-(\d{1,2})(?::\d{2})?", meta["window"])
371
+ if wm:
372
+ win_start, win_end = int(wm.group(1)) % 24, int(wm.group(2)) % 24
373
+
374
+ jobs.append(
375
+ ScheduledJob(
376
+ id=f"job-{job_idx:02d}",
377
+ name=j_name,
378
+ cron=cron,
379
+ command=current_cmd or f"[{fmt.upper()}] {j_name}",
380
+ duration_min=dur,
381
+ weight=weight,
382
+ resource=resource,
383
+ locked=("lock" in meta),
384
+ window_start_hour=win_start,
385
+ window_end_hour=win_end,
386
+ source_line=idx,
387
+ source_format=fmt,
388
+ )
389
+ )
390
+ if fmt == "k8s":
391
+ current_name = None
392
+ current_comment = ""
393
+ return jobs
394
+
395
+
396
+ def _derive_job_name(command: str, comment: str, index: int) -> str:
397
+ clean_c = re.sub(r"@[a-zA-Z0-9_=-]+", "", comment).strip(" -:#")
398
+ if clean_c and len(clean_c) <= 36:
399
+ return re.sub(r"\s+", "-", clean_c.lower())
400
+ tokens = [
401
+ t for t in command.split()
402
+ if not t.startswith("-") and "=" not in t and t not in ("/bin/sh", "/bin/bash", "python", "python3", "node", "flock", "nice", "ionice", "cronic")
403
+ ]
404
+ if tokens:
405
+ base = tokens[0].rsplit("/", 1)[-1]
406
+ base = re.sub(r"\.(sh|py|js|rb|pl|sql)$", "", base)
407
+ if len(tokens) > 1 and base in ("manage", "artisan", "rake", "celery", "kubectl", "pg_dump", "mysqldump", "dbt"):
408
+ sub = re.sub(r"[^a-zA-Z0-9_-]", "", tokens[1])
409
+ if sub:
410
+ return f"{base}-{sub}"[:32]
411
+ if base:
412
+ return base[:32]
413
+ return f"cron-job-{index:02d}"
cronphase/solver.py ADDED
@@ -0,0 +1,320 @@
1
+ """
2
+ Deterministic Constrained Phase-Shift Compiler for Cron Fleets.
3
+ Shifts job minute/hour phase offsets while strictly preserving:
4
+ 1. Exact execution cadence / period (every 5m/15m/30m, hourly, multi-hour lattice, daily)
5
+ 2. Pinned jobs (`locked=True`)
6
+ 3. Allowed maintenance window bounds (`window_start_hour`..`window_end_hour`)
7
+ 4. Standard POSIX/Vixie cron syntax compatibility
8
+ """
9
+
10
+ from __future__ import annotations
11
+ import copy
12
+ import dataclasses
13
+ from typing import Dict, List, Optional, Tuple
14
+ from .parser import CronExpression, ScheduledJob
15
+ from .engine import SimulationResult, simulate_day
16
+
17
+
18
+ @dataclasses.dataclass
19
+ class SolverConfig:
20
+ strategy: str = "anneal" # 'anneal' | 'prime-splay' | 'fnv-hash'
21
+ preserve_hours_for_daily: bool = False
22
+ daily_window_hours: Tuple[int, int] = (0, 4)
23
+
24
+
25
+ @dataclasses.dataclass
26
+ class JobRewrite:
27
+ job_id: str
28
+ job_name: str
29
+ old_expr: str
30
+ new_expr: str
31
+ phase_shift_minutes: int
32
+ reason: str
33
+
34
+
35
+ def _detect_minute_period(minutes: Tuple[int, ...]) -> Optional[int]:
36
+ """If minutes form an exact periodic lattice across [0..59], return its period."""
37
+ n = len(minutes)
38
+ if n == 1:
39
+ return 60
40
+ if n in (2, 3, 4, 5, 6, 10, 12, 15, 20, 30):
41
+ period = 60 // n
42
+ base = minutes[0] % period
43
+ expected = tuple(sorted((base + i * period) % 60 for i in range(n)))
44
+ if minutes == expected:
45
+ return period
46
+ return None
47
+
48
+
49
+ def _detect_hour_period(hours: Tuple[int, ...]) -> Optional[int]:
50
+ """If hours form a multi-hour periodic lattice (e.g., */2, */3, */4, */6, */8, */12), return period."""
51
+ n = len(hours)
52
+ if n in (2, 3, 4, 6, 8, 12):
53
+ period = 24 // n
54
+ base = hours[0] % period
55
+ expected = tuple(sorted((base + i * period) % 24 for i in range(n)))
56
+ if hours == expected:
57
+ return period
58
+ return None
59
+
60
+
61
+ def _build_shifted_minute_field(period: int, phase: int) -> str:
62
+ phase_mod = phase % period
63
+ if period == 60:
64
+ return str(phase_mod)
65
+ if phase_mod == 0:
66
+ return f"*/{period}"
67
+ return f"{phase_mod}-59/{period}"
68
+
69
+
70
+ def _build_shifted_hour_field(h_period: int, h_phase: int) -> str:
71
+ p = h_phase % h_period
72
+ if p == 0:
73
+ return f"*/{h_period}"
74
+ return f"{p}-23/{h_period}"
75
+
76
+
77
+ def _allowed_hours_for_job(job: ScheduledJob, cfg: SolverConfig) -> List[int]:
78
+ if job.window_start_hour is not None and job.window_end_hour is not None:
79
+ s, e = job.window_start_hour, job.window_end_hour
80
+ if s <= e:
81
+ return list(range(s, e + 1))
82
+ return list(range(s, 24)) + list(range(0, e + 1))
83
+
84
+ if not cfg.preserve_hours_for_daily and len(job.cron.hours) == 1 and len(job.cron.minutes) == 1:
85
+ orig_h = job.cron.hours[0]
86
+ w_start, w_end = cfg.daily_window_hours
87
+ if w_start <= orig_h <= w_end:
88
+ return list(range(w_start, w_end + 1))
89
+ return list(job.cron.hours)
90
+
91
+
92
+ def _fnv1a_32(text: str) -> int:
93
+ h = 0x811C9DC5
94
+ for b in text.encode("utf-8"):
95
+ h ^= b
96
+ h = (h * 0x01000193) & 0xFFFFFFFF
97
+ return h
98
+
99
+
100
+ def optimize_fleet(
101
+ jobs: List[ScheduledJob],
102
+ config: Optional[SolverConfig] = None,
103
+ ) -> Tuple[List[ScheduledJob], List[JobRewrite], SimulationResult, SimulationResult]:
104
+ """
105
+ Optimizes the fleet schedule to minimize Peak Load (L_inf) and Load Variance (L_2).
106
+ Returns (optimized_jobs, rewrites, before_sim, after_sim).
107
+ """
108
+ cfg = config or SolverConfig()
109
+ before_sim = simulate_day(jobs)
110
+ optimized = [copy.deepcopy(j) for j in jobs]
111
+
112
+ indexed_jobs = list(enumerate(optimized))
113
+ indexed_jobs.sort(
114
+ key=lambda pair: (
115
+ not pair[1].locked,
116
+ pair[1].weight * pair[1].duration_min * len(pair[1].cron.firing_minutes_in_day()),
117
+ ),
118
+ reverse=True,
119
+ )
120
+
121
+ if cfg.strategy == "fnv-hash":
122
+ _apply_hash_splay(optimized, cfg)
123
+ elif cfg.strategy == "prime-splay":
124
+ _apply_prime_splay(optimized, cfg)
125
+ else:
126
+ _apply_minimax_solver(optimized, indexed_jobs, cfg)
127
+
128
+ rewrites: List[JobRewrite] = []
129
+ for orig, new_job in zip(jobs, optimized):
130
+ old_s = orig.cron.format_canonical()
131
+ new_s = new_job.cron.format_canonical()
132
+ if old_s != new_s:
133
+ old_first = orig.cron.firing_minutes_in_day()[0] if orig.cron.firing_minutes_in_day() else 0
134
+ new_first = new_job.cron.firing_minutes_in_day()[0] if new_job.cron.firing_minutes_in_day() else 0
135
+ delta = new_first - old_first
136
+ rewrites.append(
137
+ JobRewrite(
138
+ job_id=orig.id,
139
+ job_name=orig.name,
140
+ old_expr=orig.cron.raw,
141
+ new_expr=new_s,
142
+ phase_shift_minutes=delta,
143
+ reason=f"De-phased from {old_s} to flatten {orig.resource} peak contention",
144
+ )
145
+ )
146
+
147
+ after_sim = simulate_day(optimized)
148
+ return optimized, rewrites, before_sim, after_sim
149
+
150
+
151
+ def _candidate_expressions(job: ScheduledJob, cfg: SolverConfig) -> List[CronExpression]:
152
+ period = _detect_minute_period(job.cron.minutes)
153
+ if period is None or period <= 1:
154
+ return [job.cron]
155
+
156
+ allowed_hours = _allowed_hours_for_job(job, cfg)
157
+ h_period = _detect_hour_period(job.cron.hours)
158
+ candidates: List[CronExpression] = []
159
+
160
+ if len(job.cron.hours) == 1 and len(allowed_hours) > 1 and period == 60:
161
+ for h in allowed_hours:
162
+ for m_phase in range(0, 60):
163
+ expr_str = f"{m_phase} {h} {job.cron.dom_field} {job.cron.month_field} {job.cron.dow_field}"
164
+ candidates.append(CronExpression.parse(expr_str))
165
+ elif h_period is not None and period == 60:
166
+ for hp in range(0, h_period):
167
+ h_field = _build_shifted_hour_field(h_period, hp)
168
+ for m_phase in range(0, 60):
169
+ expr_str = f"{m_phase} {h_field} {job.cron.dom_field} {job.cron.month_field} {job.cron.dow_field}"
170
+ candidates.append(CronExpression.parse(expr_str))
171
+ else:
172
+ for m_phase in range(0, period):
173
+ m_field = _build_shifted_minute_field(period, m_phase)
174
+ expr_str = f"{m_field} {job.cron.hour_field} {job.cron.dom_field} {job.cron.month_field} {job.cron.dow_field}"
175
+ candidates.append(CronExpression.parse(expr_str))
176
+
177
+ return candidates
178
+
179
+
180
+ def _apply_hash_splay(jobs: List[ScheduledJob], cfg: SolverConfig) -> None:
181
+ for job in jobs:
182
+ if job.locked:
183
+ continue
184
+ period = _detect_minute_period(job.cron.minutes)
185
+ if not period or period <= 1:
186
+ continue
187
+ h_val = _fnv1a_32(f"{job.name}:{job.command}")
188
+ allowed_hours = _allowed_hours_for_job(job, cfg)
189
+ h_period = _detect_hour_period(job.cron.hours)
190
+ m_phase = h_val % period
191
+ m_field = _build_shifted_minute_field(period, m_phase)
192
+ h_field = job.cron.hour_field
193
+ if len(job.cron.hours) == 1 and len(allowed_hours) > 1 and period == 60:
194
+ h_field = str(allowed_hours[(h_val >> 8) % len(allowed_hours)])
195
+ elif h_period is not None and period == 60:
196
+ h_field = _build_shifted_hour_field(h_period, (h_val >> 8) % h_period)
197
+ job.cron = CronExpression.parse(
198
+ f"{m_field} {h_field} {job.cron.dom_field} {job.cron.month_field} {job.cron.dow_field}"
199
+ )
200
+
201
+
202
+ def _apply_prime_splay(jobs: List[ScheduledJob], cfg: SolverConfig) -> None:
203
+ prime_stride = 17
204
+ prime_hour_stride = 3
205
+ for idx, job in enumerate(jobs):
206
+ if job.locked:
207
+ continue
208
+ period = _detect_minute_period(job.cron.minutes)
209
+ if not period or period <= 1:
210
+ continue
211
+ allowed_hours = _allowed_hours_for_job(job, cfg)
212
+ h_period = _detect_hour_period(job.cron.hours)
213
+ m_phase = ((idx + 1) * prime_stride) % period
214
+ m_field = _build_shifted_minute_field(period, m_phase)
215
+ h_field = job.cron.hour_field
216
+ if len(job.cron.hours) == 1 and len(allowed_hours) > 1 and period == 60:
217
+ h_field = str(allowed_hours[((idx + 1) * prime_hour_stride) % len(allowed_hours)])
218
+ elif h_period is not None and period == 60:
219
+ h_field = _build_shifted_hour_field(h_period, ((idx + 1) * prime_hour_stride) % h_period)
220
+ job.cron = CronExpression.parse(
221
+ f"{m_field} {h_field} {job.cron.dom_field} {job.cron.month_field} {job.cron.dow_field}"
222
+ )
223
+
224
+
225
+ def _apply_minimax_solver(
226
+ jobs: List[ScheduledJob],
227
+ ordered_pairs: List[Tuple[int, ScheduledJob]],
228
+ cfg: SolverConfig,
229
+ ) -> None:
230
+ """
231
+ 3-pass coordinate descent on the 1,440-minute load vector.
232
+ Minimizes lexicographical objective:
233
+ (candidate_peak_load, candidate_resource_peak, l2_sum_of_squares_delta, concurrency_l2_delta, harmonic_penalty)
234
+ """
235
+ total_load = [0.0] * 1440
236
+ total_conc = [0] * 1440
237
+ res_load: Dict[str, List[float]] = {}
238
+
239
+ def add_job_to_grid(j: ScheduledJob, sign: int) -> None:
240
+ r_arr = res_load.setdefault(j.resource, [0.0] * 1440)
241
+ dur = max(1, j.duration_min)
242
+ w = j.weight * sign
243
+ visited = set()
244
+ for s in j.cron.firing_minutes_in_day():
245
+ for off in range(dur):
246
+ m = (s + off) % 1440
247
+ if m not in visited:
248
+ visited.add(m)
249
+ total_load[m] += w
250
+ total_conc[m] += sign
251
+ r_arr[m] += w
252
+
253
+ for j in jobs:
254
+ add_job_to_grid(j, +1)
255
+
256
+ for _pass in range(3):
257
+ improved_in_pass = False
258
+ for idx, job in ordered_pairs:
259
+ if job.locked:
260
+ continue
261
+ candidates = _candidate_expressions(job, cfg)
262
+ if len(candidates) <= 1:
263
+ continue
264
+
265
+ add_job_to_grid(job, -1)
266
+ r_arr = res_load[job.resource]
267
+ dur = max(1, job.duration_min)
268
+ w = job.weight
269
+
270
+ best_cron = job.cron
271
+ best_score: Optional[Tuple[float, float, float, float, int]] = None
272
+
273
+ seed_offset = ((idx + 1) * 13) % len(candidates)
274
+ rotated = candidates[seed_offset:] + candidates[:seed_offset]
275
+
276
+ for cand in rotated:
277
+ fires = cand.firing_minutes_in_day()
278
+ cand_peak_load = 0.0
279
+ cand_res_peak = 0.0
280
+ delta_l2 = 0.0
281
+ delta_conc_l2 = 0.0
282
+
283
+ touched = {
284
+ (s + off) % 1440
285
+ for s in fires
286
+ for off in range(dur)
287
+ }
288
+
289
+ for m in touched:
290
+ nl = total_load[m] + w
291
+ nr = r_arr[m] + w
292
+ nc = total_conc[m] + 1
293
+ if nl > cand_peak_load:
294
+ cand_peak_load = nl
295
+ if nr > cand_res_peak:
296
+ cand_res_peak = nr
297
+ delta_l2 += nl * nl - total_load[m] * total_load[m]
298
+ delta_conc_l2 += nc * nc - total_conc[m] * total_conc[m]
299
+
300
+ first_mod = (fires[0] % 60) if fires else 0
301
+ harmonic_penalty = 2 if first_mod == 0 else (1 if first_mod % 15 == 0 else 0)
302
+
303
+ score = (
304
+ round(cand_peak_load, 4),
305
+ round(cand_res_peak, 4),
306
+ round(delta_l2, 4),
307
+ round(delta_conc_l2, 4),
308
+ harmonic_penalty,
309
+ )
310
+ if best_score is None or score < best_score:
311
+ best_score = score
312
+ best_cron = cand
313
+
314
+ if best_cron.format_canonical() != job.cron.format_canonical():
315
+ improved_in_pass = True
316
+ job.cron = best_cron
317
+ add_job_to_grid(job, +1)
318
+
319
+ if not improved_in_pass:
320
+ break
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: cronphase
3
+ Version: 0.2.1
4
+ Summary: Fleet-wide cron thundering-herd analyzer & deterministic phase-shift compiler
5
+ Author: cronphase contributors
6
+ License: MIT
7
+ Keywords: cron,crontab,kubernetes,devops,sre,scheduler,thundering-herd
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: System :: Monitoring
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # `cronphase` — Fleet-Wide Cron Thundering-Herd Oscilloscope & Phase-Shift Compiler
22
+
23
+ > **Why does your database CPU spike to 100% at `00:00`, `:00`, and `:15` every day?**
24
+ > Because humans write `0 * * * *`, `*/5 * * * *`, `*/15 * * * *`, and `0 0 * * *`. Across 20+ microservices, Kubernetes `CronJob` manifests, and GitHub Actions workflows, those schedules constructively interfere like acoustic waves—creating **The Midnight Stampede**.
25
+
26
+ Existing tools (like `crontab.guru`) only translate a **single** cron line into English.
27
+ **`cronphase`** analyzes your **entire fleet** as a 1,440-minute diurnal signal, pinpoints multi-job resource collisions (accounting for multi-minute execution durations and midnight wrap-around), and runs a **constrained minimax phase-shift solver** that rewrites cron expressions with deterministic offsets (`*/15 * * * *` → `7-59/15 * * * *`, `0 * * * *` → `23 * * * *`, `0 */4 * * *` → `19 1-23/4 * * *`) while strictly preserving every job's exact frequency, SLA window bounds, and pinned locks.
28
+
29
+ ## Key Capabilities
30
+
31
+ 1. **Multi-Format Fleet Ingestion**: Parses standard UNIX `/etc/crontab`, Kubernetes `batch/v1 CronJob` YAML manifests, and GitHub Actions `.github/workflows/*.yml` schedule blocks.
32
+ 2. **1,440-Minute Load Envelope & Crest Factor Simulator**: Models job execution duration (`@duration=8m`), resource weight (`@weight=7.5`), and target subsystem contention (`@resource=db-primary`), including jobs that start at `23:55` and spill across midnight into `00:05`.
33
+ 3. **Harmonic Resonance Spectrum**: Quantifies `:00`, `:15`, `:30`, `:45`, and `5m-grid` harmonic clustering across your fleet.
34
+ 4. **Constrained Minimax Phase-Shift Compiler**:
35
+ - **Coordinate Descent / Minimax (`--strategy anneal`)**: Minimizes $L_\infty$ peak resource load and $L_2$ load variance across the 1,440-minute ring.
36
+ - **Deterministic FNV-1a Hash Jitter (`--strategy fnv-hash`)**: Zero-coordination stateless phase assignment (compiles Jenkins `H/15 * * * *` semantics to standard POSIX/Vixie cron syntax).
37
+ - **Prime-Modulo Splay (`--strategy prime-splay`)**: Coprime stride distribution across periodic lattices.
38
+ 5. **Ready-to-Apply Unified Git Diff & CI Gate**: Generates a `git apply`-ready patch and supports `--max-crest-factor` for CI/CD pull request checks.
39
+
40
+ ## Quick Start (CLI)
41
+
42
+ ```bash
43
+ # 1. Analyze a production crontab and print the 24h waveform + collision spikes
44
+ python3 -m cronphase examples/prod.crontab
45
+
46
+ # 2. Run the constrained phase-shift compiler and emit a unified git diff patch
47
+ python3 -m cronphase examples/prod.crontab --optimize --diff
48
+
49
+ # 3. Gate CI pipelines if peak-to-mean crest factor exceeds 2.5x
50
+ python3 -m cronphase examples/k8s-cronjobs.yaml --optimize --max-crest-factor 2.5
51
+ ```
@@ -0,0 +1,13 @@
1
+ cronphase/__init__.py,sha256=qsfJw3SKG0eYJIHC-mAHpAhpi7eA9OZQtgFSOeZOac4,559
2
+ cronphase/__main__.py,sha256=R8toRIEpaunLvLRpFhTKF4x0aBUmBLoqHo0IrF4wuTw,82
3
+ cronphase/cli.py,sha256=Cx4nkPNCtFEg2feFMqLOPcGv789vBNz0utK9M0OpxIs,5652
4
+ cronphase/diff.py,sha256=jGuAmw3G3eduM2ojHh0am7grtdB7_V_9OdDcmbZ7MWg,1881
5
+ cronphase/engine.py,sha256=6FWP7B7MGzIeJmH3lbQE6oUSdC5ShY_856WPKGSEub8,5447
6
+ cronphase/parser.py,sha256=siczRf_ACPt0ga281qBrlFhMonS8NpKbGlI0sdE14jw,14429
7
+ cronphase/solver.py,sha256=avxBf2E5ApzRYMIrVHaodAybAmYudZ8q2_290UNQIN8,11842
8
+ cronphase-0.2.1.dist-info/licenses/LICENSE,sha256=OPnhVzkF-cltTDNu7Xwr83wTPrQs3pADR62XsHDoDD4,1079
9
+ cronphase-0.2.1.dist-info/METADATA,sha256=EfCjTP3cC9ByDV1Q2Tsafy2kJ94Z-9FG6cxCCEW-vsQ,3468
10
+ cronphase-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ cronphase-0.2.1.dist-info/entry_points.txt,sha256=sUcmhBc3Psb0-G1EJAVKsEZ8__y5u_NqRNKZZxitX64,49
12
+ cronphase-0.2.1.dist-info/top_level.txt,sha256=a4cvIu_YPOitzYvgQ4Jml8TEw1ibwH3YRX9nlbrswjk,10
13
+ cronphase-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cronphase = cronphase.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cronphase contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cronphase