feat(scheduler): background auto-publish task (PR F2)#71
Conversation
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>
Guide du relecteurImplémente un planificateur en arrière-plan dans Tauri qui interroge périodiquement SQLite pour les publications arrivées à échéance et les envoie via les commandes de publication existantes, avec reprise/exponentiel de re-tentative, pré‑vérifications d’expiration de jeton, et notifications utilisateur lorsque les tentatives sont épuisées, le tout intégré au démarrage de l’application et couvert par des tests de type intégration. Diagramme de séquence pour un tick du planificateur avec échec de publication et notificationsequenceDiagram
participant SchedulerTask as SchedulerTask
participant DbScheduler as db_scheduler
participant Dispatcher as PublishDispatcher
participant Notifier as Notifier
SchedulerTask->>DbScheduler: list_due_for_publish(pool)
DbScheduler-->>SchedulerTask: Vec<PostRecord>
loop for each PostRecord
SchedulerTask->>DbScheduler: try_lock_for_publish(pool, post.id)
alt lock acquired
SchedulerTask->>Dispatcher: publish(post)
alt publish Ok
Dispatcher-->>SchedulerTask: Ok(())
Note over SchedulerTask,Dispatcher: publish_post / publish_linkedin_post flip status to published
else publish Err
Dispatcher-->>SchedulerTask: Err(String)
SchedulerTask->>DbScheduler: mark_publish_attempt_failed(pool, post.id)
alt PublishFailureOutcome::GaveUp
DbScheduler-->>SchedulerTask: GaveUp { failed_attempts }
SchedulerTask->>Notifier: notify_gave_up(post, failed_attempts)
else PublishFailureOutcome::WillRetry / Skipped
DbScheduler-->>SchedulerTask: WillRetry | Skipped
end
end
else lock not acquired / error
DbScheduler-->>SchedulerTask: false | Err
end
end
Modifications au niveau des fichiers
Conseils et commandesInteragir avec Sourcery
Personnaliser votre expérienceAccédez à votre dashboard pour :
Obtenir de l’aide
Original review guide in EnglishReviewer's GuideImplements a background scheduler in Tauri that periodically polls SQLite for due posts and dispatches them through existing publish commands, with retry/backoff, token-expiry prechecks, and user notifications when retries are exhausted, all wired into app startup and covered by integration-style tests. Sequence diagram for scheduler tick with publish failure and notificationsequenceDiagram
participant SchedulerTask as SchedulerTask
participant DbScheduler as db_scheduler
participant Dispatcher as PublishDispatcher
participant Notifier as Notifier
SchedulerTask->>DbScheduler: list_due_for_publish(pool)
DbScheduler-->>SchedulerTask: Vec<PostRecord>
loop for each PostRecord
SchedulerTask->>DbScheduler: try_lock_for_publish(pool, post.id)
alt lock acquired
SchedulerTask->>Dispatcher: publish(post)
alt publish Ok
Dispatcher-->>SchedulerTask: Ok(())
Note over SchedulerTask,Dispatcher: publish_post / publish_linkedin_post flip status to published
else publish Err
Dispatcher-->>SchedulerTask: Err(String)
SchedulerTask->>DbScheduler: mark_publish_attempt_failed(pool, post.id)
alt PublishFailureOutcome::GaveUp
DbScheduler-->>SchedulerTask: GaveUp { failed_attempts }
SchedulerTask->>Notifier: notify_gave_up(post, failed_attempts)
else PublishFailureOutcome::WillRetry / Skipped
DbScheduler-->>SchedulerTask: WillRetry | Skipped
end
end
else lock not acquired / error
DbScheduler-->>SchedulerTask: false | Err
end
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - j'ai trouvé 1 problème
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="src-tauri/src/scheduler.rs" line_range="124-133" />
<code_context>
+/// file (legacy rows from before migration 014) or the post has no
+/// `account_id`; the publish call itself will surface any real auth
+/// problem in those cases.
+async fn pre_check_token_expiry(pool: &sqlx::SqlitePool, post: &PostRecord) -> Result<(), String> {
+ let Some(account_id) = post.account_id else {
+ return Ok(());
+ };
+ let account = match crate::db::accounts::get_by_id(pool, account_id).await {
+ Ok(a) => a,
+ Err(_) => return Ok(()), // let publish surface the real error
+ };
+ let Some(expires_at) = account.token_expires_at.as_deref() else {
+ return Ok(());
+ };
+ let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(expires_at) else {
+ return Ok(());
+ };
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Ignorer les erreurs de recherche de compte / d'analyse de timestamp peut rendre le diagnostic des problèmes d'authentification plus difficile.
Ici, à la fois un `get_by_id` en échec et un format `token_expires_at` invalide aboutissent à `Ok(())` sans aucun logging. Cela signifie que des lignes corrompues ou des changements de schéma qui affectent le comportement de publish/auth ne seront pas visibles dans les diagnostics. Merci d'ajouter au minimum des logs de niveau debug/warn lorsque la recherche de compte échoue ou que l'analyse du timestamp échoue, afin que ces problèmes de données soient observables.
Implémentation suggérée :
```rust
async fn pre_check_token_expiry(pool: &sqlx::SqlitePool, post: &PostRecord) -> Result<(), String> {
let Some(account_id) = post.account_id else {
return Ok(());
};
let account = match crate::db::accounts::get_by_id(pool, account_id).await {
Ok(a) => a,
Err(err) => {
tracing::warn!(
?err,
account_id,
"pre_check_token_expiry: failed to look up account for post; falling back to letting publish surface auth error"
);
return Ok(()); // let publish surface the real error
}
};
let Some(expires_at) = account.token_expires_at.as_deref() else {
return Ok(());
};
let parsed = match chrono::DateTime::parse_from_rfc3339(expires_at) {
Ok(dt) => dt,
Err(err) => {
tracing::warn!(
?err,
account_id,
expires_at,
"pre_check_token_expiry: invalid token_expires_at format; falling back to letting publish surface auth error"
);
return Ok(());
}
};
```
Si `tracing` (ou une autre bibliothèque de logging) n'est pas déjà utilisée dans ce fichier, ajoutez un import approprié comme `use tracing::warn;` / `use tracing::instrument;` ou assurez-vous que `tracing::warn!` est disponible conformément à vos conventions de logging existantes. Si votre projet préfère la bibliothèque `log`, remplacez `tracing::warn!` par `log::warn!` et adaptez les imports en conséquence.
</issue_to_address>Sourcery est gratuit pour l'open source - si vous trouvez nos revues utiles, pensez à les partager ✨
Original comment in English
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="src-tauri/src/scheduler.rs" line_range="124-133" />
<code_context>
+/// file (legacy rows from before migration 014) or the post has no
+/// `account_id`; the publish call itself will surface any real auth
+/// problem in those cases.
+async fn pre_check_token_expiry(pool: &sqlx::SqlitePool, post: &PostRecord) -> Result<(), String> {
+ let Some(account_id) = post.account_id else {
+ return Ok(());
+ };
+ let account = match crate::db::accounts::get_by_id(pool, account_id).await {
+ Ok(a) => a,
+ Err(_) => return Ok(()), // let publish surface the real error
+ };
+ let Some(expires_at) = account.token_expires_at.as_deref() else {
+ return Ok(());
+ };
+ let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(expires_at) else {
+ return Ok(());
+ };
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Swallowing account lookup / timestamp parse errors may make diagnosing auth issues harder.
Here both a failed `get_by_id` and an invalid `token_expires_at` format resolve to `Ok(())` with no logging. That means corrupt rows or schema changes that affect publish/auth behaviour won’t be visible in diagnostics. Please add at least debug/warn logs when the account lookup fails or the timestamp parse fails so these data issues are observable.
Suggested implementation:
```rust
async fn pre_check_token_expiry(pool: &sqlx::SqlitePool, post: &PostRecord) -> Result<(), String> {
let Some(account_id) = post.account_id else {
return Ok(());
};
let account = match crate::db::accounts::get_by_id(pool, account_id).await {
Ok(a) => a,
Err(err) => {
tracing::warn!(
?err,
account_id,
"pre_check_token_expiry: failed to look up account for post; falling back to letting publish surface auth error"
);
return Ok(()); // let publish surface the real error
}
};
let Some(expires_at) = account.token_expires_at.as_deref() else {
return Ok(());
};
let parsed = match chrono::DateTime::parse_from_rfc3339(expires_at) {
Ok(dt) => dt,
Err(err) => {
tracing::warn!(
?err,
account_id,
expires_at,
"pre_check_token_expiry: invalid token_expires_at format; falling back to letting publish surface auth error"
);
return Ok(());
}
};
```
If `tracing` (or another logging crate) is not already used in this file, add an appropriate import such as `use tracing::warn;` / `use tracing::instrument;` or ensure `tracing::warn!` is available according to your existing logging conventions. If your project prefers the `log` crate, replace `tracing::warn!` with `log::warn!` and adjust imports accordingly.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| async fn pre_check_token_expiry(pool: &sqlx::SqlitePool, post: &PostRecord) -> Result<(), String> { | ||
| let Some(account_id) = post.account_id else { | ||
| return Ok(()); | ||
| }; | ||
| let account = match crate::db::accounts::get_by_id(pool, account_id).await { | ||
| Ok(a) => a, | ||
| Err(_) => return Ok(()), // let publish surface the real error | ||
| }; | ||
| let Some(expires_at) = account.token_expires_at.as_deref() else { | ||
| return Ok(()); |
There was a problem hiding this comment.
suggestion (bug_risk): Ignorer les erreurs de recherche de compte / d'analyse de timestamp peut rendre le diagnostic des problèmes d'authentification plus difficile.
Ici, à la fois un get_by_id en échec et un format token_expires_at invalide aboutissent à Ok(()) sans aucun logging. Cela signifie que des lignes corrompues ou des changements de schéma qui affectent le comportement de publish/auth ne seront pas visibles dans les diagnostics. Merci d'ajouter au minimum des logs de niveau debug/warn lorsque la recherche de compte échoue ou que l'analyse du timestamp échoue, afin que ces problèmes de données soient observables.
Implémentation suggérée :
async fn pre_check_token_expiry(pool: &sqlx::SqlitePool, post: &PostRecord) -> Result<(), String> {
let Some(account_id) = post.account_id else {
return Ok(());
};
let account = match crate::db::accounts::get_by_id(pool, account_id).await {
Ok(a) => a,
Err(err) => {
tracing::warn!(
?err,
account_id,
"pre_check_token_expiry: failed to look up account for post; falling back to letting publish surface auth error"
);
return Ok(()); // let publish surface the real error
}
};
let Some(expires_at) = account.token_expires_at.as_deref() else {
return Ok(());
};
let parsed = match chrono::DateTime::parse_from_rfc3339(expires_at) {
Ok(dt) => dt,
Err(err) => {
tracing::warn!(
?err,
account_id,
expires_at,
"pre_check_token_expiry: invalid token_expires_at format; falling back to letting publish surface auth error"
);
return Ok(());
}
};Si tracing (ou une autre bibliothèque de logging) n'est pas déjà utilisée dans ce fichier, ajoutez un import approprié comme use tracing::warn; / use tracing::instrument; ou assurez-vous que tracing::warn! est disponible conformément à vos conventions de logging existantes. Si votre projet préfère la bibliothèque log, remplacez tracing::warn! par log::warn! et adaptez les imports en conséquence.
Original comment in English
suggestion (bug_risk): Swallowing account lookup / timestamp parse errors may make diagnosing auth issues harder.
Here both a failed get_by_id and an invalid token_expires_at format resolve to Ok(()) with no logging. That means corrupt rows or schema changes that affect publish/auth behaviour won’t be visible in diagnostics. Please add at least debug/warn logs when the account lookup fails or the timestamp parse fails so these data issues are observable.
Suggested implementation:
async fn pre_check_token_expiry(pool: &sqlx::SqlitePool, post: &PostRecord) -> Result<(), String> {
let Some(account_id) = post.account_id else {
return Ok(());
};
let account = match crate::db::accounts::get_by_id(pool, account_id).await {
Ok(a) => a,
Err(err) => {
tracing::warn!(
?err,
account_id,
"pre_check_token_expiry: failed to look up account for post; falling back to letting publish surface auth error"
);
return Ok(()); // let publish surface the real error
}
};
let Some(expires_at) = account.token_expires_at.as_deref() else {
return Ok(());
};
let parsed = match chrono::DateTime::parse_from_rfc3339(expires_at) {
Ok(dt) => dt,
Err(err) => {
tracing::warn!(
?err,
account_id,
expires_at,
"pre_check_token_expiry: invalid token_expires_at format; falling back to letting publish surface auth error"
);
return Ok(());
}
};If tracing (or another logging crate) is not already used in this file, add an appropriate import such as use tracing::warn; / use tracing::instrument; or ensure tracing::warn! is available according to your existing logging conventions. If your project prefers the log crate, replace tracing::warn! with log::warn! and adjust imports accordingly.
Self-review
Suit le pattern Foundation-then-Consumer validé sur PR #56/#57 et confirmé
sur #69/#70. Le data layer ferme depuis #70 ; cette PR consomme.
Motivation
PR #69+#70 ont ajouté toute la SQL pour le scheduler (try_lock_for_publish,
mark_publish_attempt_failed, backoff policy en Rust) mais aucune Tauri
command ne les appelait —
#[allow(dead_code)]partout. Sans la backgroundtask de cette PR, l'auto-publish v0.4.0 n'existe pas du point de vue utilisateur.
Design
Trois couches volontairement minces dans
src-tauri/src/scheduler.rs:tick(pool, dispatcher, notifier)— un tour de polling. Litdb::scheduler::list_due_for_publishet route chaque post viaprocess_post. AucunAppHandle— testable contre une SQLite in-memory.process_post—try_lock_for_publish, hand-off dispatcher,mark_publish_attempt_failedsur Err. Le statut'published'est mis àjour par
publish_post/publish_linkedin_postexistants — pas deduplication.
spawn(handle)— seul point qui voit leAppHandleréel. Détacheun tokio task qui poll toutes les 60s. Lives for the process lifetime.
Pourquoi des traits (PublishDispatcher / Notifier) ?
Le runtime Tauri ne se monte pas à bas coût dans un
#[tokio::test]. Lestraits donnent une seam pour mocker dispatcher + notifier dans les tests sans
toucher au code de production (les impls Tauri sont des shells minces).
Pourquoi pas de timer in-Rust pour les retries ?
Tout le retry timing vit dans
BACKOFF_MINUTES, re-lu parlist_due_for_publishà chaque tick. Le scheduler ne tient pas d'état entre les ticks et survit aux
redémarrages sans rien perdre — la source de vérité est sur disque.
Pre-check token expiry
accounts.token_expires_at(migration 014) est consulté avant chaque dispatch.Token expiré → erreur claire au lieu d'un 401 générique de Meta/LinkedIn. La
tentative compte comme un échec dans le budget retry, donc l'utilisateur finit
par recevoir la notification GaveUp même sur un token définitivement mort.
Evidence
Tests scheduler (
src-tauri/src/scheduler.rs, 9 nouveaux) :tick_with_no_due_posts_is_noop— pas de dispatch sans posts duetick_publishes_overdue_draft_and_flips_status— E2E F2 headlinetick_records_failure_and_returns_to_draft_until_thresholdtick_skips_already_locked_post— protection race multi-instancerepeated_failures_eventually_notify_user— GaveUp → notification 1×tick_respects_backoff_window_between_failures— pas de re-tick immédiatpre_check_token_expiry_blocks_expired_accountpre_check_token_expiry_allows_account_with_future_expirypre_check_token_expiry_falls_open_for_legacy_post_with_no_accountOut of scope
reschedule via
reset_retry_state)last_attempt_atune fois dansrow_to_post_record(→Option<DateTime<Utc>>) touche frontend serde,mérite PR dédiée comme noté dans la review fix(scheduler): status guards + RETURNING + single backoff source (Sourcery on #69) #70
Test plan
npm run typecheckOK localement)🤖 Generated with Claude Code
Summary by Sourcery
Ajouter un planificateur d’arrière‑plan qui publie automatiquement, à intervalles réguliers, les brouillons arrivés à échéance via les commandes de publication existantes et remonte les erreurs critiques à l’utilisateur.
New Features:
Enhancements:
Build:
tauri-plugin-notificationetasync-traitpour le planificateur et l’intégration des notifications.Tests:
Original summary in English
Summary by Sourcery
Add a background scheduler that periodically auto-publishes due drafts via existing publish commands and surfaces hard failures to the user.
New Features:
Enhancements:
Build:
Tests: