Skip to content

feat(db): scheduler foundation for v0.4.0 auto-publish (PR F1)#69

Merged
thierryvm merged 1 commit into
mainfrom
feat/v0.4.0-scheduler-foundation
May 11, 2026
Merged

feat(db): scheduler foundation for v0.4.0 auto-publish (PR F1)#69
thierryvm merged 1 commit into
mainfrom
feat/v0.4.0-scheduler-foundation

Conversation

@thierryvm

@thierryvm thierryvm commented May 11, 2026

Copy link
Copy Markdown
Owner

v0.4.0 stack — PR F1 / 3

First PR of the auto-publish scheduler per `docs/roadmap-v0.4.md`. Pure data layer + tests — no background task, no UI. Same foundation pattern as #56 (post_groups) used before the multi-network composer wired in.

  1. This PR — migration 019 + `db::scheduler` module + tests
  2. PR F2 — background task `tokio::spawn` + retry policy + notifications
  3. PR F3 — Composer / Dashboard / Calendar integration

Migration 019

`failed_attempts INTEGER NOT NULL DEFAULT 0` + `last_attempt_at TEXT` (nullable). Additive, retro-compat by construction.

db::scheduler module

Item Purpose
`MAX_FAILED_ATTEMPTS = 3` Hard cap before final 'failed' state
`BACKOFF_MINUTES = [0, 5, 30, 120]` Indexed by failed_attempts
`list_due_for_publish` Single SQL with CASE encoding backoff schedule
`try_lock_for_publish` Atomic 'draft'→'publishing' for race prevention
`mark_publish_attempt_failed` Increment counter, flip to 'failed' at threshold
`reset_retry_state` Manual recovery from Calendar reschedule
`PublishFailureOutcome` enum WillRetry vs GaveUp signal for notif routing

Critical bug avoided (would have shipped without tests)

`scheduled_at <= datetime('now')` returned zero rows on first test pass. Root cause: SQLite's `datetime('now')` returns `'YYYY-MM-DD HH:MM:SS'` (space separator, no T, no Z) which sorts lexicographically BELOW `chrono::Utc::now().to_rfc3339()` writes (`'YYYY-MM-DDTHH:MM:SS.fffZ'`) — `T` (0x54) > space (0x20). Same wall-clock instant, different string comparison result.

Fix: bind `Utc::now().to_rfc3339()` as a parameter so producer (insert) + consumer (query) share the same string convention. Documented inline as a gotcha for future maintainers.

Tests

  • +10 `db::scheduler::tests` — list_due (6 cases), try_lock (2 cases), mark_failed (3 outcomes), reset (1), policy-table lock-in (1).
  • +1 `db::migration_tests::scheduler_retry_columns_exist_after_migration_019` — verifies NOT NULL DEFAULT 0 on counter, nullable on timestamp via PRAGMA.

Rust 237/237 (was 226), clippy + fmt clean.

Next

PR F2 lands the background `tokio::spawn` that polls every 60 s, locks via `try_lock_for_publish`, calls existing `publish_post` / `publish_linkedin_post`, and routes failures through `mark_publish_attempt_failed` + native notifications. ETA ~1 PR.

🤖 Generated with Claude Code

Résumé par Sourcery

Introduction d’une prise en charge au niveau base de données pour un planificateur d’auto-publication, incluant des métadonnées de nouvelle tentative/backoff et des requêtes dédiées au planificateur, servant de base à la fonctionnalité de publication en arrière-plan de la v0.4.0.

Nouvelles fonctionnalités :

  • Ajout d’un module d’accès aux données du planificateur pour lister les posts arrivés à échéance, les verrouiller de manière atomique pour la publication, suivre les tentatives échouées avec backoff et réinitialiser l’état de nouvelle tentative.
  • Extension des enregistrements d’historique de post avec des champs liés aux nouvelles tentatives pour le comptage des échecs et les horodatages de dernière tentative, afin de prendre en charge la logique de publication automatisée.

Corrections de bugs :

  • S’assurer que les comparaisons temporelles pour la planification à échéance utilisent le même format RFC 3339 que les horodatages stockés, afin d’éviter les divergences avec le format de datetime('now') de SQLite.

Améliorations :

  • Ajout d’une nouvelle migration pour ajouter les colonnes de suivi des nouvelles tentatives du planificateur à la table d’historique des posts, avec des valeurs par défaut sûres pour les données héritées.
  • Renforcement des invariants liés au planificateur avec des tests ciblés couvrant le schéma de migration, la politique de backoff, le comportement de verrouillage, les seuils de nouvelle tentative et les flux de réinitialisation.

Tests :

  • Ajout d’un test de migration pour vérifier la présence et les contraintes des nouvelles colonnes de nouvelle tentative sur post_history.
  • Ajout de tests complets pour le module de planification couvrant la sélection des éléments arrivés à échéance, la gestion du backoff, les sémantiques de verrouillage, les transitions d’issue d’échec et les constantes de la politique de nouvelle tentative.
Original summary in English

Summary by Sourcery

Introduce database-level support for an auto-publish scheduler, including retry/backoff metadata and dedicated scheduler queries, as the foundation for the v0.4.0 background publishing feature.

New Features:

  • Add scheduler data-access module to list due posts, atomically lock them for publishing, track failed attempts with backoff, and reset retry state.
  • Extend post history records with retry-related fields for failed attempt counting and last attempt timestamps to support automated publishing logic.

Bug Fixes:

  • Ensure time comparisons for due-scheduling use the same RFC 3339 format as stored timestamps to avoid mismatches with SQLite's datetime('now') format.

Enhancements:

  • Add a new migration to append scheduler retry bookkeeping columns to the post history table with safe defaults for legacy data.
  • Strengthen scheduler-related invariants with focused tests covering migration schema, backoff policy, locking behavior, retry thresholds, and reset flows.

Tests:

  • Add migration test to verify presence and constraints of new retry columns on post_history.
  • Add comprehensive scheduler module tests covering due-selection, backoff handling, locking semantics, failure outcome transitions, and retry policy constants.

First PR of the v0.4.0 stack per docs/roadmap-v0.4.md. Data layer
only — the background task that consumes this module lands in PR
F2; the UI lands in PR F3. Same foundation pattern as PR #56
(post_groups) used before the multi-network composer wired in.

## Migration 019

Two columns added to `post_history` for retry bookkeeping:
- `failed_attempts INTEGER NOT NULL DEFAULT 0` — bounded counter,
  the scheduler gives up at MAX_FAILED_ATTEMPTS (3) and flips the
  row to `status = 'failed'`.
- `last_attempt_at TEXT` — nullable RFC 3339 UTC timestamp,
  drives the backoff-window math without a separate attempts
  history table.

Both `#[serde(default)]` on the Rust struct so deserialising a
pre-v0.4 PostRecord JSON (e.g. from the in-flight composer state
when the user upgrades mid-session) gracefully fills the defaults.

## db::scheduler module

Six items, each verified by an in-memory pool test:
- `MAX_FAILED_ATTEMPTS` const (3)
- `BACKOFF_MINUTES` const ([0, 5, 30, 120] — 0 for never-tried,
  then 5min / 30min / 2h backoff)
- `list_due_for_publish(pool)` — single SQL with a CASE clause
  encoding the backoff schedule directly; returns posts whose
  `scheduled_at` is past AND whose retry window has elapsed
- `try_lock_for_publish(pool, post_id)` — atomic flip
  `'draft' → 'publishing'` via `WHERE status = 'draft'`; returns
  `false` when another worker raced. Lock-row pattern prevents
  two concurrent app launches from double-publishing the same row.
- `mark_publish_attempt_failed(pool, post_id)` — increments
  counter, sets `last_attempt_at`, flips to `'failed'` once the
  threshold is crossed. Returns enum WillRetry vs GaveUp so the
  scheduler can route to the right notification.
- `reset_retry_state(pool, post_id)` — clears counter + timestamp
  back to `status = 'draft'`. Called from the Calendar reschedule
  flow in PR F3 when the user manually fixes a failure (token
  reconnect, etc.).

## Critical SQL bug avoided

`scheduled_at <= datetime('now')` doesn't work — SQLite's
`datetime('now')` returns `'YYYY-MM-DD HH:MM:SS'` (space
separator, no T, no Z) which sorts lexicographically BELOW
`chrono::Utc::now().to_rfc3339()` writes (`'YYYY-MM-DDTHH:MM:SS.fffZ'`)
for the SAME wall-clock instant. Two tests caught this on first
run (`list_due_returns_overdue_drafts_only` failed because the
RFC 3339 scheduled_at compared as STRICTLY GREATER than
`datetime('now')` even when the instant was in the past). Fix:
bind `Utc::now().to_rfc3339()` as a parameter so producer + consumer
share the same string convention. Documented inline.

## Tests

- +10 `db::scheduler::tests` — covers list_due (overdue match,
  future skip, backoff-window skip, backoff-window expiration,
  max-attempts skip, non-draft status skip), try_lock (success
  + race), mark_failed (will-retry x 2 + gave-up), reset, and a
  policy-table lock-in test.
- +1 `db::migration_tests::scheduler_retry_columns_exist_after_migration_019`
  — confirms NOT NULL DEFAULT 0 on `failed_attempts` and
  nullable on `last_attempt_at` via PRAGMA inspection.

Total: Rust 237/237 (was 226), clippy + fmt clean.

## Targeted dead_code allows

PR F2 will consume every symbol exposed here. Until then, six
items (1 enum + 4 fns + 2 consts) carry `#[allow(dead_code)]`
with an inline note pointing at PR F2 — same pattern as PR #56
used before #57 picked it up.

## Next: PR F2

Background task `tokio::spawn` at app startup. Poll loop every
60s. For each due row: try_lock → publish_post / publish_linkedin_post
→ mark_failed on Err / no-op on Ok (publish_post already updates
status to 'published'). Notification on final-failure via
tauri-plugin-notification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented May 11, 2026

Copy link
Copy Markdown

Guide du·de la relecteur·rice

Introduit la couche de base de la base de données pour le planificateur de publication automatique v0.4.0 en ajoutant des colonnes de suivi de nouvelle tentative à post_history, un nouveau module db::scheduler avec des API de planification/backoff/verrouillage, ainsi que des tests qui fixent la politique de nouvelle tentative et le contrat de migration.

Diagramme de flux pour le cycle de vie des nouvelles tentatives du planificateur et des statuts

flowchart LR
    A[Publication planifiée en brouillon\nstatut draft\nfailed_attempts = 0] -->|list_due_for_publish correspond| B[try_lock_for_publish\nfixe status = publishing\nmet à jour last_attempt_at]
    B --> C[Tentative de publication]
    C -->|success| D[status published\nfailed_attempts inchangé]
    C -->|error| E[mark_publish_attempt_failed\nincrémente failed_attempts\nmet à jour last_attempt_at]
    E -->|PublishFailureOutcome WillRetry\nfailed_attempts < MAX_FAILED_ATTEMPTS| A
    E -->|PublishFailureOutcome GaveUp\nfailed_attempts >= MAX_FAILED_ATTEMPTS| F[status failed\naucune nouvelle tentative]
    F -->|Replanification par l'utilisateur·rice\nreset_retry_state| A
Loading

Modifications au niveau des fichiers

Changement Détails Fichiers
Ajout de colonnes de suivi des nouvelles tentatives à post_history et exposition via PostRecord et les requêtes existantes.
  • Ajouter les champs failed_attempts et last_attempt_at à PostRecord avec des valeurs par défaut serde et de la documentation.
  • Mettre à jour les projections SELECT dans get_by_id, list_recent et list_in_range pour inclure les nouvelles colonnes.
  • Renseigner PostRecord::failed_attempts et last_attempt_at dans row_to_post_record avec des valeurs par défaut sûres afin d’éviter de casser les requêtes héritées.
src-tauri/src/db/history.rs
Introduction du module db::scheduler implémentant l’API de données du planificateur de publication automatique, incluant la politique de backoff, la requête de sélection des éléments dus, le verrouillage, la comptabilisation des échecs et la sémantique de réinitialisation, avec des tests à l’appui.
  • Définir MAX_FAILED_ATTEMPTS et BACKOFF_MINUTES comme constantes pour centraliser la politique de nouvelle tentative.
  • Implémenter list_due_for_publish avec une unique requête SQL qui filtre les publications planifiées au statut draft par heure, limite failed_attempts, et backoff basé sur CASE utilisant last_attempt_at, en liant Utc::now().to_rfc3339() au lieu de SQLite datetime('now').
  • Implémenter try_lock_for_publish pour basculer atomiquement le statut de 'draft' à 'publishing' et inscrire last_attempt_at, en renvoyant un booléen basé sur rows_affected.
  • Implémenter mark_publish_attempt_failed pour incrémenter failed_attempts, mettre à jour last_attempt_at, basculer conditionnellement le statut à 'failed', et renvoyer un enum PublishFailureOutcome indiquant WillRetry vs GaveUp.
  • Implémenter reset_retry_state pour effacer failed_attempts et last_attempt_at et ramener le statut à 'draft' pour les flux de récupération manuelle.
  • Ajouter une suite de tests dans le module qui migre une base en mémoire, insère des publications planifiées avec différents états de nouvelle tentative, et vérifie le comportement de list_due_for_publish, les sémantiques de verrouillage, le comportement au seuil d’échec, le comportement de réinitialisation et les constantes de backoff.
src-tauri/src/db/scheduler.rs
Ajout de la migration 019 pour étendre post_history avec des colonnes liées au planificateur et les vérifier via un test de migration.
  • Créer la migration 019_scheduler_retry_columns.sql pour ajouter failed_attempts INTEGER NOT NULL DEFAULT 0 et last_attempt_at TEXT à post_history, avec des commentaires détaillés sur le comportement du planificateur et les choix de schéma.
  • Ajouter le test de migration scheduler_retry_columns_exist_after_migration_019 qui vérifie la présence des deux colonnes et impose NOT NULL + DEFAULT 0 pour failed_attempts et la possibilité de NULL pour last_attempt_at en utilisant PRAGMA table_info.
src-tauri/src/db/migrations/019_scheduler_retry_columns.sql
src-tauri/src/db/mod.rs

Conseils et commandes

