retry-failed: only pick open errors and mark them as retried
"Fehlgeschlagene neu einlesen" reported the same 29 mails on every click: it selected all error log entries ever written and never noted that one had already been retried. Error log entries now get retried_at when they are picked up; the endpoint only considers entries without it. If the mail fails again, the scheduler writes a new, open error entry, so a second click right after the first reports 0. The log shows retried errors greyed out with "↻ erneut versucht <Zeit>", and the result dialog says how many open error entries and mails were requeued. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"""add retried_at to filter_logs
|
||||
|
||||
Revision ID: b4e8d2f61a07
|
||||
Revises: 9a3f5c1d7e42
|
||||
Create Date: 2026-09-29 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "b4e8d2f61a07"
|
||||
down_revision: Union[str, Sequence[str], None] = "9a3f5c1d7e42"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Markiert Fehler-Einträge, die bereits erneut angestoßen wurden, damit
|
||||
'Fehlgeschlagene neu einlesen' nicht immer wieder dieselben aufgreift.
|
||||
Nullable-Spalte → SQLite kann sie ohne Tabellen-Neuaufbau anhängen."""
|
||||
op.add_column("filter_logs", sa.Column("retried_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("filter_logs", schema=None) as batch_op:
|
||||
batch_op.drop_column("retried_at")
|
||||
@@ -141,6 +141,9 @@ class FilterLog(Base):
|
||||
folder: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
details: Mapped[str | None] = mapped_column(String(2000), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||
# Fehler-Einträge: Zeitpunkt, zu dem "Fehlgeschlagene neu einlesen" die Mail
|
||||
# erneut angestoßen hat. Scheitert sie wieder, entsteht ein neuer Eintrag.
|
||||
retried_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_filter_logs_account_created", "account_id", "created_at"),
|
||||
|
||||
+28
-12
@@ -1,3 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, or_, text
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -92,29 +94,33 @@ def reset_all_processed(db: Session = Depends(get_db)):
|
||||
|
||||
@router.post("/retry-failed")
|
||||
def retry_failed_fetches(account_id: int | None = None, db: Session = Depends(get_db)):
|
||||
"""Löscht ProcessedMail-Einträge für UIDs, bei denen das Abrufen oder eine
|
||||
Aktion (z.B. Verschieben) fehlgeschlagen ist — damit der Scheduler sie beim
|
||||
nächsten Poll neu versucht (z.B. nach einem Fix). Schon verschobene Mails
|
||||
liegen nicht mehr im Quellordner und werden daher nicht doppelt verarbeitet."""
|
||||
"""Stößt Mails, bei denen das Abrufen oder eine Aktion (z.B. Verschieben)
|
||||
fehlgeschlagen ist, für den nächsten Poll erneut an: ihre 'verarbeitet'-
|
||||
Marker werden gelöscht. Schon verschobene Mails liegen nicht mehr im
|
||||
Quellordner und werden daher nicht doppelt verarbeitet.
|
||||
|
||||
Nur offene Fehler zählen: die aufgegriffenen Log-Einträge werden mit
|
||||
retried_at markiert. Scheitert die Mail erneut, entsteht ein neuer,
|
||||
wieder offener Fehler-Eintrag."""
|
||||
try:
|
||||
log_q = db.query(FilterLog.mail_uid, FilterLog.account_id).filter(
|
||||
log_q = db.query(FilterLog.id, FilterLog.mail_uid, FilterLog.account_id).filter(
|
||||
FilterLog.level == LogLevel.ERROR,
|
||||
or_(
|
||||
FilterLog.message.like("Fehler beim Abrufen%"),
|
||||
FilterLog.message.like("Aktion fehlgeschlagen%"),
|
||||
),
|
||||
FilterLog.mail_uid.isnot(None),
|
||||
FilterLog.retried_at.is_(None),
|
||||
)
|
||||
if account_id is not None:
|
||||
log_q = log_q.filter(FilterLog.account_id == account_id)
|
||||
|
||||
pairs = log_q.distinct().all()
|
||||
if not pairs:
|
||||
return {"reset": 0, "unique_uids": 0}
|
||||
rows = log_q.all()
|
||||
if not rows:
|
||||
return {"reset": 0, "unique_uids": 0, "log_entries": 0}
|
||||
|
||||
# Bulk-Delete pro Account: alle UIDs in einem Query
|
||||
by_account: dict[int | None, set[str]] = {}
|
||||
for uid, acc_id in pairs:
|
||||
for _, uid, acc_id in rows:
|
||||
by_account.setdefault(acc_id, set()).add(uid)
|
||||
|
||||
total = 0
|
||||
@@ -123,12 +129,22 @@ def retry_failed_fetches(account_id: int | None = None, db: Session = Depends(ge
|
||||
if acc_id is not None:
|
||||
q = q.filter(ProcessedMail.account_id == acc_id)
|
||||
total += q.delete(synchronize_session=False)
|
||||
|
||||
# Aufgegriffene Fehler als erneut versucht markieren (in Blöcken, SQLite
|
||||
# begrenzt die Zahl der Parameter pro Anweisung)
|
||||
now = datetime.utcnow()
|
||||
ids = [row[0] for row in rows]
|
||||
for i in range(0, len(ids), 500):
|
||||
db.query(FilterLog).filter(FilterLog.id.in_(ids[i:i + 500])).update(
|
||||
{FilterLog.retried_at: now}, synchronize_session=False
|
||||
)
|
||||
db.commit()
|
||||
unique_uids = sum(len(v) for v in by_account.values())
|
||||
logger.info(
|
||||
"Retry-Failed: %d ProcessedMail-Einträge für %d UIDs entfernt", total, unique_uids
|
||||
"Retry-Failed: %d Fehler-Einträge, %d Mails, %d Marker entfernt",
|
||||
len(ids), unique_uids, total,
|
||||
)
|
||||
return {"reset": total, "unique_uids": unique_uids}
|
||||
return {"reset": total, "unique_uids": unique_uids, "log_entries": len(ids)}
|
||||
except Exception as e:
|
||||
logger.exception("retry-failed fehlgeschlagen")
|
||||
raise HTTPException(500, f"Fehler bei retry-failed: {e}")
|
||||
|
||||
@@ -129,6 +129,7 @@ def get_logs(
|
||||
"folder": log.folder,
|
||||
"details": log.details,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
"retried_at": log.retried_at.isoformat() if log.retried_at else None,
|
||||
}
|
||||
for log in rows
|
||||
],
|
||||
|
||||
+16
-4
@@ -116,6 +116,9 @@
|
||||
.logs .l.lv-error { box-shadow:inset 3px 0 0 #dc3545; }
|
||||
.logs .l.lv-warning { box-shadow:inset 3px 0 0 #ffc107; }
|
||||
.logs .l.lv-success { box-shadow:inset 3px 0 0 #28a745; }
|
||||
.logs .l.retried { box-shadow:inset 3px 0 0 #c9ced6; }
|
||||
.logs .l.retried > span:not(.l-main), .logs .l.retried .l-mail { opacity:0.55; }
|
||||
.retried-tag { font-size:0.72rem; font-weight:600; padding:0 0.35rem; border-radius:3px; background:#eef0f3; color:#555; white-space:nowrap; }
|
||||
.l-time { color:var(--pico-muted-color); font-variant-numeric:tabular-nums; white-space:nowrap; font-size:0.78rem; }
|
||||
.l-day { display:block; font-size:0.68rem; opacity:0.8; }
|
||||
.l-rule, .l-folder { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@@ -375,11 +378,11 @@ function renderLogs() {
|
||||
? `<div class="l-mail"><b>${esc(log.mail_subject || '(kein Betreff)')}</b>${log.mail_from ? ` · <a class="flink" onclick="event.stopPropagation(); addFilter('sender', ${jsArg(emailOf(log.mail_from))})" title="Nach diesem Absender filtern">${esc(log.mail_from)}</a>` : ''}</div>`
|
||||
: '';
|
||||
html += `
|
||||
<div class="l lv-${esc(log.level)}" onclick="toggleDetail(this, ${log.id})">
|
||||
<div class="l lv-${esc(log.level)}${log.retried_at ? ' retried' : ''}" onclick="toggleDetail(this, ${log.id})">
|
||||
<span class="l-time">${day ? `<span class="l-day">${day}</span>` : ''}${time}</span>
|
||||
<span title="${esc(log.level)}">${LEVEL_ICON[log.level] || ''}</span>
|
||||
<span class="l-rule">${log.rule_name ? `<a class="flink" onclick="event.stopPropagation(); addFilter('rule', ${jsArg(log.rule_name)})" title="Nach dieser Regel filtern">${esc(log.rule_name)}</a>` : '<span class="muted">—</span>'}</span>
|
||||
<span class="l-main"><div class="l-msg">${esc(shortMessage(log))}</div>${mail}</span>
|
||||
<span class="l-main"><div class="l-msg">${log.retried_at ? `<span class="retried-tag" title="Mit „Fehlgeschlagene neu einlesen“ erneut angestoßen. Scheitert es wieder, erscheint ein neuer Fehler-Eintrag.">↻ erneut versucht ${esc(fmtShort(log.retried_at))}</span> ` : ''}${esc(shortMessage(log))}</div>${mail}</span>
|
||||
<span class="l-folder">${target.filter ? `<a class="flink" onclick="event.stopPropagation(); addFilter('folder', ${jsArg(target.filter)})" title="Nach diesem Ordner filtern">${esc(target.text)}</a>` : esc(target.text)}</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -405,6 +408,7 @@ function toggleDetail(row, id) {
|
||||
['Ordner', log.folder],
|
||||
['Mail-UID', log.mail_uid],
|
||||
['Details', log.details],
|
||||
['Erneut versucht', log.retried_at ? parseUtc(log.retried_at).toLocaleString('de-DE') : ''],
|
||||
].filter(([, v]) => v);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'l-detail';
|
||||
@@ -435,12 +439,20 @@ async function clearLogs() {
|
||||
|
||||
async function retryFailed() {
|
||||
document.querySelector('.head-menu').open = false;
|
||||
if (!confirm('Alle Mails, bei denen Abrufen oder eine Aktion (z.B. Verschieben) fehlgeschlagen ist, für den nächsten Poll neu vormerken?')) return;
|
||||
if (!confirm('Alle offenen Fehler (Abrufen oder Aktion, z.B. Verschieben) beim nächsten Poll erneut versuchen?\n\nDie Einträge werden als „erneut versucht“ markiert. Scheitert eine Mail wieder, erscheint ein neuer Fehler-Eintrag.')) return;
|
||||
const acc = document.getElementById('log-account').value;
|
||||
const resp = await fetch(`/api/filters/retry-failed${acc ? `?account_id=${acc}` : ''}`, {method: 'POST'});
|
||||
const d = await readJson(resp);
|
||||
if (!resp.ok) { alert('Fehler: ' + (d.detail || resp.status)); return; }
|
||||
alert(`${d.unique_uids || 0} Mail(s) neu vorgemerkt. Werden beim nächsten Poll erneut geprüft.`);
|
||||
alert(d.log_entries
|
||||
? `${d.unique_uids} Mail(s) aus ${d.log_entries} offenen Fehler-Einträgen neu vorgemerkt. Sie werden beim nächsten Poll erneut verarbeitet.`
|
||||
: 'Keine offenen Fehler — alle wurden bereits erneut versucht.');
|
||||
reload();
|
||||
}
|
||||
|
||||
function fmtShort(iso) {
|
||||
const d = parseUtc(iso);
|
||||
return d.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit'}) + ' ' + d.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'});
|
||||
}
|
||||
|
||||
function fmtBytes(n) {
|
||||
|
||||
Reference in New Issue
Block a user