github-actions-ingester 0.1.0__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.
@@ -0,0 +1,580 @@
1
+ """PostgreSQL persistence: schema bootstrap, migrations and upserts.
2
+
3
+ Bootstrap contract (what the operator asked for):
4
+
5
+ * first start → connects, creates the schema and applies every
6
+ migration under ``migrations/`` in order;
7
+ * upgrade → applies only the migrations not yet recorded;
8
+ * steady state → does nothing.
9
+
10
+ Migrations are plain SQL files named ``NNNN_description.sql``. The set
11
+ applied so far is recorded in ``<schema>.schema_migrations``. A
12
+ transaction-level advisory lock serializes concurrent starters (two
13
+ replicas, a ``migrate`` job racing the deployment) so only one of them
14
+ runs the DDL; the others wait and find everything already applied.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import re
21
+ from collections.abc import Iterable, Sequence
22
+ from dataclasses import dataclass
23
+ from datetime import UTC, datetime
24
+ from importlib import resources
25
+ from typing import Any
26
+
27
+ import psycopg
28
+ import structlog
29
+ from psycopg import sql
30
+ from psycopg.rows import dict_row
31
+
32
+ from .github import Repository, Workflow, WorkflowJob, WorkflowRun
33
+
34
+ logger = structlog.get_logger(__name__)
35
+
36
+ _MIGRATION_RE = re.compile(r"^(\d{4})_[a-z0-9_]+\.sql$")
37
+ # Arbitrary but stable: hashtext('github-actions-ingester') style constant.
38
+ _ADVISORY_LOCK_KEY = 0x6768612D696E67 # "gha-ing"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Migration:
43
+ version: int
44
+ name: str
45
+ body: str
46
+
47
+ @property
48
+ def checksum(self) -> str:
49
+ return hashlib.sha256(self.body.encode("utf-8")).hexdigest()
50
+
51
+
52
+ def load_migrations() -> list[Migration]:
53
+ """Read the embedded SQL files, sorted by version."""
54
+ out: list[Migration] = []
55
+ pkg = resources.files("github_actions_ingester") / "migrations"
56
+ for entry in pkg.iterdir():
57
+ m = _MIGRATION_RE.match(entry.name)
58
+ if not m:
59
+ continue
60
+ out.append(Migration(int(m.group(1)), entry.name, entry.read_text(encoding="utf-8")))
61
+ out.sort(key=lambda mig: mig.version)
62
+ return out
63
+
64
+
65
+ @dataclass
66
+ class MigrationReport:
67
+ applied: list[str]
68
+ current_version: int
69
+ pending_before: int
70
+
71
+
72
+ class Store:
73
+ def __init__(self, database_url: str, schema: str = "gha", connect_timeout: int = 10) -> None:
74
+ self._url = database_url
75
+ self._schema = schema
76
+ self._connect_timeout = connect_timeout
77
+ self._conn: psycopg.Connection[dict[str, Any]] | None = None
78
+
79
+ # -- connection ------------------------------------------------------
80
+
81
+ @property
82
+ def schema(self) -> str:
83
+ return self._schema
84
+
85
+ def connect(self) -> psycopg.Connection[dict[str, Any]]:
86
+ if self._conn is not None and not self._conn.closed:
87
+ return self._conn
88
+ conn = psycopg.connect(
89
+ self._url,
90
+ connect_timeout=self._connect_timeout,
91
+ row_factory=dict_row,
92
+ autocommit=False,
93
+ application_name="github-actions-ingester",
94
+ )
95
+ with conn.cursor() as cur:
96
+ cur.execute(
97
+ sql.SQL("SET search_path TO {}, public").format(sql.Identifier(self._schema))
98
+ )
99
+ conn.commit()
100
+ self._conn = conn
101
+ return conn
102
+
103
+ def close(self) -> None:
104
+ if self._conn is not None and not self._conn.closed:
105
+ self._conn.close()
106
+ self._conn = None
107
+
108
+ def ping(self) -> bool:
109
+ try:
110
+ with self.connect().cursor() as cur:
111
+ cur.execute("SELECT 1")
112
+ self.connect().rollback()
113
+ return True
114
+ except psycopg.Error as exc:
115
+ logger.warning("store.ping_failed", error=str(exc))
116
+ self.close()
117
+ return False
118
+
119
+ # -- migrations ----------------------------------------------------------
120
+
121
+ def migrate(self, migrations: Sequence[Migration] | None = None) -> MigrationReport:
122
+ migrations = list(migrations if migrations is not None else load_migrations())
123
+ conn = self.connect()
124
+ applied_now: list[str] = []
125
+ with conn.transaction(), conn.cursor() as cur:
126
+ cur.execute("SELECT pg_advisory_xact_lock(%s)", (_ADVISORY_LOCK_KEY,))
127
+ cur.execute(
128
+ sql.SQL("CREATE SCHEMA IF NOT EXISTS {}").format(sql.Identifier(self._schema))
129
+ )
130
+ cur.execute(
131
+ sql.SQL(
132
+ """
133
+ CREATE TABLE IF NOT EXISTS {}.schema_migrations (
134
+ version INTEGER PRIMARY KEY,
135
+ name TEXT NOT NULL,
136
+ checksum TEXT NOT NULL,
137
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
138
+ )
139
+ """
140
+ ).format(sql.Identifier(self._schema))
141
+ )
142
+ cur.execute(
143
+ sql.SQL("SELECT version, checksum FROM {}.schema_migrations").format(
144
+ sql.Identifier(self._schema)
145
+ )
146
+ )
147
+ done = {int(r["version"]): str(r["checksum"]) for r in cur.fetchall()}
148
+ pending = [m for m in migrations if m.version not in done]
149
+ for m in migrations:
150
+ if m.version in done and done[m.version] != m.checksum:
151
+ logger.warning(
152
+ "store.migration_checksum_drift",
153
+ version=m.version,
154
+ name=m.name,
155
+ hint="an applied migration file changed; it is NOT re-run",
156
+ )
157
+ for m in pending:
158
+ logger.info("store.migration_apply", version=m.version, name=m.name)
159
+ cur.execute(m.body)
160
+ cur.execute(
161
+ sql.SQL(
162
+ "INSERT INTO {}.schema_migrations (version, name, checksum) "
163
+ "VALUES (%s, %s, %s)"
164
+ ).format(sql.Identifier(self._schema)),
165
+ (m.version, m.name, m.checksum),
166
+ )
167
+ applied_now.append(m.name)
168
+ current = max([m.version for m in migrations] + list(done), default=0)
169
+ if applied_now:
170
+ logger.info("store.migrations_applied", count=len(applied_now), version=current)
171
+ else:
172
+ logger.info("store.migrations_up_to_date", version=current)
173
+ return MigrationReport(applied_now, current, len(pending))
174
+
175
+ def schema_version(self) -> int | None:
176
+ """Highest applied migration, or None when the schema was never bootstrapped."""
177
+ conn = self.connect()
178
+ try:
179
+ with conn.cursor() as cur:
180
+ cur.execute("SELECT to_regclass(%s) AS t", (f"{self._schema}.schema_migrations",))
181
+ row = cur.fetchone()
182
+ if row is None or row["t"] is None:
183
+ return None
184
+ cur.execute(
185
+ sql.SQL(
186
+ "SELECT COALESCE(MAX(version), 0) AS v FROM {}.schema_migrations"
187
+ ).format(sql.Identifier(self._schema))
188
+ )
189
+ row = cur.fetchone()
190
+ return int(row["v"]) if row else 0
191
+ finally:
192
+ conn.rollback()
193
+
194
+ # -- repositories / workflows ------------------------------------------------
195
+
196
+ def upsert_repositories(self, repos: Iterable[Repository]) -> int:
197
+ rows = [
198
+ (
199
+ r.id,
200
+ r.owner,
201
+ r.name,
202
+ r.full_name,
203
+ r.default_branch,
204
+ r.private,
205
+ r.archived,
206
+ r.html_url,
207
+ )
208
+ for r in repos
209
+ ]
210
+ if not rows:
211
+ return 0
212
+ conn = self.connect()
213
+ with conn.transaction(), conn.cursor() as cur:
214
+ cur.executemany(
215
+ """
216
+ INSERT INTO repositories
217
+ (id, owner, name, full_name, default_branch, private, archived, html_url)
218
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
219
+ ON CONFLICT (id) DO UPDATE SET
220
+ owner = EXCLUDED.owner,
221
+ name = EXCLUDED.name,
222
+ full_name = EXCLUDED.full_name,
223
+ default_branch = EXCLUDED.default_branch,
224
+ private = EXCLUDED.private,
225
+ archived = EXCLUDED.archived,
226
+ html_url = EXCLUDED.html_url,
227
+ last_seen_at = now()
228
+ """,
229
+ rows,
230
+ )
231
+ return len(rows)
232
+
233
+ def upsert_workflows(self, workflows: Iterable[Workflow]) -> int:
234
+ rows = [
235
+ (
236
+ w.id,
237
+ w.repository_id,
238
+ w.name,
239
+ w.path,
240
+ w.state,
241
+ w.html_url,
242
+ w.created_at,
243
+ w.updated_at,
244
+ )
245
+ for w in workflows
246
+ ]
247
+ if not rows:
248
+ return 0
249
+ conn = self.connect()
250
+ with conn.transaction(), conn.cursor() as cur:
251
+ cur.executemany(
252
+ """
253
+ INSERT INTO workflows
254
+ (id, repository_id, name, path, state, html_url, created_at, updated_at)
255
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
256
+ ON CONFLICT (id) DO UPDATE SET
257
+ repository_id = EXCLUDED.repository_id,
258
+ name = EXCLUDED.name,
259
+ path = EXCLUDED.path,
260
+ state = EXCLUDED.state,
261
+ html_url = EXCLUDED.html_url,
262
+ created_at = EXCLUDED.created_at,
263
+ updated_at = EXCLUDED.updated_at,
264
+ last_seen_at = now()
265
+ """,
266
+ rows,
267
+ )
268
+ return len(rows)
269
+
270
+ def set_workflow_schedules(
271
+ self, workflow_id: int, schedules: list[str], interval_seconds: float | None
272
+ ) -> None:
273
+ conn = self.connect()
274
+ with conn.transaction(), conn.cursor() as cur:
275
+ cur.execute(
276
+ """
277
+ UPDATE workflows
278
+ SET schedules = %s, schedule_interval_seconds = %s, schedules_synced_at = now()
279
+ WHERE id = %s
280
+ """,
281
+ (schedules, interval_seconds, workflow_id),
282
+ )
283
+
284
+ def workflows_needing_schedule_sync(self, older_than: datetime) -> list[dict[str, Any]]:
285
+ conn = self.connect()
286
+ try:
287
+ with conn.cursor() as cur:
288
+ cur.execute(
289
+ """
290
+ SELECT w.id, w.path, w.repository_id, r.full_name, r.default_branch
291
+ FROM workflows w
292
+ JOIN repositories r ON r.id = w.repository_id
293
+ WHERE w.schedules_synced_at IS NULL OR w.schedules_synced_at < %s
294
+ ORDER BY w.schedules_synced_at NULLS FIRST, w.id
295
+ """,
296
+ (older_than,),
297
+ )
298
+ return list(cur.fetchall())
299
+ finally:
300
+ conn.rollback()
301
+
302
+ def list_repositories(self) -> list[Repository]:
303
+ conn = self.connect()
304
+ try:
305
+ with conn.cursor() as cur:
306
+ cur.execute(
307
+ "SELECT id, owner, name, full_name, default_branch, private, archived, html_url "
308
+ "FROM repositories ORDER BY full_name"
309
+ )
310
+ return [
311
+ Repository(
312
+ id=int(r["id"]),
313
+ owner=str(r["owner"]),
314
+ name=str(r["name"]),
315
+ full_name=str(r["full_name"]),
316
+ default_branch=str(r["default_branch"]),
317
+ private=bool(r["private"]),
318
+ archived=bool(r["archived"]),
319
+ html_url=str(r["html_url"]),
320
+ )
321
+ for r in cur.fetchall()
322
+ ]
323
+ finally:
324
+ conn.rollback()
325
+
326
+ # -- runs / jobs -----------------------------------------------------------------
327
+
328
+ def upsert_runs(self, runs: Iterable[WorkflowRun]) -> int:
329
+ rows = [
330
+ (
331
+ r.id,
332
+ r.repository_id,
333
+ r.workflow_id,
334
+ r.run_number,
335
+ r.run_attempt,
336
+ r.name,
337
+ r.display_title,
338
+ r.event,
339
+ r.status,
340
+ r.conclusion,
341
+ r.head_branch,
342
+ r.head_sha,
343
+ r.actor,
344
+ r.triggering_actor,
345
+ r.created_at,
346
+ r.updated_at,
347
+ r.run_started_at,
348
+ r.updated_at if r.status == "completed" else None,
349
+ r.html_url,
350
+ )
351
+ for r in runs
352
+ ]
353
+ if not rows:
354
+ return 0
355
+ conn = self.connect()
356
+ with conn.transaction(), conn.cursor() as cur:
357
+ cur.executemany(
358
+ """
359
+ INSERT INTO workflow_runs
360
+ (id, repository_id, workflow_id, run_number, run_attempt, name,
361
+ display_title, event, status, conclusion, head_branch, head_sha, actor,
362
+ triggering_actor, created_at, updated_at, run_started_at, completed_at,
363
+ html_url)
364
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
365
+ %s, %s)
366
+ ON CONFLICT (id) DO UPDATE SET
367
+ repository_id = EXCLUDED.repository_id,
368
+ workflow_id = EXCLUDED.workflow_id,
369
+ run_number = EXCLUDED.run_number,
370
+ run_attempt = EXCLUDED.run_attempt,
371
+ name = EXCLUDED.name,
372
+ display_title = EXCLUDED.display_title,
373
+ event = EXCLUDED.event,
374
+ status = EXCLUDED.status,
375
+ conclusion = EXCLUDED.conclusion,
376
+ head_branch = EXCLUDED.head_branch,
377
+ head_sha = EXCLUDED.head_sha,
378
+ actor = EXCLUDED.actor,
379
+ triggering_actor = EXCLUDED.triggering_actor,
380
+ updated_at = EXCLUDED.updated_at,
381
+ run_started_at = EXCLUDED.run_started_at,
382
+ -- keep the job-derived completion when we already have one
383
+ completed_at = COALESCE(workflow_runs.completed_at, EXCLUDED.completed_at),
384
+ html_url = EXCLUDED.html_url,
385
+ -- a run that changed since the last jobs sync needs its jobs again
386
+ jobs_synced_at = CASE
387
+ WHEN workflow_runs.updated_at IS DISTINCT FROM EXCLUDED.updated_at
388
+ OR workflow_runs.status IS DISTINCT FROM EXCLUDED.status
389
+ THEN NULL ELSE workflow_runs.jobs_synced_at END
390
+ """,
391
+ rows,
392
+ )
393
+ return len(rows)
394
+
395
+ def upsert_jobs(self, run_id: int, jobs: Sequence[WorkflowJob]) -> int:
396
+ rows = [
397
+ (
398
+ j.id,
399
+ j.run_id,
400
+ j.repository_id,
401
+ j.run_attempt,
402
+ j.name,
403
+ j.status,
404
+ j.conclusion,
405
+ j.runner_name,
406
+ j.runner_group_name,
407
+ j.labels,
408
+ j.created_at,
409
+ j.started_at,
410
+ j.completed_at,
411
+ j.steps,
412
+ j.html_url,
413
+ )
414
+ for j in jobs
415
+ ]
416
+ conn = self.connect()
417
+ with conn.transaction(), conn.cursor() as cur:
418
+ if rows:
419
+ cur.executemany(
420
+ """
421
+ INSERT INTO workflow_jobs
422
+ (id, run_id, repository_id, run_attempt, name, status, conclusion,
423
+ runner_name, runner_group_name, labels, created_at, started_at,
424
+ completed_at, steps, html_url)
425
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
426
+ ON CONFLICT (id) DO UPDATE SET
427
+ run_attempt = EXCLUDED.run_attempt,
428
+ name = EXCLUDED.name,
429
+ status = EXCLUDED.status,
430
+ conclusion = EXCLUDED.conclusion,
431
+ runner_name = EXCLUDED.runner_name,
432
+ runner_group_name = EXCLUDED.runner_group_name,
433
+ labels = EXCLUDED.labels,
434
+ created_at = EXCLUDED.created_at,
435
+ started_at = EXCLUDED.started_at,
436
+ completed_at = EXCLUDED.completed_at,
437
+ steps = EXCLUDED.steps,
438
+ html_url = EXCLUDED.html_url,
439
+ ingested_at = now()
440
+ """,
441
+ rows,
442
+ )
443
+ # Completion time of the run = last job to finish, when every job finished.
444
+ cur.execute(
445
+ """
446
+ UPDATE workflow_runs r
447
+ SET jobs_synced_at = now(),
448
+ completed_at = CASE
449
+ WHEN r.status = 'completed' THEN COALESCE(
450
+ (SELECT MAX(j.completed_at) FROM workflow_jobs j
451
+ WHERE j.run_id = r.id
452
+ AND NOT EXISTS (SELECT 1 FROM workflow_jobs k
453
+ WHERE k.run_id = r.id AND k.completed_at IS NULL)),
454
+ r.completed_at, r.updated_at)
455
+ ELSE NULL END
456
+ WHERE r.id = %s
457
+ """,
458
+ (run_id,),
459
+ )
460
+ return len(rows)
461
+
462
+ def runs_needing_jobs(self, repository_id: int, limit: int = 5000) -> list[int]:
463
+ conn = self.connect()
464
+ try:
465
+ with conn.cursor() as cur:
466
+ cur.execute(
467
+ """
468
+ SELECT id FROM workflow_runs
469
+ WHERE repository_id = %s AND jobs_synced_at IS NULL
470
+ ORDER BY created_at DESC LIMIT %s
471
+ """,
472
+ (repository_id, limit),
473
+ )
474
+ return [int(r["id"]) for r in cur.fetchall()]
475
+ finally:
476
+ conn.rollback()
477
+
478
+ def open_runs_before(self, repository_id: int, before: datetime, limit: int) -> list[int]:
479
+ """Runs still not completed that fall outside the lookback window."""
480
+ if limit <= 0:
481
+ return []
482
+ conn = self.connect()
483
+ try:
484
+ with conn.cursor() as cur:
485
+ cur.execute(
486
+ """
487
+ SELECT id FROM workflow_runs
488
+ WHERE repository_id = %s AND status <> 'completed' AND created_at < %s
489
+ ORDER BY created_at DESC LIMIT %s
490
+ """,
491
+ (repository_id, before, limit),
492
+ )
493
+ return [int(r["id"]) for r in cur.fetchall()]
494
+ finally:
495
+ conn.rollback()
496
+
497
+ # -- cursors -------------------------------------------------------------------
498
+
499
+ def get_cursor(self, repository_id: int) -> datetime | None:
500
+ conn = self.connect()
501
+ try:
502
+ with conn.cursor() as cur:
503
+ cur.execute(
504
+ "SELECT runs_created_since FROM ingest_cursors WHERE repository_id = %s",
505
+ (repository_id,),
506
+ )
507
+ row = cur.fetchone()
508
+ if row is None:
509
+ return None
510
+ value = row["runs_created_since"]
511
+ return value if isinstance(value, datetime) else None
512
+ finally:
513
+ conn.rollback()
514
+
515
+ def set_cursor(self, repository_id: int, since: datetime, runs: int) -> None:
516
+ conn = self.connect()
517
+ with conn.transaction(), conn.cursor() as cur:
518
+ cur.execute(
519
+ """
520
+ INSERT INTO ingest_cursors (repository_id, runs_created_since, last_cycle_at,
521
+ last_cycle_runs)
522
+ VALUES (%s, %s, now(), %s)
523
+ ON CONFLICT (repository_id) DO UPDATE SET
524
+ runs_created_since = EXCLUDED.runs_created_since,
525
+ last_cycle_at = now(),
526
+ last_cycle_runs = EXCLUDED.last_cycle_runs
527
+ """,
528
+ (repository_id, since, runs),
529
+ )
530
+
531
+ # -- read models for metrics ---------------------------------------------------------
532
+
533
+ def scheduled_workflow_status(self) -> list[dict[str, Any]]:
534
+ """One row per workflow with a cron schedule: last scheduled run + interval."""
535
+ conn = self.connect()
536
+ try:
537
+ with conn.cursor() as cur:
538
+ cur.execute(
539
+ """
540
+ SELECT r.full_name AS repository, w.path, w.name,
541
+ w.schedule_interval_seconds AS interval_seconds,
542
+ (SELECT MAX(run.created_at) FROM workflow_runs run
543
+ WHERE run.workflow_id = w.id AND run.event = 'schedule')
544
+ AS last_scheduled_run_at,
545
+ (SELECT run.conclusion FROM workflow_runs run
546
+ WHERE run.workflow_id = w.id AND run.event = 'schedule'
547
+ AND run.status = 'completed'
548
+ ORDER BY run.created_at DESC LIMIT 1) AS last_conclusion
549
+ FROM workflows w
550
+ JOIN repositories r ON r.id = w.repository_id
551
+ WHERE cardinality(w.schedules) > 0 AND w.state = 'active'
552
+ ORDER BY r.full_name, w.path
553
+ """
554
+ )
555
+ return list(cur.fetchall())
556
+ finally:
557
+ conn.rollback()
558
+
559
+ def counts(self) -> dict[str, int]:
560
+ conn = self.connect()
561
+ try:
562
+ with conn.cursor() as cur:
563
+ cur.execute(
564
+ """
565
+ SELECT (SELECT count(*) FROM repositories) AS repositories,
566
+ (SELECT count(*) FROM workflows) AS workflows,
567
+ (SELECT count(*) FROM workflow_runs) AS runs,
568
+ (SELECT count(*) FROM workflow_runs WHERE status <> 'completed')
569
+ AS open_runs,
570
+ (SELECT count(*) FROM workflow_jobs) AS jobs
571
+ """
572
+ )
573
+ row = cur.fetchone() or {}
574
+ return {k: int(v) for k, v in row.items()}
575
+ finally:
576
+ conn.rollback()
577
+
578
+
579
+ def utcnow() -> datetime:
580
+ return datetime.now(UTC)
@@ -0,0 +1,83 @@
1
+ """Extract ``on.schedule`` cron expressions from a workflow file.
2
+
3
+ Only the trigger block is inspected; the rest of the workflow is ignored.
4
+ YAML 1.1 parses the bare key ``on`` as the boolean ``True`` (PyYAML follows
5
+ that spec), so both spellings are looked up.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from itertools import pairwise
12
+ from typing import Any
13
+
14
+ import yaml
15
+ from croniter import croniter
16
+
17
+
18
+ def parse_schedules(workflow_yaml: str) -> list[str]:
19
+ """Return the cron expressions declared under ``on.schedule``.
20
+
21
+ Returns an empty list for workflows without a schedule trigger and for
22
+ files that do not parse (a broken workflow never runs anyway).
23
+ """
24
+ try:
25
+ doc = yaml.safe_load(workflow_yaml)
26
+ except yaml.YAMLError:
27
+ return []
28
+ if not isinstance(doc, dict):
29
+ return []
30
+ triggers: Any = doc.get("on")
31
+ if triggers is None:
32
+ triggers = doc.get(True)
33
+ if not isinstance(triggers, dict):
34
+ return []
35
+ schedule = triggers.get("schedule")
36
+ if not isinstance(schedule, list):
37
+ return []
38
+ crons: list[str] = []
39
+ for entry in schedule:
40
+ if isinstance(entry, dict):
41
+ cron = entry.get("cron")
42
+ if isinstance(cron, str) and cron.strip():
43
+ crons.append(" ".join(cron.split()))
44
+ return crons
45
+
46
+
47
+ def expected_interval_seconds(
48
+ crons: list[str],
49
+ horizon_days: int = 800,
50
+ max_fires: int = 20000,
51
+ now: float | None = None,
52
+ ) -> float | None:
53
+ """Longest legitimate silence between two consecutive firings.
54
+
55
+ All valid expressions are merged into a single timeline (a workflow
56
+ with ``0 8 * * *`` and ``0 20 * * *`` fires twice a day) and the largest
57
+ gap between neighbours over the horizon is returned. That is the number
58
+ an alert must compare the last run against: for ``0 9 * * 1-5`` the
59
+ answer is 72h (Friday to Monday), not 24h -- using the shortest gap would
60
+ page every weekend.
61
+
62
+ Returns None when no expression is valid or fewer than two firings fall
63
+ inside the horizon.
64
+ """
65
+ base = time.time() if now is None else now
66
+ limit = base + horizon_days * 86400
67
+ fires: list[float] = []
68
+ for cron in crons:
69
+ if not croniter.is_valid(cron):
70
+ continue
71
+ it = croniter(cron, start_time=base)
72
+ count = 0
73
+ while count < max_fires:
74
+ nxt = it.get_next(float)
75
+ if nxt > limit:
76
+ break
77
+ fires.append(nxt)
78
+ count += 1
79
+ if len(fires) < 2:
80
+ return None
81
+ fires.sort()
82
+ gaps = (b - a for a, b in pairwise(fires) if b > a)
83
+ return max(gaps, default=None)