Interagir avec Sourcery

  • Déclencher une nouvelle revue : Commentez @sourcery-ai review sur la pull request.
  • Poursuivre les discussions : Répondez directement aux commentaires de revue de Sourcery.
  • Générer un ticket GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer un
    ticket à partir d’un commentaire de revue en y répondant. Vous pouvez aussi répondre à un
    commentaire de revue avec @sourcery-ai issue pour créer un ticket à partir de celui-ci.
  • Générer un titre de pull request : Écrivez @sourcery-ai n’importe où dans le titre de la pull
    request pour générer un titre à tout moment. Vous pouvez aussi commenter
    @sourcery-ai title sur la pull request pour (re)générer le titre à tout moment.
  • Générer un résumé de pull request : Écrivez @sourcery-ai summary n’importe où dans
    le corps de la pull request pour générer un résumé de PR à tout moment exactement
    à l’endroit souhaité. Vous pouvez aussi commenter @sourcery-ai summary sur la pull request pour
    (re)générer le résumé à tout moment.
  • Générer le guide du·de la relecteur·rice : Commentez @sourcery-ai guide sur la pull
    request pour (re)générer le guide du·de la relecteur·rice à tout moment.
  • Résoudre tous les commentaires Sourcery : Commentez @sourcery-ai resolve sur la pull
    request pour résoudre tous les commentaires Sourcery. Utile si vous avez déjà
    traité tous les commentaires et ne voulez plus les voir.
  • Rejeter toutes les revues Sourcery : Commentez @sourcery-ai dismiss sur la pull
    request pour rejeter toutes les revues Sourcery existantes. Particulièrement utile si vous
    souhaitez repartir de zéro avec une nouvelle revue – n’oubliez pas de commenter
    @sourcery-ai review pour déclencher une nouvelle revue !

Personnaliser votre expérience

Accédez à votre tableau de bord pour :

  • Activer ou désactiver des fonctionnalités de revue telles que le résumé de pull request
    généré par Sourcery, le guide du·de la relecteur·rice, et d’autres.
  • Changer la langue de la revue.
  • Ajouter, supprimer ou modifier des instructions de revue personnalisées.
  • Ajuster d’autres paramètres de revue.

Obtenir de l’aide

Original review guide in English

Reviewer's Guide

Introduces the database-layer foundation for the v0.4.0 auto-publish scheduler by adding retry bookkeeping columns to post_history, a new db::scheduler module with scheduling/backoff/locking APIs, and tests that lock in the retry policy and migration contract.

Flow diagram for scheduler retry and status lifecycle

flowchart LR
    A[Draft scheduled post\nstatus draft\nfailed_attempts = 0] -->|list_due_for_publish matches| B[try_lock_for_publish\nsets status = publishing\nupdates last_attempt_at]
    B --> C[Publish attempt]
    C -->|success| D[status published\nfailed_attempts unchanged]
    C -->|error| E[mark_publish_attempt_failed\nincrements failed_attempts\nupdates last_attempt_at]
    E -->|PublishFailureOutcome WillRetry\nfailed_attempts < MAX_FAILED_ATTEMPTS| A
    E -->|PublishFailureOutcome GaveUp\nfailed_attempts >= MAX_FAILED_ATTEMPTS| F[status failed\nno further retries]
    F -->|User reschedules\nreset_retry_state| A
Loading

File-Level Changes

Change Details Files
Add retry bookkeeping columns to post_history and expose them through PostRecord and existing queries.
  • Add failed_attempts and last_attempt_at fields to PostRecord with serde defaults and documentation.
  • Update SELECT projections in get_by_id, list_recent, and list_in_range to include the new columns.
  • Populate PostRecord::failed_attempts and last_attempt_at in row_to_post_record with safe defaults to avoid breaking legacy queries.
src-tauri/src/db/history.rs
Introduce db::scheduler module implementing the auto-publish scheduler data API, including backoff policy, due-selection query, locking, failure accounting, and reset semantics, backed by tests.
  • Define MAX_FAILED_ATTEMPTS and BACKOFF_MINUTES as constants to centralize retry policy.
  • Implement list_due_for_publish with a single SQL query that filters draft scheduled posts by time, failed_attempts limit, and CASE-based backoff using last_attempt_at, binding Utc::now().to_rfc3339() instead of SQLite datetime('now').
  • Implement try_lock_for_publish to atomically flip status from 'draft' to 'publishing' and stamp last_attempt_at, returning a bool based on rows_affected.
  • Implement mark_publish_attempt_failed to increment failed_attempts, update last_attempt_at, conditionally flip status to 'failed', and return a PublishFailureOutcome enum indicating WillRetry vs GaveUp.
  • Implement reset_retry_state to clear failed_attempts and last_attempt_at and return status to 'draft' for manual recovery flows.
  • Add an in-module test suite that migrates an in-memory DB, inserts scheduled posts with different retry states, and verifies list_due_for_publish behavior, locking semantics, failure threshold behavior, reset behavior, and backoff constants.
src-tauri/src/db/scheduler.rs
Add migration 019 to extend post_history with scheduler-related columns and verify them via a migration test.
  • Create migration 019_scheduler_retry_columns.sql to add failed_attempts INTEGER NOT NULL DEFAULT 0 and last_attempt_at TEXT to post_history, with extensive rationale comments about scheduler behavior and schema choices.
  • Add migration test scheduler_retry_columns_exist_after_migration_019 that asserts presence of both columns and enforces NOT NULL + DEFAULT 0 for failed_attempts and NULL-ability for last_attempt_at using PRAGMA table_info.
src-tauri/src/db/migrations/019_scheduler_retry_columns.sql
src-tauri/src/db/mod.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - j’ai trouvé 3 problèmes et laissé quelques retours de haut niveau :

  • Le planning de backoff est actuellement dupliqué entre BACKOFF_MINUTES et le CASE failed_attempts codé en dur dans list_due_for_publish. Il serait préférable de centraliser ça (par ex. via un helper ou au moins un test dédié qui vérifie explicitement que les minutes du CASE SQL correspondent à BACKOFF_MINUTES) afin d’éviter une dérive silencieuse si la politique change.
  • reset_retry_state définit inconditionnellement status = 'draft' pour le post_id donné. Si cela est prévu uniquement pour des posts ayant déjà échoué, il serait plus sûr de restreindre le UPDATE avec un WHERE id = ? AND status = 'failed' (ou similaire) pour éviter de remettre accidentellement à l’état draft une ligne déjà correctement published.
Prompt pour les agents IA
Please address the comments from this code review:

## Overall Comments
- The backoff schedule is currently duplicated between `BACKOFF_MINUTES` and the hard-coded `CASE failed_attempts` in `list_due_for_publish`; consider centralizing this (e.g. via a helper or at least a dedicated test that explicitly asserts the SQL CASE minutes against `BACKOFF_MINUTES`) to avoid silent drift if the policy changes.
- `reset_retry_state` unconditionally sets `status = 'draft'` for the given `post_id`; if this is intended only for previously failed posts, it may be safer to restrict the `UPDATE` with a `WHERE id = ? AND status = 'failed'` (or similar) to avoid accidentally resetting a legitimately `published` row.

## Individual Comments

