Remove Instructions/Feedback + lp_profiles; sync retry, purge, mobile fixes (v0.1.0:104)
Removals (net -570 lines): - Delete the Instructions and Feedback (feature_requests) pages + backend. - Retire lp_profiles + investor_type across server, ingest, and seeds; migration 0008 drops both empty tables (a sanctioned one-off exception to never-hard-delete). 0001's lp_profiles ALTER is removed so a fresh DB doesn't break the migration chain (live DBs already applied it). Fixes: - Email sync: a transient timeout no longer terminally parks a mailbox; the scheduler retries 'retrying' each cycle and re-includes errored accounts on an hourly backoff, so stuck mailboxes self-heal. - Mobile Contacts: page through the full directory (server caps 500/page) -- one fetch silently truncated at 720, hiding people from the list and from search. - Mobile email review: clock icon to set a reminder inline; approval cards show date/time. New: - Admin-only purge of soft-deleted rows (Settings -> Admin; type-to-confirm, refuses any row still linked to live data). Tests: 45/45 (adds test_sync_ready + test_purge_soft_deleted). Reviewer pass applied (NULL reminders.contact_id on contact purge). Bumped to v0.1.0:104.
This commit is contained in:
@@ -57,10 +57,20 @@ def _json(v) -> str:
|
||||
# ------------------------------------------------------------------ email_accounts
|
||||
|
||||
def list_sync_ready_accounts(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
# Ready = healthy ('pending'/'active') + transient-failing ('retrying', retried every
|
||||
# cycle for fast recovery) + 'error' accounts whose last attempt was over an hour ago.
|
||||
# The hour-backoff on 'error' means a terminal failure (auth/permanent) self-heals once
|
||||
# the operator fixes it WITHOUT hammering Google, and un-sticks any mailbox parked by the
|
||||
# pre-v0.1.0:104 bug where one timeout dark-listed it forever. (last_synced_at is stamped
|
||||
# on every attempt, success or fail, so it doubles as the last-attempt clock here.)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT * FROM email_accounts "
|
||||
"WHERE sync_enabled = 1 AND sync_status IN ('pending','active') "
|
||||
"WHERE sync_enabled = 1 AND ("
|
||||
" sync_status IN ('pending','active','retrying') "
|
||||
" OR (sync_status = 'error' AND (last_synced_at IS NULL "
|
||||
" OR last_synced_at < datetime('now','-1 hour')))"
|
||||
") "
|
||||
"ORDER BY last_synced_at IS NOT NULL, last_synced_at"
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
@@ -23,6 +23,7 @@ import logging
|
||||
import sqlite3
|
||||
import traceback
|
||||
from typing import Optional
|
||||
from urllib.error import URLError
|
||||
|
||||
from . import attachments as _attach
|
||||
from . import config as _cfg
|
||||
@@ -112,6 +113,15 @@ def sync_account(conn_factory, credential_provider, account,
|
||||
error_str = "history expired; fallback to date backfill"
|
||||
status = "partial"
|
||||
_fallback_date_backfill(conn_factory, client, account, index, run_stats)
|
||||
except (_errors.RateLimitError, _errors.TransientError, URLError, TimeoutError) as e:
|
||||
# A network / 5xx / rate-limit error that outlived the in-pass retry loop.
|
||||
# This is TRANSIENT, not terminal: park it as 'retrying' (which the scheduler
|
||||
# still picks up every cycle) instead of 'error' (which it excludes). Fixes the
|
||||
# v<=0.1.0:103 bug where a single timeout dark-listed a mailbox until a manual
|
||||
# kick. Terminal causes (auth, permanent, unexpected) still fall through to 'error'.
|
||||
error_str = f"transient: {type(e).__name__}: {e}"
|
||||
status = "retrying"
|
||||
log.warning("transient error during sync of %s: %s", email_addr, e)
|
||||
except Exception as e:
|
||||
error_str = f"unexpected: {type(e).__name__}: {e}"
|
||||
status = "error"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression test for list_sync_ready_accounts (v0.1.0:104).
|
||||
|
||||
Guards the Bug-B fix: a transient network timeout used to flip a mailbox to terminal
|
||||
sync_status='error', which the old `IN ('pending','active')` filter excluded forever —
|
||||
so a single blip dark-listed a mailbox until a manual kick. The new filter:
|
||||
* always includes 'pending' / 'active' / 'retrying' (the transient-retry state), and
|
||||
* re-includes 'error' accounts whose last attempt was over an hour ago (gentle backoff,
|
||||
so a fixed credential self-heals and the pre-fix stuck mailboxes recover on deploy).
|
||||
Synthetic data only (guardrail #9).
|
||||
Run: cd backend && python3 email_integration/test_sync_ready.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from email_integration import db as edb # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
def make_account(conn, email, status, *, enabled=1, last_synced_sql="NULL"):
|
||||
aid = edb.upsert_account(conn, user_id="u-" + email,
|
||||
email_address=email, auth_method="dwd")
|
||||
conn.execute(
|
||||
f"UPDATE email_accounts SET sync_status=?, sync_enabled=?, "
|
||||
f"last_synced_at={last_synced_sql} WHERE id=?",
|
||||
(status, enabled, aid),
|
||||
)
|
||||
conn.commit()
|
||||
return aid
|
||||
|
||||
|
||||
def main():
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
edb.apply_migrations(conn.cursor())
|
||||
|
||||
make_account(conn, "active@t.xyz", "active", last_synced_sql="datetime('now','-5 minutes')")
|
||||
make_account(conn, "retrying@t.xyz", "retrying", last_synced_sql="datetime('now','-5 minutes')")
|
||||
make_account(conn, "pending@t.xyz", "pending", last_synced_sql="NULL")
|
||||
make_account(conn, "error_stale@t.xyz", "error", last_synced_sql="datetime('now','-2 hours')")
|
||||
make_account(conn, "error_recent@t.xyz", "error", last_synced_sql="datetime('now','-5 minutes')")
|
||||
make_account(conn, "error_neversync@t.xyz", "error", last_synced_sql="NULL")
|
||||
make_account(conn, "disabled@t.xyz", "active", enabled=0, last_synced_sql="datetime('now','-5 minutes')")
|
||||
|
||||
ready = {r["email_address"] for r in edb.list_sync_ready_accounts(conn)}
|
||||
|
||||
check("active@t.xyz" in ready, "healthy 'active' is ready")
|
||||
check("retrying@t.xyz" in ready, "transient 'retrying' is ready (fast retry)")
|
||||
check("pending@t.xyz" in ready, "'pending' is ready")
|
||||
check("error_stale@t.xyz" in ready, "'error' last attempted >1h ago is ready (backoff elapsed → recovers stuck mailbox)")
|
||||
check("error_neversync@t.xyz" in ready, "'error' never synced (NULL last attempt) is ready")
|
||||
check("error_recent@t.xyz" not in ready, "'error' attempted <1h ago is NOT ready (gentle backoff, no hammering)")
|
||||
check("disabled@t.xyz" not in ready, "sync_enabled=0 is never ready")
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"{len(FAILS)} FAILED")
|
||||
sys.exit(1)
|
||||
print("ALL PASS (sync ready filter)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user