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.
- github_actions_ingester/__init__.py +9 -0
- github_actions_ingester/__main__.py +330 -0
- github_actions_ingester/app_manifest.py +97 -0
- github_actions_ingester/collector.py +338 -0
- github_actions_ingester/config.py +290 -0
- github_actions_ingester/github.py +585 -0
- github_actions_ingester/metrics.py +184 -0
- github_actions_ingester/migrations/0001_initial.sql +140 -0
- github_actions_ingester/ratelimit.py +39 -0
- github_actions_ingester/server.py +115 -0
- github_actions_ingester/store.py +580 -0
- github_actions_ingester/workflow_schedule.py +83 -0
- github_actions_ingester-0.1.0.dist-info/METADATA +522 -0
- github_actions_ingester-0.1.0.dist-info/RECORD +17 -0
- github_actions_ingester-0.1.0.dist-info/WHEEL +4 -0
- github_actions_ingester-0.1.0.dist-info/entry_points.txt +2 -0
- github_actions_ingester-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|