### Comment 1
<location path="src-tauri/src/db/scheduler.rs" line_range="141-150" />
<code_context>
+/// Should be called by the scheduler in PR F2 when the actual publish
+/// call (`publish_post` / `publish_linkedin_post`) returned `Err`.
+#[allow(dead_code)]
+pub async fn mark_publish_attempt_failed(
+    pool: &SqlitePool,
+    post_id: i64,
+) -> Result<PublishFailureOutcome, String> {
+    let now = Utc::now().to_rfc3339();
+    // Single UPDATE that does the accounting: the CASE flips the
+    // status to 'failed' once the increment crosses MAX_FAILED_ATTEMPTS,
+    // otherwise returns the row to draft so the next polling pass can
+    // pick it up after the backoff window.
+    sqlx::query(
+        "UPDATE post_history
+         SET failed_attempts = failed_attempts + 1,
+             last_attempt_at = ?,
+             status = CASE
+                 WHEN failed_attempts + 1 >= ? THEN 'failed'
+                 ELSE 'draft'
+             END
+         WHERE id = ?",
+    )
+    .bind(&now)
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard the failure accounting by status to avoid incrementing retries in unexpected states.

The UPDATE in `mark_publish_attempt_failed` currently runs for any row with the given `id`, regardless of `status`. If this is called when a post is already `failed`, `published`, or still `draft`, it will still increment `failed_attempts` and may flip `status` again. Please constrain the UPDATE with an appropriate status predicate (e.g. `WHERE id = ? AND status = 'publishing'`) so only in-flight publishes are affected and retry state can’t be corrupted by misuse from other call sites.
</issue_to_address>

### Comment 2
<location path="src-tauri/src/db/scheduler.rs" line_range="188-158" />
<code_context>
+/// underlying problem (reconnected account, etc.) and want a fresh
+/// budget. Called by the Calendar reschedule flow in PR F3.
+#[allow(dead_code)]
+pub async fn reset_retry_state(pool: &SqlitePool, post_id: i64) -> Result<(), String> {
+    sqlx::query(
+        "UPDATE post_history
+         SET failed_attempts = 0,
+             last_attempt_at = NULL,
+             status = 'draft'
+         WHERE id = ?",
+    )
+    .bind(post_id)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider restricting `reset_retry_state` to specific statuses to avoid racing with in-flight publishes.

Because this updates `failed_attempts`, `last_attempt_at`, and `status` unconditionally, a concurrent call while a post is `publishing` (or locked by another worker) can overwrite the scheduler’s state and lead to confusing outcomes (e.g., a publish completing after the status has been reset). Consider adding a status guard in the `WHERE` clause (e.g., `status IN ('failed', 'draft')` or just `'failed'`) and surfacing when no rows are updated so callers can handle concurrent edits explicitly.

Suggested implementation:

```rust
#[allow(dead_code)]
pub async fn reset_retry_state(pool: &SqlitePool, post_id: i64) -> Result<bool, String> {
    let result = sqlx::query(
        "UPDATE post_history
         SET failed_attempts = 0,
             last_attempt_at = NULL,
             status = 'draft'
         WHERE id = ?
           AND status IN ('draft', 'failed')",
    )
    .bind(post_id)
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    Ok(result.rows_affected() == 1)
}

```

1. Update all call sites of `reset_retry_state` to handle the new `Result<bool, String>` signature, checking the boolean to detect when no row was updated (e.g., due to a concurrent state change).
2. If your scheduler uses additional transient states (such as `'queued'` or `'retrying'`) that are also safe to reset from, extend the `status IN ('draft', 'failed')` clause accordingly to match your state machine.
</issue_to_address>

### Comment 3
<location path="src-tauri/src/db/scheduler.rs" line_range="73" />
<code_context>
+/// `failed_attempts` so the SQL stays one query — no per-row decision
+/// in Rust.
+#[allow(dead_code)]
+pub async fn list_due_for_publish(pool: &SqlitePool) -> Result<Vec<PostRecord>, String> {
+    let now_rfc3339 = Utc::now().to_rfc3339();
+    let rows: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the scheduler’s retry handling by moving backoff logic into Rust and using an UPDATE … RETURNING to collapse multi-step DB operations into single statements.

You can reduce complexity without changing behavior by:

1. **Moving the backoff window logic out of SQL and into Rust**, using the existing `BACKOFF_MINUTES`.
2. **Using `UPDATE … RETURNING` in `mark_publish_attempt_failed`** to avoid the two-step UPDATE+SELECT.

### 1. Simplify `list_due_for_publish` SQL and centralize backoff in Rust

Right now the retry policy is duplicated and encoded in a complex `CASE` + `datetime` expression. You already have `BACKOFF_MINUTES`; you can use it to keep a single source of truth and make the SQL easier to read.

Example refactor:

```rust
#[allow(dead_code)]
pub async fn list_due_for_publish(pool: &SqlitePool) -> Result<Vec<PostRecord>, String> {
    let now = Utc::now();
    let now_rfc3339 = now.to_rfc3339();

    // Simpler SQL: let SQLite only enforce basic constraints.
    let rows: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
        "SELECT id, network, caption, hashtags, status, created_at, published_at,
                scheduled_at, image_path, images, ig_media_id, account_id, published_url,
                group_id, failed_attempts, last_attempt_at
         FROM post_history
         WHERE status = 'draft'
           AND scheduled_at IS NOT NULL
           AND scheduled_at <= ?
           AND failed_attempts < ? 
         ORDER BY scheduled_at ASC",
    )
    .bind(&now_rfc3339)
    .bind(MAX_FAILED_ATTEMPTS)
    .fetch_all(pool)
    .await
    .map_err(|e| e.to_string())?;

    // Apply retry window in Rust using BACKOFF_MINUTES as sole source of truth.
    let due = rows
        .into_iter()
        .filter_map(|row| {
            let mut post = row_to_post_record(&row)?;

            // Convert RFC3339 fields to chrono types
            let last_attempt_at = post
                .last_attempt_at
                .as_deref()
                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                .map(|dt| dt.with_timezone(&Utc));

            let failed_attempts = post.failed_attempts;
            let backoff_minutes = BACKOFF_MINUTES
                .get(failed_attempts as usize)
                .copied()
                .unwrap_or_else(|| *BACKOFF_MINUTES.last().unwrap());

            let next_allowed = last_attempt_at
                .map(|t| t + chrono::Duration::minutes(backoff_minutes))
                // never tried → eligible immediately when scheduled_at <= now
                .unwrap_or(now);

            if next_allowed <= now {
                Some(post)
            } else {
                None
            }
        })
        .collect();

    Ok(due)
}
```

This keeps all behavior but:

- Removes the `CASE` + `datetime` expression from SQL.
- Uses `BACKOFF_MINUTES` directly so your retry policy is defined in one place.
- Makes the `backoff_minutes_table_matches_documented_policy` test strictly about the Rust constants (and you can drop any tests that only exist to keep SQL and Rust in sync).

### 2. Use `UPDATE … RETURNING` in `mark_publish_attempt_failed`

You can avoid the second query and keep the logic in one place:

```rust
#[allow(dead_code)]
pub async fn mark_publish_attempt_failed(
    pool: &SqlitePool,
    post_id: i64,
) -> Result<PublishFailureOutcome, String> {
    let now = Utc::now().to_rfc3339();

    // Single statement: increment, set status, and read back.
    let (failed_attempts, status): (i64, String) = sqlx::query_as(
        "UPDATE post_history
         SET failed_attempts = failed_attempts + 1,
             last_attempt_at = ?,
             status = CASE
                 WHEN failed_attempts + 1 >= ? THEN 'failed'
                 ELSE 'draft'
             END
         WHERE id = ?
         RETURNING failed_attempts, status",
    )
    .bind(&now)
    .bind(MAX_FAILED_ATTEMPTS)
    .bind(post_id)
    .fetch_one(pool)
    .await
    .map_err(|e| e.to_string())?;

    if status == "failed" {
        Ok(PublishFailureOutcome::GaveUp { failed_attempts })
    } else {
        Ok(PublishFailureOutcome::WillRetry { failed_attempts })
    }
}
```

This keeps the same semantics but:

- Removes the need to mentally track “before” and “after” states across two DB calls.
- Reduces DB round-trips and simplifies error handling.

If you adopt these two changes, the module’s core logic becomes easier to follow, and the retry policy has a single authoritative definition.
</issue_to_address>

Sourcery est gratuit pour l’open source - si vous aimez nos revues de code, pensez à les partager ✨
Aidez-moi à être plus utile ! Cliquez sur 👍 ou 👎 sur chaque commentaire et j’utiliserai vos retours pour améliorer les revues.
Original comment in English

Hey - I've found 3 issues, and left some high level feedback:

  • The backoff schedule is currently duplicated between BACKOFF_MINUTES and the hard-coded CASE failed_attempts in list_due_for_publish; consider centralizing this (e.g. via a helper or at least a dedicated test that explicitly asserts the SQL CASE minutes against BACKOFF_MINUTES) to avoid silent drift if the policy changes.
  • reset_retry_state unconditionally sets status = 'draft' for the given post_id; if this is intended only for previously failed posts, it may be safer to restrict the UPDATE with a WHERE id = ? AND status = 'failed' (or similar) to avoid accidentally resetting a legitimately published row.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The backoff schedule is currently duplicated between `BACKOFF_MINUTES` and the hard-coded `CASE failed_attempts` in `list_due_for_publish`; consider centralizing this (e.g. via a helper or at least a dedicated test that explicitly asserts the SQL CASE minutes against `BACKOFF_MINUTES`) to avoid silent drift if the policy changes.
- `reset_retry_state` unconditionally sets `status = 'draft'` for the given `post_id`; if this is intended only for previously failed posts, it may be safer to restrict the `UPDATE` with a `WHERE id = ? AND status = 'failed'` (or similar) to avoid accidentally resetting a legitimately `published` row.

## Individual Comments

### Comment 1
<location path="src-tauri/src/db/scheduler.rs" line_range="141-150" />
<code_context>
+/// Should be called by the scheduler in PR F2 when the actual publish
+/// call (`publish_post` / `publish_linkedin_post`) returned `Err`.
+#[allow(dead_code)]
+pub async fn mark_publish_attempt_failed(
+    pool: &SqlitePool,
+    post_id: i64,
+) -> Result<PublishFailureOutcome, String> {
+    let now = Utc::now().to_rfc3339();
+    // Single UPDATE that does the accounting: the CASE flips the
+    // status to 'failed' once the increment crosses MAX_FAILED_ATTEMPTS,
+    // otherwise returns the row to draft so the next polling pass can
+    // pick it up after the backoff window.
+    sqlx::query(
+        "UPDATE post_history
+         SET failed_attempts = failed_attempts + 1,
+             last_attempt_at = ?,
+             status = CASE
+                 WHEN failed_attempts + 1 >= ? THEN 'failed'
+                 ELSE 'draft'
+             END
+         WHERE id = ?",
+    )
+    .bind(&now)
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard the failure accounting by status to avoid incrementing retries in unexpected states.

The UPDATE in `mark_publish_attempt_failed` currently runs for any row with the given `id`, regardless of `status`. If this is called when a post is already `failed`, `published`, or still `draft`, it will still increment `failed_attempts` and may flip `status` again. Please constrain the UPDATE with an appropriate status predicate (e.g. `WHERE id = ? AND status = 'publishing'`) so only in-flight publishes are affected and retry state can’t be corrupted by misuse from other call sites.
</issue_to_address>

### Comment 2
<location path="src-tauri/src/db/scheduler.rs" line_range="188-158" />
<code_context>
+/// underlying problem (reconnected account, etc.) and want a fresh
+/// budget. Called by the Calendar reschedule flow in PR F3.
+#[allow(dead_code)]
+pub async fn reset_retry_state(pool: &SqlitePool, post_id: i64) -> Result<(), String> {
+    sqlx::query(
+        "UPDATE post_history
+         SET failed_attempts = 0,
+             last_attempt_at = NULL,
+             status = 'draft'
+         WHERE id = ?",
+    )
+    .bind(post_id)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider restricting `reset_retry_state` to specific statuses to avoid racing with in-flight publishes.

Because this updates `failed_attempts`, `last_attempt_at`, and `status` unconditionally, a concurrent call while a post is `publishing` (or locked by another worker) can overwrite the scheduler’s state and lead to confusing outcomes (e.g., a publish completing after the status has been reset). Consider adding a status guard in the `WHERE` clause (e.g., `status IN ('failed', 'draft')` or just `'failed'`) and surfacing when no rows are updated so callers can handle concurrent edits explicitly.

Suggested implementation:

```rust
#[allow(dead_code)]
pub async fn reset_retry_state(pool: &SqlitePool, post_id: i64) -> Result<bool, String> {
    let result = sqlx::query(
        "UPDATE post_history
         SET failed_attempts = 0,
             last_attempt_at = NULL,
             status = 'draft'
         WHERE id = ?
           AND status IN ('draft', 'failed')",
    )
    .bind(post_id)
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    Ok(result.rows_affected() == 1)
}

```

1. Update all call sites of `reset_retry_state` to handle the new `Result<bool, String>` signature, checking the boolean to detect when no row was updated (e.g., due to a concurrent state change).
2. If your scheduler uses additional transient states (such as `'queued'` or `'retrying'`) that are also safe to reset from, extend the `status IN ('draft', 'failed')` clause accordingly to match your state machine.
</issue_to_address>

### Comment 3
<location path="src-tauri/src/db/scheduler.rs" line_range="73" />
<code_context>
+/// `failed_attempts` so the SQL stays one query — no per-row decision
+/// in Rust.
+#[allow(dead_code)]
+pub async fn list_due_for_publish(pool: &SqlitePool) -> Result<Vec<PostRecord>, String> {
+    let now_rfc3339 = Utc::now().to_rfc3339();
+    let rows: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the scheduler’s retry handling by moving backoff logic into Rust and using an UPDATE … RETURNING to collapse multi-step DB operations into single statements.

You can reduce complexity without changing behavior by:

1. **Moving the backoff window logic out of SQL and into Rust**, using the existing `BACKOFF_MINUTES`.
2. **Using `UPDATE … RETURNING` in `mark_publish_attempt_failed`** to avoid the two-step UPDATE+SELECT.

### 1. Simplify `list_due_for_publish` SQL and centralize backoff in Rust

Right now the retry policy is duplicated and encoded in a complex `CASE` + `datetime` expression. You already have `BACKOFF_MINUTES`; you can use it to keep a single source of truth and make the SQL easier to read.

Example refactor:

```rust
#[allow(dead_code)]
pub async fn list_due_for_publish(pool: &SqlitePool) -> Result<Vec<PostRecord>, String> {
    let now = Utc::now();
    let now_rfc3339 = now.to_rfc3339();

    // Simpler SQL: let SQLite only enforce basic constraints.
    let rows: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
        "SELECT id, network, caption, hashtags, status, created_at, published_at,
                scheduled_at, image_path, images, ig_media_id, account_id, published_url,
                group_id, failed_attempts, last_attempt_at
         FROM post_history
         WHERE status = 'draft'
           AND scheduled_at IS NOT NULL
           AND scheduled_at <= ?
           AND failed_attempts < ? 
         ORDER BY scheduled_at ASC",
    )
    .bind(&now_rfc3339)
    .bind(MAX_FAILED_ATTEMPTS)
    .fetch_all(pool)
    .await
    .map_err(|e| e.to_string())?;

    // Apply retry window in Rust using BACKOFF_MINUTES as sole source of truth.
    let due = rows
        .into_iter()
        .filter_map(|row| {
            let mut post = row_to_post_record(&row)?;

            // Convert RFC3339 fields to chrono types
            let last_attempt_at = post
                .last_attempt_at
                .as_deref()
                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                .map(|dt| dt.with_timezone(&Utc));

            let failed_attempts = post.failed_attempts;
            let backoff_minutes = BACKOFF_MINUTES
                .get(failed_attempts as usize)
                .copied()
                .unwrap_or_else(|| *BACKOFF_MINUTES.last().unwrap());

            let next_allowed = last_attempt_at
                .map(|t| t + chrono::Duration::minutes(backoff_minutes))
                // never tried → eligible immediately when scheduled_at <= now
                .unwrap_or(now);

            if next_allowed <= now {
                Some(post)
            } else {
                None
            }
        })
        .collect();

    Ok(due)
}
```

This keeps all behavior but:

- Removes the `CASE` + `datetime` expression from SQL.
- Uses `BACKOFF_MINUTES` directly so your retry policy is defined in one place.
- Makes the `backoff_minutes_table_matches_documented_policy` test strictly about the Rust constants (and you can drop any tests that only exist to keep SQL and Rust in sync).

### 2. Use `UPDATE … RETURNING` in `mark_publish_attempt_failed`

You can avoid the second query and keep the logic in one place:

```rust
#[allow(dead_code)]
pub async fn mark_publish_attempt_failed(
    pool: &SqlitePool,
    post_id: i64,
) -> Result<PublishFailureOutcome, String> {
    let now = Utc::now().to_rfc3339();

    // Single statement: increment, set status, and read back.
    let (failed_attempts, status): (i64, String) = sqlx::query_as(
        "UPDATE post_history
         SET failed_attempts = failed_attempts + 1,
             last_attempt_at = ?,
             status = CASE
                 WHEN failed_attempts + 1 >= ? THEN 'failed'
                 ELSE 'draft'
             END
         WHERE id = ?
         RETURNING failed_attempts, status",
    )
    .bind(&now)
    .bind(MAX_FAILED_ATTEMPTS)
    .bind(post_id)
    .fetch_one(pool)
    .await
    .map_err(|e| e.to_string())?;

    if status == "failed" {
        Ok(PublishFailureOutcome::GaveUp { failed_attempts })
    } else {
        Ok(PublishFailureOutcome::WillRetry { failed_attempts })
    }
}
```

This keeps the same semantics but:

- Removes the need to mentally track “before” and “after” states across two DB calls.
- Reduces DB round-trips and simplifies error handling.

If you adopt these two changes, the module’s core logic becomes easier to follow, and the retry policy has a single authoritative definition.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +141 to +150
pub async fn mark_publish_attempt_failed(
pool: &SqlitePool,
post_id: i64,
) -> Result<PublishFailureOutcome, String> {
let now = Utc::now().to_rfc3339();
// Single UPDATE that does the accounting: the CASE flips the
// status to 'failed' once the increment crosses MAX_FAILED_ATTEMPTS,
// otherwise returns the row to draft so the next polling pass can
// pick it up after the backoff window.
sqlx::query(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Protéger la comptabilisation des échecs via le statut pour éviter d’incrémenter les tentatives de nouvelle exécution dans des états inattendus.

Le UPDATE dans mark_publish_attempt_failed s’exécute actuellement pour n’importe quelle ligne avec l’id donné, quel que soit le status. Si cette fonction est appelée alors qu’un post est déjà failed, published ou encore draft, elle incrémentera tout de même failed_attempts et pourra modifier à nouveau status. Merci de restreindre le UPDATE avec un prédicat de statut approprié (par ex. WHERE id = ? AND status = 'publishing') afin que seules les publications en cours soient affectées et que l’état de retry ne puisse pas être corrompu par une mauvaise utilisation depuis d’autres points d’appel.

Original comment in English

issue (bug_risk): Guard the failure accounting by status to avoid incrementing retries in unexpected states.

The UPDATE in mark_publish_attempt_failed currently runs for any row with the given id, regardless of status. If this is called when a post is already failed, published, or still draft, it will still increment failed_attempts and may flip status again. Please constrain the UPDATE with an appropriate status predicate (e.g. WHERE id = ? AND status = 'publishing') so only in-flight publishes are affected and retry state can’t be corrupted by misuse from other call sites.

WHEN failed_attempts + 1 >= ? THEN 'failed'
ELSE 'draft'
END
WHERE id = ?",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Envisagez de restreindre reset_retry_state à des statuts spécifiques afin d’éviter les courses avec des publications en cours.

Comme cette requête met à jour failed_attempts, last_attempt_at et status de manière inconditionnelle, un appel concurrent alors qu’un post est en cours de publishing (ou verrouillé par un autre worker) peut écraser l’état du scheduler et conduire à des situations déroutantes (par exemple, une publication qui se termine après que le statut ait été réinitialisé). Envisagez d’ajouter une condition sur le statut dans la clause WHERE (par ex. status IN ('failed', 'draft') ou seulement 'failed') et de faire remonter le cas où aucune ligne n’est mise à jour afin que les appelants puissent gérer explicitement les modifications concurrentes.

Implémentation suggérée :

#[allow(dead_code)]
pub async fn reset_retry_state(pool: &SqlitePool, post_id: i64) -> Result<bool, String> {
    let result = sqlx::query(
        "UPDATE post_history
         SET failed_attempts = 0,
             last_attempt_at = NULL,
             status = 'draft'
         WHERE id = ?
           AND status IN ('draft', 'failed')",
    )
    .bind(post_id)
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    Ok(result.rows_affected() == 1)
}
  1. Mettez à jour tous les points d’appel de reset_retry_state pour gérer la nouvelle signature Result<bool, String>, en vérifiant le booléen pour détecter les cas où aucune ligne n’a été mise à jour (par ex. à cause d’un changement d’état concurrent).
  2. Si votre scheduler utilise des états transitoires supplémentaires (comme 'queued' ou 'retrying') qui sont également sûrs à réinitialiser, étendez la clause status IN ('draft', 'failed') en conséquence pour refléter votre machine à états.
Original comment in English

suggestion (bug_risk): Consider restricting reset_retry_state to specific statuses to avoid racing with in-flight publishes.

Because this updates failed_attempts, last_attempt_at, and status unconditionally, a concurrent call while a post is publishing (or locked by another worker) can overwrite the scheduler’s state and lead to confusing outcomes (e.g., a publish completing after the status has been reset). Consider adding a status guard in the WHERE clause (e.g., status IN ('failed', 'draft') or just 'failed') and surfacing when no rows are updated so callers can handle concurrent edits explicitly.

Suggested implementation:

#[allow(dead_code)]
pub async fn reset_retry_state(pool: &SqlitePool, post_id: i64) -> Result<bool, String> {
    let result = sqlx::query(
        "UPDATE post_history
         SET failed_attempts = 0,
             last_attempt_at = NULL,
             status = 'draft'
         WHERE id = ?
           AND status IN ('draft', 'failed')",
    )
    .bind(post_id)
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    Ok(result.rows_affected() == 1)
}
  1. Update all call sites of reset_retry_state to handle the new Result<bool, String> signature, checking the boolean to detect when no row was updated (e.g., due to a concurrent state change).
  2. If your scheduler uses additional transient states (such as 'queued' or 'retrying') that are also safe to reset from, extend the status IN ('draft', 'failed') clause accordingly to match your state machine.

/// `failed_attempts` so the SQL stays one query — no per-row decision
/// in Rust.
#[allow(dead_code)]
pub async fn list_due_for_publish(pool: &SqlitePool) -> Result<Vec<PostRecord>, String> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Envisagez de simplifier la gestion des retries du scheduler en déplaçant la logique de backoff dans le code Rust et en utilisant un UPDATE … RETURNING pour regrouper les opérations multi‑étapes en base en instructions uniques.

Vous pouvez réduire la complexité sans changer le comportement en :

  1. Déplaçant la logique de fenêtre de backoff hors du SQL vers Rust, en utilisant le BACKOFF_MINUTES existant.
  2. Utilisant UPDATE … RETURNING dans mark_publish_attempt_failed pour éviter le duo UPDATE + SELECT.

1. Simplifier le SQL de list_due_for_publish et centraliser le backoff dans Rust

Actuellement, la politique de retry est dupliquée et encodée dans une expression CASE + datetime complexe. Vous avez déjà BACKOFF_MINUTES ; vous pouvez l’utiliser pour garder une seule source de vérité et rendre le SQL plus lisible.

Exemple de refactorisation :

#[allow(dead_code)]
pub async fn list_due_for_publish(pool: &SqlitePool) -> Result<Vec<PostRecord>, String> {
    let now = Utc::now();
    let now_rfc3339 = now.to_rfc3339();

    // SQL plus simple : laisser SQLite n’appliquer que les contraintes de base.
    let rows: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
        "SELECT id, network, caption, hashtags, status, created_at, published_at,
                scheduled_at, image_path, images, ig_media_id, account_id, published_url,
                group_id, failed_attempts, last_attempt_at
         FROM post_history
         WHERE status = 'draft'
           AND scheduled_at IS NOT NULL
           AND scheduled_at <= ?
           AND failed_attempts < ? 
         ORDER BY scheduled_at ASC",
    )
    .bind(&now_rfc3339)
    .bind(MAX_FAILED_ATTEMPTS)
    .fetch_all(pool)
    .await
    .map_err(|e| e.to_string())?;

    // Appliquer la fenêtre de retry en Rust en utilisant BACKOFF_MINUTES comme unique source de vérité.
    let due = rows
        .into_iter()
        .filter_map(|row| {
            let mut post = row_to_post_record(&row)?;

            // Conversion des champs RFC3339 en types chrono
            let last_attempt_at = post
                .last_attempt_at
                .as_deref()
                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                .map(|dt| dt.with_timezone(&Utc));

            let failed_attempts = post.failed_attempts;
            let backoff_minutes = BACKOFF_MINUTES
                .get(failed_attempts as usize)
                .copied()
                .unwrap_or_else(|| *BACKOFF_MINUTES.last().unwrap());

            let next_allowed = last_attempt_at
                .map(|t| t + chrono::Duration::minutes(backoff_minutes))
                // jamais tenté → éligible immédiatement quand scheduled_at <= now
                .unwrap_or(now);

            if next_allowed <= now {
                Some(post)
            } else {
                None
            }
        })
        .collect();

    Ok(due)
}

Cela conserve tout le comportement, mais :

  • Supprime l’expression CASE + datetime du SQL.
  • Utilise directement BACKOFF_MINUTES pour que votre politique de retry ne soit définie qu’à un seul endroit.
  • Rend le test backoff_minutes_table_matches_documented_policy strictement centré sur les constantes Rust (et vous pouvez supprimer les tests qui existent uniquement pour garder le SQL et Rust synchronisés).

2. Utiliser UPDATE … RETURNING dans mark_publish_attempt_failed

Vous pouvez éviter la seconde requête et regrouper la logique au même endroit :

#[allow(dead_code)]
pub async fn mark_publish_attempt_failed(
    pool: &SqlitePool,
    post_id: i64,
) -> Result<PublishFailureOutcome, String> {
    let now = Utc::now().to_rfc3339();

    // Instruction unique : incrément, mise à jour du statut, puis lecture.
    let (failed_attempts, status): (i64, String) = sqlx::query_as(
        "UPDATE post_history
         SET failed_attempts = failed_attempts + 1,
             last_attempt_at = ?,
             status = CASE
                 WHEN failed_attempts + 1 >= ? THEN 'failed'
                 ELSE 'draft'
             END
         WHERE id = ?
         RETURNING failed_attempts, status",
    )
    .bind(&now)
    .bind(MAX_FAILED_ATTEMPTS)
    .bind(post_id)
    .fetch_one(pool)
    .await
    .map_err(|e| e.to_string())?;

    if status == "failed" {
        Ok(PublishFailureOutcome::GaveUp { failed_attempts })
    } else {
        Ok(PublishFailureOutcome::WillRetry { failed_attempts })
    }
}

On garde les mêmes sémantiques, mais :

  • Il n’est plus nécessaire de suivre mentalement les états « avant » et « après » à travers deux appels DB.
  • On réduit les allers‑retours vers la base et on simplifie la gestion d’erreurs.

Si vous adoptez ces deux changements, la logique centrale du module devient plus facile à suivre et la politique de retry a une définition unique et autoritative.

Original comment in English

issue (complexity): Consider simplifying the scheduler’s retry handling by moving backoff logic into Rust and using an UPDATE … RETURNING to collapse multi-step DB operations into single statements.

You can reduce complexity without changing behavior by:

  1. Moving the backoff window logic out of SQL and into Rust, using the existing BACKOFF_MINUTES.
  2. Using UPDATE … RETURNING in mark_publish_attempt_failed to avoid the two-step UPDATE+SELECT.

1. Simplify list_due_for_publish SQL and centralize backoff in Rust

Right now the retry policy is duplicated and encoded in a complex CASE + datetime expression. You already have BACKOFF_MINUTES; you can use it to keep a single source of truth and make the SQL easier to read.

Example refactor:

#[allow(dead_code)]
pub async fn list_due_for_publish(pool: &SqlitePool) -> Result<Vec<PostRecord>, String> {
    let now = Utc::now();
    let now_rfc3339 = now.to_rfc3339();

    // Simpler SQL: let SQLite only enforce basic constraints.
    let rows: Vec<sqlx::sqlite::SqliteRow> = sqlx::query(
        "SELECT id, network, caption, hashtags, status, created_at, published_at,
                scheduled_at, image_path, images, ig_media_id, account_id, published_url,
                group_id, failed_attempts, last_attempt_at
         FROM post_history
         WHERE status = 'draft'
           AND scheduled_at IS NOT NULL
           AND scheduled_at <= ?
           AND failed_attempts < ? 
         ORDER BY scheduled_at ASC",
    )
    .bind(&now_rfc3339)
    .bind(MAX_FAILED_ATTEMPTS)
    .fetch_all(pool)
    .await
    .map_err(|e| e.to_string())?;

    // Apply retry window in Rust using BACKOFF_MINUTES as sole source of truth.
    let due = rows
        .into_iter()
        .filter_map(|row| {
            let mut post = row_to_post_record(&row)?;

            // Convert RFC3339 fields to chrono types
            let last_attempt_at = post
                .last_attempt_at
                .as_deref()
                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                .map(|dt| dt.with_timezone(&Utc));

            let failed_attempts = post.failed_attempts;
            let backoff_minutes = BACKOFF_MINUTES
                .get(failed_attempts as usize)
                .copied()
                .unwrap_or_else(|| *BACKOFF_MINUTES.last().unwrap());

            let next_allowed = last_attempt_at
                .map(|t| t + chrono::Duration::minutes(backoff_minutes))
                // never tried → eligible immediately when scheduled_at <= now
                .unwrap_or(now);

            if next_allowed <= now {
                Some(post)
            } else {
                None
            }
        })
        .collect();

    Ok(due)
}

This keeps all behavior but:

  • Removes the CASE + datetime expression from SQL.
  • Uses BACKOFF_MINUTES directly so your retry policy is defined in one place.
  • Makes the backoff_minutes_table_matches_documented_policy test strictly about the Rust constants (and you can drop any tests that only exist to keep SQL and Rust in sync).

2. Use UPDATE … RETURNING in mark_publish_attempt_failed

You can avoid the second query and keep the logic in one place:

#[allow(dead_code)]
pub async fn mark_publish_attempt_failed(
    pool: &SqlitePool,
    post_id: i64,
) -> Result<PublishFailureOutcome, String> {
    let now = Utc::now().to_rfc3339();

    // Single statement: increment, set status, and read back.
    let (failed_attempts, status): (i64, String) = sqlx::query_as(
        "UPDATE post_history
         SET failed_attempts = failed_attempts + 1,
             last_attempt_at = ?,
             status = CASE
                 WHEN failed_attempts + 1 >= ? THEN 'failed'
                 ELSE 'draft'
             END
         WHERE id = ?
         RETURNING failed_attempts, status",
    )
    .bind(&now)
    .bind(MAX_FAILED_ATTEMPTS)
    .bind(post_id)
    .fetch_one(pool)
    .await
    .map_err(|e| e.to_string())?;

    if status == "failed" {
        Ok(PublishFailureOutcome::GaveUp { failed_attempts })
    } else {
        Ok(PublishFailureOutcome::WillRetry { failed_attempts })
    }
}

This keeps the same semantics but:

  • Removes the need to mentally track “before” and “after” states across two DB calls.
  • Reduces DB round-trips and simplifies error handling.

If you adopt these two changes, the module’s core logic becomes easier to follow, and the retry policy has a single authoritative definition.

@thierryvm thierryvm merged commit 07e8701 into main May 11, 2026
5 checks passed
@thierryvm thierryvm deleted the feat/v0.4.0-scheduler-foundation branch May 11, 2026 19:38
thierryvm added a commit that referenced this pull request May 11, 2026
…urcery on PR #69) (#70)

Address 3 Sourcery comments on the v0.4.0 scheduler foundation (PR #69):

1. `mark_publish_attempt_failed` now requires `WHERE status = 'publishing'`
   so a misfire on a draft/failed/published row cannot corrupt the retry
   counter. Folded the post-UPDATE state read into the same statement via
   `UPDATE ... RETURNING` (1 round-trip instead of 2). Adds new
   `PublishFailureOutcome::Skipped` variant for the no-op case so the
   scheduler can log it without notifying.

2. `reset_retry_state` now requires `WHERE status IN ('draft', 'failed')`
   so an accidental call on a published row can't silently un-publish it,
   and a call while the scheduler holds the row in 'publishing' can't race
   the lock. Signature changes from `Result<(), String>` to
   `Result<bool, String>` so the caller can surface the no-op to the user.

3. Backoff window check moved out of the SQL CASE into Rust against
   BACKOFF_MINUTES — eliminates the silent-drift risk between the const
   table and the duplicated `WHEN 0 THEN 0 WHEN 1 THEN 5 ...` SQL literal.
   New `backoff_window_elapsed` helper keeps the filter logic isolated
   and unit-test-friendly.

Three new regression tests lock the new contracts:
- mark_failed_skips_when_status_is_not_publishing
- reset_retry_state_rejects_published_row
- reset_retry_state_rejects_publishing_row

Existing tests still green; total: 13 scheduler tests, 240 lib tests.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
thierryvm added a commit that referenced this pull request May 12, 2026
Wires the data layer mergé in PR #69+#70 to a real tokio task. Polls
SQLite every 60s for due drafts, atomically locks each row via
try_lock_for_publish, dispatches through the existing publish_post /
publish_linkedin_post commands, and surfaces a desktop notification
when a post exhausts MAX_FAILED_ATTEMPTS.

The scheduler stays a dumb pump: zero in-Rust retry timers, all
backoff lives in db::scheduler::BACKOFF_MINUTES re-read every tick.
That survives app restarts without losing track of pending posts.

Architecture:
- `tick` / `process_post` take traits (PublishDispatcher + Notifier)
  so the loop is testable without an AppHandle (real Tauri runtime
  is too heavy to stand up in #[tokio::test]). Production impls are
  thin shells: TauriDispatcher routes by network, TauriNotifier
  wraps tauri-plugin-notification.
- Pre-check token expiry against accounts.token_expires_at (added in
  migration 014). Expired token short-circuits with a clear error
  rather than waiting for Meta/LinkedIn to 401 — the dispatch still
  counts as a failed attempt so the retry budget burns down even on
  doomed tokens.

New deps:
- tauri-plugin-notification = "2" + notification:default capability
- async-trait = "0.1" for the dispatcher seam (already transitive)

Tests: +9 in src/scheduler.rs (240 -> 249 lib tests). Cover the
headline F2 E2E (overdue draft -> published after one tick), retry
budget exhaustion -> notification, lock-contention skip, backoff
window respect between failures, and the three token-expiry branches.

Out of scope (deferred to follow-ups):
- Scheduler UI badges in Composer/Dashboard (PR F3)
- Sourcery #70 complexity follow-ups (task #94) — DateTime<Utc>
  parsing in PostRecord touches frontend serde, warrants a dedicated
  PR rather than smuggling it through F2.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant