Skip to content

fix(scheduler): status guards + RETURNING + single backoff source (Sourcery on #69)#70

Merged
thierryvm merged 1 commit into
mainfrom
fix/scheduler-sourcery-followup
May 11, 2026
Merged

fix(scheduler): status guards + RETURNING + single backoff source (Sourcery on #69)#70
thierryvm merged 1 commit into
mainfrom
fix/scheduler-sourcery-followup

Conversation

@thierryvm

@thierryvm thierryvm commented May 11, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to PR #69 addressing 3 Sourcery review comments on src-tauri/src/db/scheduler.rs.

  • Status guard on mark_publish_attempt_failedWHERE status = 'publishing' prevents misfires from corrupting failed_attempts. New PublishFailureOutcome::Skipped variant surfaces the no-op. Collapsed into one UPDATE ... RETURNING (was 2 round-trips).
  • Status guard on reset_retry_stateWHERE status IN ('draft', 'failed') prevents accidentally un-publishing a published post or racing the scheduler's 'publishing' lock. Signature now Result<bool, String> so callers can surface the no-op.
  • Backoff duplication eliminated — moved the retry-window check out of the SQL CASE into Rust against BACKOFF_MINUTES. The const table is now the single source of truth; no more silent-drift risk between docs and SQL literals.

Test plan

  • cargo check → 0 errors
  • cargo clippy --lib -- -D warnings → 0 warnings
  • cargo fmt --check → clean
  • cargo test --lib db::scheduler:: → 13/13 pass (10 existing adapted + 3 new regression tests)
  • Full lib test suite → 240/240 pass

New regression tests

  • mark_failed_skips_when_status_is_not_publishing — locks the new guard contract
  • reset_retry_state_rejects_published_row — critical: published rows stay published
  • reset_retry_state_rejects_publishing_row — protects scheduler lock from races

Notes

🤖 Generated with Claude Code

Summary by Sourcery

Renforcer la logique de nouvelle tentative du scheduler et centraliser la politique de backoff tout en améliorant les sémantiques de mise à jour protégées par les statuts.

Corrections de bugs :

  • Protéger les mises à jour en cas d’échec de publication afin que seules les publications « en cours de publication » modifient l’état de retry et signalent les résultats ignorés lorsqu’il y a des conditions de concurrence.
  • Empêcher les réinitialisations de l’état de retry de dépublier ou d’entrer en concurrence avec des publications en cours, en limitant les mises à jour aux statuts brouillon/échoué et en exposant des résultats no-op.

Améliorations :

  • Déplacer l’évaluation de la fenêtre de backoff du SQL vers Rust en utilisant la table partagée BACKOFF_MINUTES pour conserver la politique de retry dans une seule source de vérité verrouillée par les tests.
  • Réduire les allers-retours du scheduler vers la base de données en utilisant une seule commande UPDATE ... RETURNING pour la prise en compte des échecs de publication et le reporting des résultats.
  • Clarifier la documentation du scheduler concernant la gestion des timestamps et le comportement de backoff.

Tests :

  • Ajouter des tests de régression couvrant les mises à jour de publication échouées mal déclenchées et les réinitialisations d’état de retry protégées pour les lignes published/publishing.
  • Étendre les tests pour valider la couverture de la table de backoff et garantir que la nouvelle logique de fenêtre de backoff respecte la politique de retry documentée.
Original summary in English

Summary by Sourcery

Harden scheduler retry logic and centralize backoff policy while improving status-guarded update semantics.

Bug Fixes:

  • Guard publish-failure updates so only in-flight 'publishing' posts mutate retry state and report skipped outcomes when races occur.
  • Prevent retry-state resets from unpublishing or racing with publishing posts by constraining updates to draft/failed statuses and surfacing no-op results.

Enhancements:

  • Move backoff window evaluation from SQL into Rust using the shared BACKOFF_MINUTES table to keep retry policy in a single, test-locked source of truth.
  • Reduce scheduler DB round-trips by using a single UPDATE ... RETURNING for publish-failure accounting and outcome reporting.
  • Clarify scheduler documentation around timestamp handling and backoff behavior.

Tests:

  • Add regression tests covering misfired publish-failure updates and guarded retry-state resets for published/publishing rows.
  • Extend tests to validate backoff table coverage and ensure the new backoff-window logic respects the documented retry policy.

…urcery on PR #69)

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>
@sourcery-ai

sourcery-ai Bot commented May 11, 2026

Copy link
Copy Markdown

Guide du réviseur

Refactorise la gestion des échecs/reculs (backoff) du planificateur afin que le SQL soit protégé et minimal tandis que Rust possède la politique de backoff, ajoute des garde-fous sur les statuts et un signalement no-op pour les chemins d’échec/réinitialisation, et étend les tests pour verrouiller les nouveaux contrats.

Diagramme de séquence pour la gestion d’une tentative de publication échouée avec garde sur le statut

sequenceDiagram
    actor Scheduler
    participant SchedulerModule as scheduler_rs
    participant Db as SqlitePool

    Scheduler->>SchedulerModule: mark_publish_attempt_failed(pool, post_id)
    SchedulerModule->>Db: query_as(UPDATE post_history ... WHERE id = post_id AND status = 'publishing' RETURNING failed_attempts, status)
    Db-->>SchedulerModule: Option(failed_attempts, status)

    alt [no row updated]
        SchedulerModule-->>Scheduler: PublishFailureOutcome::Skipped
    else [status == failed]
        SchedulerModule-->>Scheduler: PublishFailureOutcome::GaveUp { failed_attempts }
    else [status != failed]
        SchedulerModule-->>Scheduler: PublishFailureOutcome::WillRetry { failed_attempts }
    end
Loading

Modifications au niveau des fichiers

Modification Détails Fichiers
Déplacer la logique de fenêtre de backoff de reprise du SQL CASE vers Rust avec un helper dédié, tout en conservant BACKOFF_MINUTES comme unique source de vérité.
  • Ajuster list_due_for_publish pour lier now en tant que chrono::DateTime, récupérer les lignes candidates sans backoff basé sur SQL CASE, et les filtrer en Rust
  • Introduire le helper backoff_window_elapsed qui analyse last_attempt_at, applique BACKOFF_MINUTES avec indexation saturée et compare au now
  • Mettre à jour la documentation/les commentaires autour de BACKOFF_MINUTES et de la politique de backoff pour refléter l’application côté Rust et la charge de requêtes attendue
  • Resserrer le test de la politique de backoff pour affirmer que la longueur de la table dépasse MAX_FAILED_ATTEMPTS afin que l’indexation soit toujours dans la plage
src-tauri/src/db/scheduler.rs
Renforcer mark_publish_attempt_failed avec un garde sur le statut, le réduire à un seul UPDATE ... RETURNING, et étendre l’enum de résultat pour signaler les cas no-op.
  • Ajouter WHERE status = 'publishing' à l’UPDATE sur post_history et utiliser UPDATE ... RETURNING pour obtenir failed_attempts et status en un seul aller-retour
  • Modifier mark_publish_attempt_failed afin d’interpréter les mises à jour de zéro ligne comme un résultat Skipped et mapper le status retourné vers WillRetry vs GaveUp
  • Étendre PublishFailureOutcome avec une variante Skipped et mettre à jour sa documentation pour décrire les nouvelles sémantiques
  • Introduire un helper lock_for_test et adapter les tests pour verrouiller les lignes à 'publishing' avant d’appeler mark_publish_attempt_failed
  • Ajouter un test de régression vérifiant qu’appeler mark_publish_attempt_failed sur une ligne non publishing retourne Skipped et préserve failed_attempts/status
src-tauri/src/db/scheduler.rs
Protéger reset_retry_state pour qu’il n’opère que sur les lignes draft/failed, changer son API pour indiquer si une réinitialisation a eu lieu, et ajouter des tests pour le comportement des garde-fous.
  • Modifier reset_retry_state pour ne mettre à jour que les lignes avec status IN ('draft','failed') et pour retourner Result<bool, String> en fonction de rows_affected
  • Mettre à jour le test existant de reset_retry_state pour affirmer qu’une réinitialisation réussie retourne true et réinitialise toujours failed_attempts/last_attempt_at/status
  • Ajouter des tests de régression garantissant que reset_retry_state rejette les lignes published et publishing, en laissant leur status inchangé et en retournant false
src-tauri/src/db/scheduler.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 une issue GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer une issue à 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 une issue à 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 réviseur : Commentez @sourcery-ai guide sur la pull request pour (re)générer le guide du réviseur à tout moment.
  • Résoudre tous les commentaires de Sourcery : Commentez @sourcery-ai resolve sur la pull request pour résoudre tous les commentaires de Sourcery. Utile si vous avez déjà traité tous les commentaires et ne souhaitez plus les voir.
  • Rejeter toutes les revues de Sourcery : Commentez @sourcery-ai dismiss sur la pull request pour rejeter toutes les revues Sourcery existantes. Particulièrement utile si vous voulez 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 réviseur, 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

Refactors the scheduler’s failure/backoff handling so SQL is guarded and minimal while Rust owns the backoff policy, adds status guards and no-op signaling for failure/reset paths, and extends tests to lock in the new contracts.

Sequence diagram for handling a failed publish attempt with status guard

sequenceDiagram
    actor Scheduler
    participant SchedulerModule as scheduler_rs
    participant Db as SqlitePool

    Scheduler->>SchedulerModule: mark_publish_attempt_failed(pool, post_id)
    SchedulerModule->>Db: query_as(UPDATE post_history ... WHERE id = post_id AND status = 'publishing' RETURNING failed_attempts, status)
    Db-->>SchedulerModule: Option(failed_attempts, status)

    alt [no row updated]
        SchedulerModule-->>Scheduler: PublishFailureOutcome::Skipped
    else [status == failed]
        SchedulerModule-->>Scheduler: PublishFailureOutcome::GaveUp { failed_attempts }
    else [status != failed]
        SchedulerModule-->>Scheduler: PublishFailureOutcome::WillRetry { failed_attempts }
    end
Loading

File-Level Changes

Change Details Files
Move retry backoff window logic from SQL CASE into Rust with a dedicated helper while keeping BACKOFF_MINUTES as the single source of truth.
  • Adjust list_due_for_publish to bind now as a chrono::DateTime, fetch candidate rows without SQL CASE-based backoff, and filter them in Rust
  • Introduce backoff_window_elapsed helper that parses last_attempt_at, applies BACKOFF_MINUTES with saturated indexing, and compares against now
  • Update documentation/comments around BACKOFF_MINUTES and backoff policy to reflect Rust-side enforcement and expected query load
  • Tighten the backoff policy test to assert the table length exceeds MAX_FAILED_ATTEMPTS so indexing is always in range
src-tauri/src/db/scheduler.rs
Strengthen mark_publish_attempt_failed with a status guard, collapse it to a single UPDATE ... RETURNING, and extend the outcome enum to signal no-op cases.
  • Add WHERE status = 'publishing' to the UPDATE on post_history and use UPDATE ... RETURNING to get failed_attempts and status in one round-trip
  • Change mark_publish_attempt_failed to interpret zero-row updates as a Skipped outcome and map returned status into WillRetry vs GaveUp
  • Extend PublishFailureOutcome with a Skipped variant and update its documentation to describe the new semantics
  • Introduce a lock_for_test helper and adjust tests to lock rows to 'publishing' before calling mark_publish_attempt_failed
  • Add a regression test verifying that calling mark_publish_attempt_failed on a non-publishing row returns Skipped and preserves failed_attempts/status
src-tauri/src/db/scheduler.rs
Guard reset_retry_state to only operate on draft/failed rows, change its API to report whether a reset occurred, and add tests for guard behavior.
  • Change reset_retry_state to update only rows with status IN ('draft','failed') and to return Result<bool, String> based on rows_affected
  • Update existing reset_retry_state test to assert a successful reset returns true and still resets failed_attempts/last_attempt_at/status
  • Add regression tests ensuring reset_retry_state rejects published and publishing rows, leaving their status unchanged and returning false
src-tauri/src/db/scheduler.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é 1 problème et laissé quelques retours de haut niveau :

  • Il y a plusieurs chaînes de statut codées en dur (par ex. 'draft', 'failed', 'publishing', 'published') utilisées dans les comparaisons SQL et Rust ; envisagez de les centraliser sous forme de constantes ou d’un enum avec une table de correspondance pour réduire le risque de dérive et rendre les changements de statut futurs plus sûrs.
  • Dans backoff_window_elapsed, chaque appel réanalyse last_attempt_at depuis un RFC 3339 ; si cette fonction devient une section de code critique, vous pourriez envisager de stocker une datetime déjà parsée ou une représentation numérique dans la base de données pour éviter les coûts de parsing répétés.
Prompt pour agents IA
Veuillez traiter les commentaires de cette revue de code :

## Commentaires généraux
- Il y a plusieurs chaînes de statut codées en dur (par ex. 'draft', 'failed', 'publishing', 'published') utilisées dans les comparaisons SQL et Rust ; envisagez de les centraliser sous forme de constantes ou d’un enum avec une table de correspondance pour réduire le risque de dérive et rendre les changements de statut futurs plus sûrs.
- Dans `backoff_window_elapsed`, chaque appel réanalyse `last_attempt_at` depuis un RFC 3339 ; si cette fonction devient une section de code critique, vous pourriez envisager de stocker une datetime déjà parsée ou une représentation numérique dans la base de données pour éviter les coûts de parsing répétés.

## Commentaires individuels

### Commentaire 1
<location path="src-tauri/src/db/scheduler.rs" line_range="58" />
<code_context>
-/// We avoid SQLite's `datetime('now')` here because it returns the
-/// format `'YYYY-MM-DD HH:MM:SS'` (space separator, no fractional, no
-/// Z), which sorts lexicographically before our RFC 3339 writes
+/// `now_rfc3339` is bound from Rust as the reference instant for
+/// `scheduled_at` comparisons (string-equal to what
+/// `chrono::Utc::now().to_rfc3339()` writes during scheduling). We avoid
</code_context>
<issue_to_address>
**issue (complexity):** Envisagez de simplifier la nouvelle logique de backoff et de retry en parsant les timestamps plus tôt, en renforçant les invariants, et en simplifiant le flux de données et les tests, tout en gardant Rust comme unique source de vérité.

Vous pouvez conserver l’objectif « Rust comme unique source de vérité » tout en réduisant sensiblement la complexité de la nouvelle logique de backoff et du flux de données.

### 1. Parser `last_attempt_at` une seule fois dans `row_to_post_record`

Au lieu de parser le RFC3339 à chaque appel de `backoff_window_elapsed`, parsez-le au moment de la construction et stockez une valeur typée (ou `Option`) dans `PostRecord`. Cela supprime le bruit lié au parsing et à la politique d’erreur dans le prédicat de backoff.

```rust
// Example: adjust PostRecord to carry a typed timestamp
pub struct PostRecord {
    // ...
    pub last_attempt_at: Option<DateTime<Utc>>,
    pub failed_attempts: i64,
    // ...
}

// In row_to_post_record:
fn row_to_post_record(row: &SqliteRow) -> Result<PostRecord, String> {
    let last_attempt_str: Option<String> = row.try_get("last_attempt_at")?;
    let last_attempt_at = match last_attempt_str {
        None => None,
        Some(s) => match DateTime::parse_from_rfc3339(&s) {
            Ok(dt) => Some(dt.with_timezone(&Utc)),
            Err(_) => None, // “fail open” here; treat malformed as never-attempted
        },
    };

    Ok(PostRecord {
        // ...
        last_attempt_at,
        failed_attempts: row.try_get("failed_attempts")?,
        // ...
    })
}
```

Avec cela, `backoff_window_elapsed` peut se concentrer uniquement sur la logique de fenêtre temporelle :

```rust
fn backoff_window_elapsed(post: &PostRecord, now: DateTime<Utc>) -> bool {
    let Some(last) = post.last_attempt_at else {
        return true;
    };

    let idx = post
        .failed_attempts
        .clamp(0, MAX_FAILED_ATTEMPTS) as usize;

    let window_min = BACKOFF_MINUTES[idx];
    last + chrono::Duration::minutes(window_min) <= now
}
```

### 2. Renforcer les invariants de backoff et l’indexation

Étant donné l’invariant selon lequel la table couvre toutes les tentatives, vous pouvez simplifier l’indexation défensive en remplaçant la gymnastique `.get().unwrap_or_else(..)` par un simple `clamp` :

```rust
#[test]
fn backoff_table_covers_all_attempts() {
    assert_eq!(BACKOFF_MINUTES, [0, 5, 30, 120]);
    assert_eq!(MAX_FAILED_ATTEMPTS, 3);
    assert_eq!(BACKOFF_MINUTES.len(), (MAX_FAILED_ATTEMPTS + 1) as usize);
}
```

Ensuite, le `clamp` ci-dessus suffit ; vous n’avez plus besoin des fallbacks `.get()` plus `.last()`.

### 3. Éviter le `Vec<PostRecord>` intermédiaire dans `list_due_for_publish`

Vous pouvez conserver le backoff côté Rust tout en évitant la passe supplémentaire collect/filter en filtrant lors du mapping :

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

    let rows: Vec<SqliteRow> = sqlx::query(/* same SELECT */)
        .bind(&now_rfc3339)
        .bind(MAX_FAILED_ATTEMPTS)
        .fetch_all(pool)
        .await
        .map_err(|e| e.to_string())?;

    let mut due = Vec::with_capacity(rows.len());
    for row in rows.iter() {
        let post = row_to_post_record(row)?;
        if backoff_window_elapsed(&post, now) {
            due.push(post);
        }
    }
    Ok(due)
}
```

Cela maintient toute la logique en Rust (pas de `CASE` SQL) tout en réduisant le pipeline de transformation à une boucle simple.

### 4. Petit helper de test pour réduire la répétition

Les nouveaux tests offrent une bonne couverture d’état mais dupliquent un peu de SQL « lecture status/failed_attempts ». Un petit helper leur permet de se concentrer sur les transitions :

```rust
async fn fetch_attempts_and_status(
    pool: &SqlitePool,
    id: i64,
) -> (i64, String) {
    sqlx::query_as("SELECT failed_attempts, status FROM post_history WHERE id = ?")
        .bind(id)
        .fetch_one(pool)
        .await
        .expect("fetch")
}
```

Ensuite, des tests comme `mark_failed_skips_when_status_is_not_publishing` deviennent un peu plus faciles à lire :

```rust
let (failed_attempts, status) = fetch_attempts_and_status(&pool, id).await;
assert_eq!(failed_attempts, 0);
assert_eq!(status, "draft");
```

Tout ce qui précède conserve les nouveaux comportements (backoff piloté par Rust, garde-fous, résultats) tout en rendant le flux de contrôle et la politique de backoff plus faciles à comprendre.
</issue_to_address>

Sourcery est gratuit pour l’open source - si vous appréciez nos revues, merci d’en parler ✨
Aidez-moi à être plus utile ! Merci de cliquer sur 👍 ou 👎 sur chaque commentaire et j’utiliserai ces retours pour améliorer vos revues.
Original comment in English

Hey - I've found 1 issue, and left some high level feedback:

  • There are multiple hard-coded status string literals (e.g. 'draft', 'failed', 'publishing', 'published') used across SQL and Rust comparisons; consider centralizing these as constants or an enum + mapping to reduce drift risk and make future status changes safer.
  • In backoff_window_elapsed, every call reparses last_attempt_at from RFC 3339; if this becomes a hot path, you might consider storing a parsed datetime or a numeric representation in the DB to avoid repeated parsing costs.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- There are multiple hard-coded status string literals (e.g. 'draft', 'failed', 'publishing', 'published') used across SQL and Rust comparisons; consider centralizing these as constants or an enum + mapping to reduce drift risk and make future status changes safer.
- In `backoff_window_elapsed`, every call reparses `last_attempt_at` from RFC 3339; if this becomes a hot path, you might consider storing a parsed datetime or a numeric representation in the DB to avoid repeated parsing costs.

## Individual Comments

### Comment 1
<location path="src-tauri/src/db/scheduler.rs" line_range="58" />
<code_context>
-/// We avoid SQLite's `datetime('now')` here because it returns the
-/// format `'YYYY-MM-DD HH:MM:SS'` (space separator, no fractional, no
-/// Z), which sorts lexicographically before our RFC 3339 writes
+/// `now_rfc3339` is bound from Rust as the reference instant for
+/// `scheduled_at` comparisons (string-equal to what
+/// `chrono::Utc::now().to_rfc3339()` writes during scheduling). We avoid
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the new backoff and retry logic by parsing timestamps earlier, tightening invariants, and streamlining data flow and tests while keeping Rust as the single source of truth.

You can keep the “single source of truth in Rust” goal while trimming a fair bit of complexity from the new backoff logic and data flow.

### 1. Parse `last_attempt_at` once in `row_to_post_record`

Instead of parsing RFC3339 in every `backoff_window_elapsed` call, parse at construction time and store a typed value (or `Option`) in `PostRecord`. That removes parsing and error-policy noise from the backoff predicate.

```rust
// Example: adjust PostRecord to carry a typed timestamp
pub struct PostRecord {
    // ...
    pub last_attempt_at: Option<DateTime<Utc>>,
    pub failed_attempts: i64,
    // ...
}

// In row_to_post_record:
fn row_to_post_record(row: &SqliteRow) -> Result<PostRecord, String> {
    let last_attempt_str: Option<String> = row.try_get("last_attempt_at")?;
    let last_attempt_at = match last_attempt_str {
        None => None,
        Some(s) => match DateTime::parse_from_rfc3339(&s) {
            Ok(dt) => Some(dt.with_timezone(&Utc)),
            Err(_) => None, // “fail open” here; treat malformed as never-attempted
        },
    };

    Ok(PostRecord {
        // ...
        last_attempt_at,
        failed_attempts: row.try_get("failed_attempts")?,
        // ...
    })
}
```

With that, `backoff_window_elapsed` can focus purely on the time-window logic:

```rust
fn backoff_window_elapsed(post: &PostRecord, now: DateTime<Utc>) -> bool {
    let Some(last) = post.last_attempt_at else {
        return true;
    };

    let idx = post
        .failed_attempts
        .clamp(0, MAX_FAILED_ATTEMPTS) as usize;

    let window_min = BACKOFF_MINUTES[idx];
    last + chrono::Duration::minutes(window_min) <= now
}
```

### 2. Tighten the backoff invariants and indexing

Given the invariant that the table covers all attempts, you can simplify the defensive indexing, replacing the `.get().unwrap_or_else(..)` dance with a simple clamp:

```rust
#[test]
fn backoff_table_covers_all_attempts() {
    assert_eq!(BACKOFF_MINUTES, [0, 5, 30, 120]);
    assert_eq!(MAX_FAILED_ATTEMPTS, 3);
    assert_eq!(BACKOFF_MINUTES.len(), (MAX_FAILED_ATTEMPTS + 1) as usize);
}
```

Then the `clamp` above is enough; you no longer need `.get()` plus `.last()` fallbacks.

### 3. Avoid the intermediate `Vec<PostRecord>` in `list_due_for_publish`

You can keep the Rust-side backoff while avoiding the extra collect/filter pass by filtering during mapping:

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

    let rows: Vec<SqliteRow> = sqlx::query(/* same SELECT */)
        .bind(&now_rfc3339)
        .bind(MAX_FAILED_ATTEMPTS)
        .fetch_all(pool)
        .await
        .map_err(|e| e.to_string())?;

    let mut due = Vec::with_capacity(rows.len());
    for row in rows.iter() {
        let post = row_to_post_record(row)?;
        if backoff_window_elapsed(&post, now) {
            due.push(post);
        }
    }
    Ok(due)
}
```

This keeps all logic in Rust (no SQL `CASE`) but reduces the transformation pipeline to a straightforward loop.

### 4. Small test helper to reduce repetition

The new tests add good state coverage but duplicate a bit of “read status/failed_attempts” SQL. A tiny helper keeps them focused on transitions:

```rust
async fn fetch_attempts_and_status(
    pool: &SqlitePool,
    id: i64,
) -> (i64, String) {
    sqlx::query_as("SELECT failed_attempts, status FROM post_history WHERE id = ?")
        .bind(id)
        .fetch_one(pool)
        .await
        .expect("fetch")
}
```

Then tests like `mark_failed_skips_when_status_is_not_publishing` become a little easier to scan:

```rust
let (failed_attempts, status) = fetch_attempts_and_status(&pool, id).await;
assert_eq!(failed_attempts, 0);
assert_eq!(status, "draft");
```

All of the above keeps the new behaviors (Rust-driven backoff, guards, outcomes) intact while making the control flow and backoff policy easier to reason about.
</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.

/// We avoid SQLite's `datetime('now')` here because it returns the
/// format `'YYYY-MM-DD HH:MM:SS'` (space separator, no fractional, no
/// Z), which sorts lexicographically before our RFC 3339 writes
/// `now_rfc3339` is bound from Rust as the reference instant for

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 nouvelle logique de backoff et de retry en parsant les timestamps plus tôt, en renforçant les invariants, et en simplifiant le flux de données et les tests, tout en gardant Rust comme unique source de vérité.

Vous pouvez conserver l’objectif « Rust comme unique source de vérité » tout en réduisant sensiblement la complexité de la nouvelle logique de backoff et du flux de données.

1. Parser last_attempt_at une seule fois dans row_to_post_record

Au lieu de parser le RFC3339 à chaque appel de backoff_window_elapsed, parsez-le au moment de la construction et stockez une valeur typée (ou Option) dans PostRecord. Cela supprime le bruit lié au parsing et à la politique d’erreur dans le prédicat de backoff.

// Example: adjust PostRecord to carry a typed timestamp
pub struct PostRecord {
    // ...
    pub last_attempt_at: Option<DateTime<Utc>>,
    pub failed_attempts: i64,
    // ...
}

// In row_to_post_record:
fn row_to_post_record(row: &SqliteRow) -> Result<PostRecord, String> {
    let last_attempt_str: Option<String> = row.try_get("last_attempt_at")?;
    let last_attempt_at = match last_attempt_str {
        None => None,
        Some(s) => match DateTime::parse_from_rfc3339(&s) {
            Ok(dt) => Some(dt.with_timezone(&Utc)),
            Err(_) => None, // “fail open” here; treat malformed as never-attempted
        },
    };

    Ok(PostRecord {
        // ...
        last_attempt_at,
        failed_attempts: row.try_get("failed_attempts")?,
        // ...
    })
}

Avec cela, backoff_window_elapsed peut se concentrer uniquement sur la logique de fenêtre temporelle :

fn backoff_window_elapsed(post: &PostRecord, now: DateTime<Utc>) -> bool {
    let Some(last) = post.last_attempt_at else {
        return true;
    };

    let idx = post
        .failed_attempts
        .clamp(0, MAX_FAILED_ATTEMPTS) as usize;

    let window_min = BACKOFF_MINUTES[idx];
    last + chrono::Duration::minutes(window_min) <= now
}

2. Renforcer les invariants de backoff et l’indexation

Étant donné l’invariant selon lequel la table couvre toutes les tentatives, vous pouvez simplifier l’indexation défensive en remplaçant la gymnastique .get().unwrap_or_else(..) par un simple clamp :

#[test]
fn backoff_table_covers_all_attempts() {
    assert_eq!(BACKOFF_MINUTES, [0, 5, 30, 120]);
    assert_eq!(MAX_FAILED_ATTEMPTS, 3);
    assert_eq!(BACKOFF_MINUTES.len(), (MAX_FAILED_ATTEMPTS + 1) as usize);
}

Ensuite, le clamp ci-dessus suffit ; vous n’avez plus besoin des fallbacks .get() plus .last().

3. Éviter le Vec<PostRecord> intermédiaire dans list_due_for_publish

Vous pouvez conserver le backoff côté Rust tout en évitant la passe supplémentaire collect/filter en filtrant lors du mapping :

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

    let rows: Vec<SqliteRow> = sqlx::query(/* same SELECT */)
        .bind(&now_rfc3339)
        .bind(MAX_FAILED_ATTEMPTS)
        .fetch_all(pool)
        .await
        .map_err(|e| e.to_string())?;

    let mut due = Vec::with_capacity(rows.len());
    for row in rows.iter() {
        let post = row_to_post_record(row)?;
        if backoff_window_elapsed(&post, now) {
            due.push(post);
        }
    }
    Ok(due)
}

