fix: re-baseline reconcile when Email/changes no longer replays
A stale sinceState made every later run retry the same dead state and reconcile:state froze forever, so reconcile answered 503 with no forward progress. Re-baseline to a freshly fetched state at position 0 instead of failing: the position walk never depends on server-side changelog retention, and the 24h dedup key keeps the re-read at-most-duplicate. Closes opengaps.md item 1 without a downtime window.
This commit is contained in:
parent
4cff3a7cee
commit
269c8f6304
145
src/worker.rs
145
src/worker.rs
|
|
@ -335,11 +335,29 @@ impl<B: JmapBackend> WorkerHandler for MetadataWorker<B> {
|
||||||
return if pages == 0 { Err(()) } else { Ok(cursor) };
|
return if pages == 0 { Err(()) } else { Ok(cursor) };
|
||||||
}
|
}
|
||||||
pages += 1;
|
pages += 1;
|
||||||
let changes = self
|
let changes = match self.jmap.email_changes(&cursor, window).await {
|
||||||
.jmap
|
Ok(changes) => changes,
|
||||||
.email_changes(&cursor, window)
|
Err(_) => {
|
||||||
.await
|
// Re-baseline instead of failing the invocation. jmap-client
|
||||||
.map_err(|_| ())?;
|
// 0.4.2 exposes no reset signal: `ChangesResponse` only
|
||||||
|
// carries `oldState`/`newState`/`hasMoreChanges`/`created`/
|
||||||
|
// `updated`/`destroyed`, so a `/changes` failure is
|
||||||
|
// indistinguishable from "the server no longer replays from
|
||||||
|
// this `sinceState`". Returning `Err(())` leaves the cursor
|
||||||
|
// untouched, so every later invocation retries the same dead
|
||||||
|
// state and `reconcile:state` freezes forever (a persistent
|
||||||
|
// 503 `reconcile_retry` with no forward progress). The
|
||||||
|
// position walk above does not depend on server-side
|
||||||
|
// changelog retention at all, so re-baselining to a fresh
|
||||||
|
// state at position 0 recovers on the next run. The 24h
|
||||||
|
// dedup key makes the resulting re-read at-most-duplicate,
|
||||||
|
// never-loss.
|
||||||
|
return Ok(encode_baseline_cursor(
|
||||||
|
&self.jmap.current_state().await.map_err(|_| ())?,
|
||||||
|
0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
for email_id in changes.created.iter().chain(changes.updated.iter()) {
|
for email_id in changes.created.iter().chain(changes.updated.iter()) {
|
||||||
self.enqueue_reconcile_event(email_id).await?;
|
self.enqueue_reconcile_event(email_id).await?;
|
||||||
}
|
}
|
||||||
|
|
@ -1104,3 +1122,120 @@ mod reload_tests {
|
||||||
assert_eq!(format_received_at(unix, 25 * 3600), None);
|
assert_eq!(format_received_at(unix, 25 * 3600), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod reconcile_rebaseline_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::domain::jmap::{EmailChanges, EmailContent, EmailMetadata, Folder, JmapError};
|
||||||
|
use crate::state::{runtime_provider, MemoryState, OutboundConfig};
|
||||||
|
use secrecy::SecretString;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// A backend whose `Email/changes` replay is already invalid while position
|
||||||
|
/// paging still works: `current_state()` succeeds, `list_emails_page`
|
||||||
|
/// succeeds, `/changes` fails.
|
||||||
|
struct StaleChangesBackend {
|
||||||
|
changes_calls: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JmapBackend for StaleChangesBackend {
|
||||||
|
async fn create_push_subscription(&self, _callback_url: &str) -> Result<String, JmapError> {
|
||||||
|
unreachable!("not exercised")
|
||||||
|
}
|
||||||
|
async fn destroy_push_subscription(&self, _subscription_id: &str) -> Result<(), JmapError> {
|
||||||
|
unreachable!("not exercised")
|
||||||
|
}
|
||||||
|
async fn verify_push_subscription(
|
||||||
|
&self,
|
||||||
|
_subscription_id: &str,
|
||||||
|
_verification_code: &str,
|
||||||
|
) -> Result<(), JmapError> {
|
||||||
|
unreachable!("not exercised")
|
||||||
|
}
|
||||||
|
async fn current_state(&self) -> Result<String, JmapError> {
|
||||||
|
Ok("state-fresh".to_string())
|
||||||
|
}
|
||||||
|
async fn email_changes(
|
||||||
|
&self,
|
||||||
|
_account_id: &str,
|
||||||
|
_since_state: &str,
|
||||||
|
_max_changes: usize,
|
||||||
|
) -> Result<EmailChanges, JmapError> {
|
||||||
|
self.changes_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Err(JmapError::InvalidRequest("email changes"))
|
||||||
|
}
|
||||||
|
async fn list_folders(&self, _account_id: &str) -> Result<Vec<Folder>, JmapError> {
|
||||||
|
unreachable!("not exercised")
|
||||||
|
}
|
||||||
|
async fn list_emails(
|
||||||
|
&self,
|
||||||
|
_account_id: &str,
|
||||||
|
_folder_id: Option<&str>,
|
||||||
|
_limit: usize,
|
||||||
|
) -> Result<Vec<EmailMetadata>, JmapError> {
|
||||||
|
unreachable!("not exercised")
|
||||||
|
}
|
||||||
|
async fn list_emails_page(
|
||||||
|
&self,
|
||||||
|
_account_id: &str,
|
||||||
|
_folder_id: Option<&str>,
|
||||||
|
_position: usize,
|
||||||
|
_limit: usize,
|
||||||
|
) -> Result<Vec<EmailMetadata>, JmapError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn read_email(
|
||||||
|
&self,
|
||||||
|
_account_id: &str,
|
||||||
|
_email_id: &str,
|
||||||
|
) -> Result<Option<EmailContent>, JmapError> {
|
||||||
|
unreachable!("not exercised")
|
||||||
|
}
|
||||||
|
async fn search_emails(
|
||||||
|
&self,
|
||||||
|
_account_id: &str,
|
||||||
|
_query: &str,
|
||||||
|
_limit: usize,
|
||||||
|
) -> Result<SearchResult, JmapError> {
|
||||||
|
unreachable!("not exercised")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frozen `baseline:` cursor whose server-side replay is stale must
|
||||||
|
/// re-baseline rather than wedge. `Err(())` used to leave `reconcile:state`
|
||||||
|
/// untouched, so every later run retried the same dead `sinceState` and
|
||||||
|
/// reconcile answered 503 forever. Asserting `Ok` pins the whole loop: the
|
||||||
|
/// state key gets a fresh cursor, so the next run restarts the walk.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconcile_rebaselines_when_changes_replay_is_stale() {
|
||||||
|
let changes_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let backend = StaleChangesBackend {
|
||||||
|
changes_calls: changes_calls.clone(),
|
||||||
|
};
|
||||||
|
let jmap = JmapService::new(backend, "account-x").expect("account id");
|
||||||
|
let telegram = TelegramClient::with_endpoint(
|
||||||
|
SecretString::from("token".to_string()),
|
||||||
|
runtime_provider(OutboundConfig::default()),
|
||||||
|
"http://127.0.0.1:1/".to_string(),
|
||||||
|
);
|
||||||
|
let state: Arc<dyn ReliableState> = Arc::new(MemoryState::enabled_for_tests());
|
||||||
|
let worker = MetadataWorker::new(jmap, telegram, 42, state, None, 0);
|
||||||
|
|
||||||
|
let stale = "baseline:7374616c65:98";
|
||||||
|
let result = worker.reconcile(Some(stale), 100).await;
|
||||||
|
|
||||||
|
let cursor = result.expect("a stale replay must re-baseline instead of failing the run");
|
||||||
|
assert_eq!(
|
||||||
|
decode_baseline_cursor(&cursor),
|
||||||
|
Some(("state-fresh".to_string(), 0)),
|
||||||
|
"the cursor must hold a freshly fetched state at position 0"
|
||||||
|
);
|
||||||
|
assert_ne!(&cursor, stale);
|
||||||
|
assert_eq!(
|
||||||
|
changes_calls.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"the stale state is attempted exactly once before re-baselining"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue