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 +21 -0
- cronphase/__main__.py +5 -0
- cronphase/cli.py +136 -0
- cronphase/diff.py +51 -0
- cronphase/engine.py +167 -0
- cronphase/parser.py +413 -0
- cronphase/solver.py +320 -0
- cronphase-0.2.1.dist-info/METADATA +51 -0
- cronphase-0.2.1.dist-info/RECORD +13 -0
- cronphase-0.2.1.dist-info/WHEEL +5 -0
- cronphase-0.2.1.dist-info/entry_points.txt +2 -0
- cronphase-0.2.1.dist-info/licenses/LICENSE +21 -0
- cronphase-0.2.1.dist-info/top_level.txt +1 -0
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
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,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
|