Cela maintient toute la logique en Rust (pas de CASE SQL) tout en réduisant le pipeline de transformation à une boucle simple.

4. Petit helper de test pour réduire la répétition

Les nouveaux tests offrent une bonne couverture d’état mais dupliquent un peu de SQL « lecture status/failed_attempts ». Un petit helper leur permet de se concentrer sur les transitions :

async fn fetch_attempts_and_status(
    pool: &SqlitePool,
    id: i64,
) -> (i64, String) {
    sqlx::query_as("SELECT failed_attempts, status FROM post_history WHERE id = ?")
        .bind(id)
        .fetch_one(pool)
        .await
        .expect("fetch")
}

Ensuite, des tests comme mark_failed_skips_when_status_is_not_publishing deviennent un peu plus faciles à lire :

let (failed_attempts, status) = fetch_attempts_and_status(&pool, id).await;
assert_eq!(failed_attempts, 0);
assert_eq!(status, "draft");

Tout ce qui précède conserve les nouveaux comportements (backoff piloté par Rust, garde-fous, résultats) tout en rendant le flux de contrôle et la politique de backoff plus faciles à comprendre.

Original comment in English

issue (complexity): Consider simplifying the new backoff and retry logic by parsing timestamps earlier, tightening invariants, and streamlining data flow and tests while keeping Rust as the single source of truth.

