Conversation
|
Rebased onto main (picking up the CI workflow and clippy fixes) and pushed two fixes for review findings: 1. Rate-limit backoff never triggered. The retry arm in 2. One writer-task DB error caused a permanent, silent livelock. Verified locally: |
|
Reviewed the concurrency implementation against issue #5's acceptance criteria and found two problems, both fixed in 6b24519. 1. Retry after a transient error could double-count messages (violated the "results match a sequential run" criterion) The seen-check (read pool) and the seen-mark (writer task commit) are separated by the mpsc queue. When Fixed on two levels:
As a side effect, the schema is now created automatically on startup (resolving the "add DB schema" TODO), so the manual 2. Concurrency limit was bounded but not configurable (first acceptance criterion asks for "a bounded, configurable concurrency limit")
Verified: |
|
Fixed two startup issues found in review (commit a703df2):
|
|
Automated fix pass for the reported merge conflict: Findings
Verification (on
No code changes were required, so nothing new was committed or pushed. |
|
Addressed a remaining gap against issue #5 acceptance criterion 4 (commit 8a7f88f): Found: The rate-limiter tick and exponential-backoff retry were applied only to Fixed:
Data integrity was already safe across restarts (writer quiesce + UNIQUE index); this was purely a robustness gap. |
|
Follow-up fix pushed (cbd0b2c): the final-failure exit path now quiesces the DB writer instead of returning immediately. Problem found: in
Fix: the bail path now drops Verified: |
|
Fixed a remaining reliability gap in the retry handling (commit 02c834c). Found: Per-call retry with backoff existed only for rate-limit responses — the retry arm in both Fixed:
Verified: |
…er DB task Resolves the TODOs at src/main.rs:23, :24, and :127 by re-enabling the concurrent fetch path attempted in 14f9d60 and reverted in 0be8d53, with the SQLite deadlock class removed: - Enable WAL journal mode + Synchronous::Normal and a 5s busy_timeout (previously stubbed out as comments). - Route all DB writes through a single writer task fed by an mpsc channel; fetch tasks send (message_id, sender) and the writer owns the only write connection, so writers can never deadlock each other. The pool is now a small read-only pool sized to the fetch concurrency instead of max_connections(100). - Fetch messages concurrently in parse_messages() via try_for_each_concurrent with a bound of 8, replacing the dead commented-out task::spawn scaffolding. - Rate-limit messages_get client-side with an interval-based token (25ms spacing, ~40 req/sec, under Gmail's ~250 units/sec at 5 units per get) and retry individual messages on 429/403 with exponential backoff (1s base, up to 5 retries) instead of aborting the run. - mark_seen/increment_sender_mails now take the extracted message_id/sender so results can cross the channel; counting logic is unchanged, so sender totals match a sequential run.
Two fixes on top of the concurrent-fetch work: 1. The backoff arm in fetch_message only matched Error::Failure, but google-apis-common returns Error::BadRequest(json) whenever the error body parses as JSON - which Gmail's 429/403 rate-limit responses always do (they carry the standard Google JSON error envelope). Rate limits therefore skipped the backoff entirely and failed the whole scan. Extract an is_rate_limit_error() helper that recognizes both variants: BadRequest by error.code == 429 / status == RESOURCE_EXHAUSTED / rateLimitExceeded reasons, Failure by HTTP status. 2. A single db_writer error permanently closed the write channel, so every subsequent work() pass failed with "DB writer task closed unexpectedly" while the outer loop - whose retries counter was never incremented - re-listed the whole mailbox forever without ever reporting the underlying DB error. Now the loop actually counts retries (bailing with the last error after 3), and when the writer task has died it prints the root-cause error and spawns a fresh writer so a transient SQLite error (e.g. "database is locked") can succeed on retry.
Two fixes for review feedback on the concurrent-fetching PR:
1. Retry after a transient error could double-count messages. The
seen-check (read pool) and the seen-mark (writer task commit) are
separated by the mpsc queue, and the outer retry loop restarted
work() without waiting for the writer to drain, so a message that
was queued but not yet committed read as unseen on the re-list and
was fetched, sent, and counted a second time. Fixed on two levels:
- The retry loop now quiesces the writer before every retry: it
closes the channel, awaits the writer task (draining and
committing the full backlog), then spawns a fresh writer. This
also subsumes the old writer-died restart path.
- Replays are now idempotent at the DB layer: seen_mails.mail_id
gets a UNIQUE index (created on startup, after collapsing any
pre-existing duplicate rows), mark_seen uses INSERT OR IGNORE,
and the sender counter is only incremented when the row was
actually inserted. This also closes the smaller window where a
message id appearing on two consecutive list pages could be
double-counted within a single run.
The schema is now created automatically on startup (resolving the
"add DB schema" TODO), so the manual sqlite3 setup step in the
README is gone.
2. The concurrency limit was bounded but not configurable, missing
part of issue #5's first acceptance criterion. FETCH_CONCURRENCY
and the rate-limit interval are now runtime-configurable via the
GMAIL_STATS_FETCH_CONCURRENCY and GMAIL_STATS_RATE_LIMIT_MS env
vars (defaults unchanged: 8 and 25ms), so users with lower
per-user Gmail quotas can tune the request rate without
rebuilding.
- Add .create_if_missing(true) to the SQLite connect options so the database file is created automatically on first run, matching the README's claim that no manual schema setup is needed. Previously a fresh checkout failed with "(code: 14) unable to open database file" before init_schema ever ran. - Validate GMAIL_STATS_RATE_LIMIT_MS >= 1 with anyhow::ensure!, mirroring the GMAIL_STATS_FETCH_CONCURRENCY check. Setting it to 0 previously hit tokio::time::interval's non-zero-period assertion and panicked at startup instead of failing with a clean error. Document the minimum in the README.
The interval tick and exponential-backoff retry previously applied only to messages_get (fetch_message); the messages_list calls in work() used plain doit().await?, so a 429/rateLimitExceeded on any list call propagated into main's outer loop and restarted the whole mailbox scan from page one — exactly what issue #5 criterion 4 forbids. The outer loop also retried immediately with no sleep, so a sustained quota exhaustion burned all 4 retries in seconds and aborted with "too many retries". - Add list_messages(): waits for a rate-limiter tick before each attempt and retries rate-limit responses with exponential backoff, mirroring fetch_message; work() now pages through it in a single loop. - Add exponential backoff (2s/4s/8s) between outer-loop retries so errors that outlast the per-call retries no longer trigger back-to-back full-mailbox restarts. Data integrity was already covered by the writer quiesce and UNIQUE index; this closes the criterion-4 robustness gap.
The terminal bail (`retries > 3`) returned before the writer quiesce block, so the runtime shutdown could cancel the db_writer task with up to a channel's worth of fetched results still queued (wasted Gmail quota on the next run), and when the writer itself was the failure, its root-cause SQLite error was never awaited — the user saw only the opaque "DB writer task closed unexpectedly" send error. Now the bail path closes the channel, awaits the writer to drain and commit queued results, and attaches the writer's own error (or panic) to the returned error chain.
…dget on progress Per-call backoff-retry previously covered only rate-limit responses (429/RESOURCE_EXHAUSTED/rateLimitExceeded). Every other transient error -- Google's sporadic 503/UNAVAILABLE JSON errors, 500s, and network-level connection resets or TLS hiccups -- propagated out of work(), cancelled the page, and restarted the mailbox scan from page one. Because the outer loop's retries counter was cumulative over the whole process lifetime, the 4th such sporadic failure anywhere in a multi-hour scan aborted the run with "too many retries" -- a regression versus main, which retried this class indefinitely. Two fixes: - Broaden the per-call retry predicate (now is_retryable_error) to also cover retryable server errors (5xx codes, UNAVAILABLE/INTERNAL statuses, backendError reason, and non-JSON 5xx responses) and network-level transients (Error::HttpError, Error::Io), so they back off and retry in place instead of restarting the scan. - Reset the outer retry counter whenever a work() attempt makes forward progress (completes at least one page), so the abort cap applies to consecutive no-progress failures rather than accumulating across the entire run.
…d tests Rebasing onto main replayed the concurrency rework over three PRs that also changed main.rs; this restores their improvements on top of the concurrent architecture: - get_sender: single borrow-only case-insensitive pass over a header priority list (from, return-path), no clones, no panic on valueless headers (PR #6). - Error classification: unify the per-call is_retryable_error with the outer loop's is_transient. is_transient walks the anyhow chain and recognizes SQLite busy/locked and PoolTimedOut; is_transient_gmail keeps the union of both sides' Gmail coverage (JSON envelope 429/5xx, 403 rate-limit reasons and RESOURCE_EXHAUSTED, non-JSON Failure fallback, network-level errors) and remains the per-call retry predicate (PR #7). - Outer retry loop: fail fast on non-transient errors (consulting the recovered writer error, since a dead writer surfaces to work() only as a channel-closed send error), resume from the last fully processed page via a resume token instead of re-listing from page one, and use capped exponential backoff with jitter (PR #7). The writer quiesce, fresh-writer respawn, and progress-based retry reset are unchanged. - Tests: classification, cleanup_sender, get_sender, and SQLite counting suites (PRs #7, #13), with the DB tests adapted to the new signatures (mark_seen takes an id and reports newly-inserted, increment_sender_mails takes the cleaned sender) and run against the production init_schema DDL including the seen_mails unique index.
Closes #5
What changed
Synchronous::Normalare now actually enabled (they were commented-out stubs), plus a 5sbusy_timeout. All writes go through a single writer task that owns the only write connection, fed by anmpscchannel — fetch tasks send(message_id, sender)and the writer commits one transaction per message. With exactly one writer, SQLite writer-vs-writer deadlocks cannot occur. The pool dropped frommax_connections(100)to a small read-only pool sized to the fetch concurrency (8), used solely for theseen_mailschecks, which WAL allows concurrently with the writer.parse_messages()now fans out viastream::iter(...).try_for_each_concurrent(8, ...)— bounded, no unbounded task-per-message spawns. The dead commented-outtask::spawnscaffolding from 14f9d60/0be8d53 and the stale connect-option comments are deleted.messages_getwaits on a shared 25ms-interval tick (~40 req/sec, under Gmail's ~250 quota units/sec at 5 units permessages.get). A 429/403 response backs off exponentially (1s, 2s, 4s, ... up to 5 retries) and retries just that message rather than crashing or restarting the mailbox scan.seen_mail/mark_seen/increment_sender_mails) is unchanged except for taking the pre-extractedmessage_id/senderstrings so results can cross the channel; each message is still counted exactly once, so totals match a sequential run.Cargo.toml: added tokiosync+timefeatures (mpsc, interval, sleep).Verification
cargo buildsucceeds.cargo clippyreports no new warnings — the sole warning (unused_mutonretries) exists identically onmain.