Merge branch 'v20260204-02-performance-optimizations' into main

This commit is contained in:
Ivo Oskamp 2026-02-06 13:23:31 +01:00
commit 5ed4c41b80
6 changed files with 398 additions and 72 deletions

View File

@ -1 +1 @@
v20260203-13-autotask-resolution-item-wrapper v20260204-02-performance-optimizations

View File

@ -5,6 +5,212 @@ from .routes_shared import _format_datetime, _get_or_create_settings, _apply_ove
# A job is only marked Missed after the latest expected time plus this grace. # A job is only marked Missed after the latest expected time plus this grace.
MISSED_GRACE_WINDOW = timedelta(hours=1) MISSED_GRACE_WINDOW = timedelta(hours=1)
# Job types that should never participate in schedule inference
_SKIP_SCHEDULE_TYPES = {
("veeam", "license key"),
("synology", "account protection"),
("synology", "updates"),
("qnap", "firmware update"),
("syncovery", "syncovery"),
}
def _batch_infer_schedules(job_ids: list[int], tz) -> dict[int, dict]:
"""Batch infer weekly schedules for multiple jobs in a single query.
Returns dict of job_id -> {weekday: [times]} schedule maps.
"""
MIN_OCCURRENCES = 3
if not job_ids:
return {}
# Load all historical runs for schedule inference in one query
try:
runs = (
JobRun.query
.filter(
JobRun.job_id.in_(job_ids),
JobRun.run_at.isnot(None),
JobRun.missed.is_(False),
JobRun.mail_message_id.isnot(None),
)
.order_by(JobRun.job_id, JobRun.run_at.desc())
.limit(len(job_ids) * 500) # ~500 runs per job max
.all()
)
except Exception:
runs = []
# Group runs by job_id
runs_by_job: dict[int, list] = {jid: [] for jid in job_ids}
for r in runs:
if r.job_id in runs_by_job and len(runs_by_job[r.job_id]) < 500:
runs_by_job[r.job_id].append(r)
# Process each job's runs
result = {}
for job_id in job_ids:
job_runs = runs_by_job.get(job_id, [])
schedule = {i: [] for i in range(7)}
if not job_runs:
result[job_id] = schedule
continue
counts = {i: {} for i in range(7)}
for r in job_runs:
if not r.run_at:
continue
dt = r.run_at
if tz is not None:
try:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime_module.timezone.utc).astimezone(tz)
else:
dt = dt.astimezone(tz)
except Exception:
pass
wd = dt.weekday()
minute_bucket = (dt.minute // 15) * 15
tstr = f"{dt.hour:02d}:{minute_bucket:02d}"
counts[wd][tstr] = int(counts[wd].get(tstr, 0)) + 1
for wd in range(7):
keep = [t for t, c in counts[wd].items() if int(c) >= MIN_OCCURRENCES]
schedule[wd] = sorted(keep)
result[job_id] = schedule
return result
def _batch_infer_monthly_schedules(job_ids: list[int], tz) -> dict[int, dict | None]:
"""Batch infer monthly schedules for multiple jobs.
Returns dict of job_id -> monthly schedule dict or None.
"""
MIN_OCCURRENCES = 3
if not job_ids:
return {}
# Load runs for monthly inference
try:
runs = (
JobRun.query
.filter(
JobRun.job_id.in_(job_ids),
JobRun.run_at.isnot(None),
JobRun.missed.is_(False),
JobRun.mail_message_id.isnot(None),
)
.order_by(JobRun.job_id, JobRun.run_at.asc())
.limit(len(job_ids) * 500)
.all()
)
except Exception:
runs = []
# Group runs by job_id
runs_by_job: dict[int, list] = {jid: [] for jid in job_ids}
for r in runs:
if r.job_id in runs_by_job and len(runs_by_job[r.job_id]) < 500:
runs_by_job[r.job_id].append(r)
result = {}
for job_id in job_ids:
job_runs = runs_by_job.get(job_id, [])
if len(job_runs) < MIN_OCCURRENCES:
result[job_id] = None
continue
# Convert to local time
local_dts = []
for r in job_runs:
if not r.run_at:
continue
dt = r.run_at
if tz is not None:
try:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime_module.timezone.utc).astimezone(tz)
else:
dt = dt.astimezone(tz)
except Exception:
pass
local_dts.append(dt)
if len(local_dts) < MIN_OCCURRENCES:
result[job_id] = None
continue
# Cadence heuristic
local_dts_sorted = sorted(local_dts)
gaps = []
for i in range(1, len(local_dts_sorted)):
try:
delta_days = (local_dts_sorted[i] - local_dts_sorted[i - 1]).total_seconds() / 86400.0
if delta_days > 0:
gaps.append(delta_days)
except Exception:
continue
if gaps:
gaps_sorted = sorted(gaps)
median_gap = gaps_sorted[len(gaps_sorted) // 2]
if median_gap < 20.0:
result[job_id] = None
continue
# Count day-of-month occurrences
dom_counts = {}
time_counts_by_dom = {}
for dt in local_dts:
dom = int(dt.day)
dom_counts[dom] = int(dom_counts.get(dom, 0)) + 1
minute_bucket = (dt.minute // 15) * 15
tstr = f"{int(dt.hour):02d}:{int(minute_bucket):02d}"
if dom not in time_counts_by_dom:
time_counts_by_dom[dom] = {}
time_counts_by_dom[dom][tstr] = int(time_counts_by_dom[dom].get(tstr, 0)) + 1
best_dom = None
best_dom_count = 0
for dom, c in dom_counts.items():
if int(c) >= MIN_OCCURRENCES and int(c) > best_dom_count:
best_dom = int(dom)
best_dom_count = int(c)
if best_dom is None:
result[job_id] = None
continue
time_counts = time_counts_by_dom.get(best_dom) or {}
keep_times = [t for t, c in time_counts.items() if int(c) >= MIN_OCCURRENCES]
if not keep_times:
best_t = None
best_c = 0
for t, c in time_counts.items():
if int(c) > best_c:
best_t = t
best_c = int(c)
if best_t:
keep_times = [best_t]
keep_times = sorted(set(keep_times))
if not keep_times:
result[job_id] = None
continue
result[job_id] = {"day_of_month": int(best_dom), "times": keep_times}
return result
@main_bp.route("/daily-jobs") @main_bp.route("/daily-jobs")
@login_required @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")
@ -30,8 +236,6 @@ def daily_jobs():
missed_start_date = getattr(settings, "daily_jobs_start_date", None) missed_start_date = getattr(settings, "daily_jobs_start_date", None)
# Day window: treat run_at as UTC-naive timestamps stored in UTC (existing behavior) # Day window: treat run_at as UTC-naive timestamps stored in UTC (existing behavior)
# Note: if your DB stores local-naive timestamps, this still works because the same logic
# is used consistently in schedule inference and details.
if tz: if tz:
local_midnight = datetime( local_midnight = datetime(
year=target_date.year, year=target_date.year,
@ -74,6 +278,7 @@ def daily_jobs():
weekday_idx = target_date.weekday() # 0=Mon..6=Sun weekday_idx = target_date.weekday() # 0=Mon..6=Sun
# Load all non-archived jobs with customer eagerly loaded
jobs = ( jobs = (
Job.query.join(Customer, isouter=True) Job.query.join(Customer, isouter=True)
.filter(Job.archived.is_(False)) .filter(Job.archived.is_(False))
@ -81,18 +286,112 @@ def daily_jobs():
.all() .all()
) )
rows = [] # Filter out job types that should skip schedule inference
eligible_jobs = []
for job in jobs: for job in jobs:
schedule_map = _infer_schedule_map_from_runs(job.id) bs = (job.backup_software or '').strip().lower()
bt = (job.backup_type or '').strip().lower()
if (bs, bt) not in _SKIP_SCHEDULE_TYPES:
eligible_jobs.append(job)
job_ids = [j.id for j in eligible_jobs]
# Batch load all today's runs for all jobs in one query
all_runs_today = []
if job_ids:
try:
all_runs_today = (
JobRun.query
.filter(
JobRun.job_id.in_(job_ids),
JobRun.run_at >= start_of_day,
JobRun.run_at < end_of_day,
)
.order_by(JobRun.job_id, JobRun.run_at.asc())
.all()
)
except Exception:
all_runs_today = []
# Group runs by job_id
runs_by_job: dict[int, list] = {jid: [] for jid in job_ids}
for r in all_runs_today:
if r.job_id in runs_by_job:
runs_by_job[r.job_id].append(r)
# Batch infer weekly schedules
schedule_maps = _batch_infer_schedules(job_ids, tz)
# For jobs without weekly schedule, batch infer monthly
jobs_needing_monthly = [
jid for jid in job_ids
if not (schedule_maps.get(jid, {}).get(weekday_idx) or [])
]
monthly_schedules = _batch_infer_monthly_schedules(jobs_needing_monthly, tz) if jobs_needing_monthly else {}
# Batch load ticket indicators
job_has_ticket: dict[int, bool] = {jid: False for jid in job_ids}
job_has_remark: dict[int, bool] = {jid: False for jid in job_ids}
if job_ids:
try:
ticket_job_ids = db.session.execute(
text(
"""
SELECT DISTINCT ts.job_id
FROM tickets t
JOIN ticket_scopes ts ON ts.ticket_id = t.id
WHERE ts.job_id = ANY(:job_ids)
AND t.active_from_date <= :target_date
AND (
t.resolved_at IS NULL
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Amsterdam')::date) >= :target_date
)
"""
),
{"job_ids": job_ids, "target_date": target_date},
).scalars().all()
for jid in ticket_job_ids:
job_has_ticket[jid] = True
except Exception:
pass
try:
remark_job_ids = db.session.execute(
text(
"""
SELECT DISTINCT rs.job_id
FROM remarks r
JOIN remark_scopes rs ON rs.remark_id = r.id
WHERE rs.job_id = ANY(:job_ids)
AND COALESCE(
r.active_from_date,
((r.start_date AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Amsterdam')::date)
) <= :target_date
AND (
r.resolved_at IS NULL
OR ((r.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Amsterdam')::date) >= :target_date
)
"""
),
{"job_ids": job_ids, "target_date": target_date},
).scalars().all()
for jid in remark_job_ids:
job_has_remark[jid] = True
except Exception:
pass
rows = []
for job in eligible_jobs:
schedule_map = schedule_maps.get(job.id, {})
expected_times = schedule_map.get(weekday_idx) or [] expected_times = schedule_map.get(weekday_idx) or []
# If no weekly schedule is inferred (e.g. monthly jobs), try monthly inference. # If no weekly schedule, try monthly
if not expected_times: if not expected_times:
monthly = _infer_monthly_schedule_from_runs(job.id) monthly = monthly_schedules.get(job.id)
if monthly: if monthly:
dom = int(monthly.get("day_of_month") or 0) dom = int(monthly.get("day_of_month") or 0)
mtimes = monthly.get("times") or [] mtimes = monthly.get("times") or []
# For months shorter than dom, treat the last day of month as the scheduled day.
try: try:
import calendar as _calendar import calendar as _calendar
last_dom = _calendar.monthrange(target_date.year, target_date.month)[1] last_dom = _calendar.monthrange(target_date.year, target_date.month)[1]
@ -105,69 +404,14 @@ def daily_jobs():
if not expected_times: if not expected_times:
continue continue
runs_for_day = ( runs_for_day = runs_by_job.get(job.id, [])
JobRun.query.filter(
JobRun.job_id == job.id,
JobRun.run_at >= start_of_day,
JobRun.run_at < end_of_day,
)
.order_by(JobRun.run_at.asc())
.all()
)
run_count = len(runs_for_day) run_count = len(runs_for_day)
customer_name = job.customer.name if job.customer else "" customer_name = job.customer.name if job.customer else ""
# Ticket/Remark indicators for this job on this date # Use pre-loaded ticket/remark indicators
# Tickets: active-from date should apply to subsequent runs until resolved. has_active_ticket = job_has_ticket.get(job.id, False)
has_active_ticket = False has_active_remark = job_has_remark.get(job.id, False)
has_active_remark = False
try:
t_exists = db.session.execute(
text(
"""
SELECT 1
FROM tickets t
JOIN ticket_scopes ts ON ts.ticket_id = t.id
WHERE ts.job_id = :job_id
AND t.active_from_date <= :target_date
AND (
t.resolved_at IS NULL
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Amsterdam')::date) >= :target_date
)
LIMIT 1
"""
),
{"job_id": job.id, "target_date": target_date},
).first()
has_active_ticket = bool(t_exists)
r_exists = db.session.execute(
text(
"""
SELECT 1
FROM remarks r
JOIN remark_scopes rs ON rs.remark_id = r.id
WHERE rs.job_id = :job_id
AND COALESCE(
r.active_from_date,
((r.start_date AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Amsterdam')::date)
) <= :target_date
AND (
r.resolved_at IS NULL
OR ((r.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Amsterdam')::date) >= :target_date
)
LIMIT 1
"""
),
{"job_id": job.id, "target_date": target_date},
).first()
has_active_remark = bool(r_exists)
except Exception:
has_active_ticket = False
has_active_remark = False
# We show a single row per job for today. # We show a single row per job for today.
last_remark_excerpt = "" last_remark_excerpt = ""

View File

@ -534,13 +534,18 @@ def _recompute_override_flags_for_runs(job_ids: list[int] | None = None, start_a
except Exception: except Exception:
runs = [] runs = []
# Batch load all jobs to avoid N+1 queries
job_ids = {run.job_id for run in runs if run.job_id}
jobs_by_id = {}
if job_ids:
try:
jobs_by_id = {j.id: j for j in Job.query.filter(Job.id.in_(job_ids)).all()}
except Exception:
jobs_by_id = {}
updated = 0 updated = 0
for run in runs: for run in runs:
job = None job = jobs_by_id.get(run.job_id)
try:
job = Job.query.get(run.job_id)
except Exception:
job = None
if not job: if not job:
continue continue

View File

@ -930,6 +930,7 @@ def run_migrations() -> None:
migrate_news_tables() migrate_news_tables()
migrate_reporting_tables() migrate_reporting_tables()
migrate_reporting_report_config() migrate_reporting_report_config()
migrate_performance_indexes()
print("[migrations] All migrations completed.") print("[migrations] All migrations completed.")
@ -1114,6 +1115,46 @@ def migrate_jobs_archiving() -> None:
print("[migrations] migrate_jobs_archiving completed.") print("[migrations] migrate_jobs_archiving completed.")
def migrate_performance_indexes() -> None:
"""Add performance indexes for frequently queried foreign key columns.
These indexes significantly improve query performance on slow storage,
especially for Daily Jobs and Run Checks pages.
This migration is idempotent.
"""
try:
engine = db.get_engine()
except Exception as exc:
print(f"[migrations] Could not get engine for performance indexes migration: {exc}")
return
with engine.begin() as conn:
# JobRun indexes
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_run_job_id ON job_runs (job_id)'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_run_job_id_run_at ON job_runs (job_id, run_at)'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_run_job_id_reviewed_at ON job_runs (job_id, reviewed_at)'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_run_mail_message_id ON job_runs (mail_message_id)'))
# MailMessage indexes
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_mail_message_job_id ON mail_messages (job_id)'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_mail_message_location ON mail_messages (location)'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_mail_message_job_id_location ON mail_messages (job_id, location)'))
# MailObject indexes
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_mail_object_mail_message_id ON mail_objects (mail_message_id)'))
# TicketScope indexes
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_ticket_scope_ticket_id ON ticket_scopes (ticket_id)'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_ticket_scope_job_id ON ticket_scopes (job_id)'))
# RemarkScope indexes
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_remark_scope_remark_id ON remark_scopes (remark_id)'))
conn.execute(text('CREATE INDEX IF NOT EXISTS idx_remark_scope_job_id ON remark_scopes (job_id)'))
print("[migrations] migrate_performance_indexes completed.")
def migrate_reporting_report_config() -> None: def migrate_reporting_report_config() -> None:
"""Add report_definitions.report_config column if missing. """Add report_definitions.report_config column if missing.

View File

@ -248,6 +248,12 @@ class Job(db.Model):
class JobRun(db.Model): class JobRun(db.Model):
__tablename__ = "job_runs" __tablename__ = "job_runs"
__table_args__ = (
db.Index("idx_job_run_job_id", "job_id"),
db.Index("idx_job_run_job_id_run_at", "job_id", "run_at"),
db.Index("idx_job_run_job_id_reviewed_at", "job_id", "reviewed_at"),
db.Index("idx_job_run_mail_message_id", "mail_message_id"),
)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -341,6 +347,11 @@ class JobObject(db.Model):
class MailMessage(db.Model): class MailMessage(db.Model):
__tablename__ = "mail_messages" __tablename__ = "mail_messages"
__table_args__ = (
db.Index("idx_mail_message_job_id", "job_id"),
db.Index("idx_mail_message_location", "location"),
db.Index("idx_mail_message_job_id_location", "job_id", "location"),
)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@ -400,6 +411,9 @@ class MailMessage(db.Model):
class MailObject(db.Model): class MailObject(db.Model):
__tablename__ = "mail_objects" __tablename__ = "mail_objects"
__table_args__ = (
db.Index("idx_mail_object_mail_message_id", "mail_message_id"),
)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
mail_message_id = db.Column(db.Integer, db.ForeignKey("mail_messages.id"), nullable=False) mail_message_id = db.Column(db.Integer, db.ForeignKey("mail_messages.id"), nullable=False)
@ -434,6 +448,10 @@ class Ticket(db.Model):
class TicketScope(db.Model): class TicketScope(db.Model):
__tablename__ = "ticket_scopes" __tablename__ = "ticket_scopes"
__table_args__ = (
db.Index("idx_ticket_scope_ticket_id", "ticket_id"),
db.Index("idx_ticket_scope_job_id", "job_id"),
)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False) ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=False)
scope_type = db.Column(db.String(32), nullable=False) scope_type = db.Column(db.String(32), nullable=False)
@ -475,6 +493,10 @@ class Remark(db.Model):
class RemarkScope(db.Model): class RemarkScope(db.Model):
__tablename__ = "remark_scopes" __tablename__ = "remark_scopes"
__table_args__ = (
db.Index("idx_remark_scope_remark_id", "remark_id"),
db.Index("idx_remark_scope_job_id", "job_id"),
)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
remark_id = db.Column(db.Integer, db.ForeignKey("remarks.id"), nullable=False) remark_id = db.Column(db.Integer, db.ForeignKey("remarks.id"), nullable=False)
scope_type = db.Column(db.String(32), nullable=False) scope_type = db.Column(db.String(32), nullable=False)