You can keep the “single source of truth in Rust” goal while trimming a fair bit of complexity from the new backoff logic and data flow.

1. Parse last_attempt_at once in row_to_post_record

Instead of parsing RFC3339 in every backoff_window_elapsed call, parse at construction time and store a typed value (or Option) in PostRecord. That removes parsing and error-policy noise from the backoff predicate.

// Example: adjust PostRecord to carry a typed timestamp
pub struct PostRecord {
    // ...
    pub last_attempt_at: Option<DateTime<Utc>>,
    pub failed_attempts: i64,
    // ...
}

// In row_to_post_record:
fn row_to_post_record(row: &SqliteRow) -> Result<PostRecord, String> {
    let last_attempt_str: Option<String> = row.try_get("last_attempt_at")?;
    let last_attempt_at = match last_attempt_str {
        None => None,
        Some(s) => match DateTime::parse_from_rfc3339(&s) {
            Ok(dt) => Some(dt.with_timezone(&Utc)),
            Err(_) => None, // “fail open” here; treat malformed as never-attempted
        },
    };

    Ok(PostRecord {
        // ...
        last_attempt_at,
        failed_attempts: row.try_get("failed_attempts")?,
        // ...
    })
}

With that, backoff_window_elapsed can focus purely on the time-window logic:

fn backoff_window_elapsed(post: &PostRecord, now: DateTime<Utc>) -> bool {
    let Some(last) = post.last_attempt_at else {
        return true;
    };

    let idx = post
        .failed_attempts
        .clamp(0, MAX_FAILED_ATTEMPTS) as usize;

    let window_min = BACKOFF_MINUTES[idx];
    last + chrono::Duration::minutes(window_min) <= now
}

2. Tighten the backoff invariants and indexing

Given the invariant that the table covers all attempts, you can simplify the defensive indexing, replacing the .get().unwrap_or_else(..) dance with a simple clamp:

#[test]
fn backoff_table_covers_all_attempts() {
    assert_eq!(BACKOFF_MINUTES, [0, 5, 30, 120]);
    assert_eq!(MAX_FAILED_ATTEMPTS, 3);
    assert_eq!(BACKOFF_MINUTES.len(), (MAX_FAILED_ATTEMPTS + 1) as usize);
}

Then the clamp above is enough; you no longer need .get() plus .last() fallbacks.

3. Avoid the intermediate Vec<PostRecord> in list_due_for_publish

You can keep the Rust-side backoff while avoiding the extra collect/filter pass by filtering during mapping:

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

    let rows: Vec<SqliteRow> = sqlx::query(/* same SELECT */)
        .bind(&now_rfc3339)
        .bind(MAX_FAILED_ATTEMPTS)
        .fetch_all(pool)
        .await
        .map_err(|e| e.to_string())?;

    let mut due = Vec::with_capacity(rows.len());
    for row in rows.iter() {
        let post = row_to_post_record(row)?;
        if backoff_window_elapsed(&post, now) {
            due.push(post);
        }
    }
    Ok(due)
}

This keeps all logic in Rust (no SQL CASE) but reduces the transformation pipeline to a straightforward loop.

4. Small test helper to reduce repetition

The new tests add good state coverage but duplicate a bit of “read status/failed_attempts” SQL. A tiny helper keeps them focused on transitions:

async fn fetch_attempts_and_status(
    pool: &SqlitePool,
    id: i64,
) -> (i64, String) {
    sqlx::query_as("SELECT failed_attempts, status FROM post_history WHERE id = ?")
        .bind(id)
        .fetch_one(pool)
        .await
        .expect("fetch")
}

Then tests like mark_failed_skips_when_status_is_not_publishing become a little easier to scan:

let (failed_attempts, status) = fetch_attempts_and_status(&pool, id).await;
assert_eq!(failed_attempts, 0);
assert_eq!(status, "draft");

All of the above keeps the new behaviors (Rust-driven backoff, guards, outcomes) intact while making the control flow and backoff policy easier to reason about.

@thierryvm thierryvm merged commit 589a98e into main May 11, 2026
5 checks passed
@thierryvm thierryvm deleted the fix/scheduler-sourcery-followup branch May 11, 2026 20:23
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