View File

@ -11,3 +11,17 @@ This file documents all changes made to this project via Claude Code.
- Converted changelog to English (all project documentation must be in English) - Converted changelog to English (all project documentation must be in English)
- Documented branch naming convention and build workflow in Claude memory - Documented branch naming convention and build workflow in Claude memory
- Filled README.md with comprehensive project documentation based on source code analysis - Filled README.md with comprehensive project documentation based on source code analysis
### Performance
- Added database indexes migration (`migrations.py`) for frequently queried foreign key columns:
- `JobRun`: indexes on `job_id`, `job_id+run_at`, `job_id+reviewed_at`, `mail_message_id`
- `MailMessage`: indexes on `job_id`, `location`, `job_id+location`
- `MailObject`: index on `mail_message_id`
- `TicketScope`: indexes on `ticket_id`, `job_id`
- `RemarkScope`: indexes on `remark_id`, `job_id`
- Fixed N+1 query in `_recompute_override_flags_for_runs()` - batch loads all jobs instead of per-run queries
- Optimized Daily Jobs page with batch queries:
- Batch load all today's runs for all jobs in single query
- Batch infer weekly schedules for all jobs (was per-job query)
- Batch infer monthly schedules for jobs without weekly schedule
- Batch load ticket/remark indicators for all jobs