Refactor: Implement oneliner poller background task
This commit is contained in:
@@ -1,26 +1,536 @@
|
||||
//! Placeholder — full implementation follows in next step.
|
||||
//! Background poller for `GET /api/oneliners`.
|
||||
//!
|
||||
//! Hits the server every `poll_interval` with `If-None-Match`, merges
|
||||
//! 200-responses into the case store, persists each fresh response into
|
||||
//! the snapshot cache, and survives transient errors by logging + waiting
|
||||
//! for the next tick.
|
||||
//!
|
||||
//! The polling logic is factored into a standalone `poll_once` method on
|
||||
//! [`PollerState`] so tests can drive a single cycle without wall-clock
|
||||
//! timing. The [`OnelinerPoller`] handle just owns an [`AbortHandle`]:
|
||||
//! dropping the handle aborts the task.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use doctate_common::oneliners::{OnelinersResponse, ONELINERS_PATH};
|
||||
use doctate_common::API_KEY_HEADER;
|
||||
use reqwest::StatusCode;
|
||||
use tokio::task::AbortHandle;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::case_store::CaseStore;
|
||||
use crate::snapshot_cache::{CachedSnapshot, SnapshotCache};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PollerConfig {
|
||||
pub server_url: String,
|
||||
pub api_key: String,
|
||||
pub poll_interval: std::time::Duration,
|
||||
pub poll_interval: Duration,
|
||||
pub window_hours: u32,
|
||||
}
|
||||
|
||||
/// Handle that aborts the background task when dropped.
|
||||
pub struct OnelinerPoller {
|
||||
_abort: tokio::task::AbortHandle,
|
||||
abort: AbortHandle,
|
||||
/// Timestamp of the last successful 200 or 304 response. UI reads
|
||||
/// this via `last_success` to render a staleness indicator.
|
||||
last_success: Arc<std::sync::Mutex<Option<Instant>>>,
|
||||
}
|
||||
|
||||
impl OnelinerPoller {
|
||||
/// To be implemented.
|
||||
#[doc(hidden)]
|
||||
pub fn from_handle(abort: tokio::task::AbortHandle) -> Self {
|
||||
Self { _abort: abort }
|
||||
/// Start the background polling task. Returns immediately.
|
||||
///
|
||||
/// Expects a Tokio runtime context (call inside `Runtime::enter()`
|
||||
/// or `#[tokio::main]`).
|
||||
pub fn spawn(
|
||||
http: Arc<reqwest::Client>,
|
||||
config: PollerConfig,
|
||||
store: Arc<CaseStore>,
|
||||
cache: Arc<SnapshotCache>,
|
||||
) -> Self {
|
||||
let last_success = Arc::new(std::sync::Mutex::new(None));
|
||||
let last_success_for_task = last_success.clone();
|
||||
let handle = tokio::spawn(run_loop(
|
||||
http,
|
||||
config,
|
||||
store,
|
||||
cache,
|
||||
last_success_for_task,
|
||||
));
|
||||
Self {
|
||||
abort: handle.abort_handle(),
|
||||
last_success,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Some(Instant)` if the poller has ever received a 200 or 304
|
||||
/// response; `None` on cold start with no successful poll yet.
|
||||
pub fn last_success(&self) -> Option<Instant> {
|
||||
*self.last_success.lock().expect("poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OnelinerPoller {
|
||||
fn drop(&mut self) {
|
||||
self._abort.abort();
|
||||
self.abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Task loop: load cache, then poll every `poll_interval` until aborted.
|
||||
async fn run_loop(
|
||||
http: Arc<reqwest::Client>,
|
||||
config: PollerConfig,
|
||||
store: Arc<CaseStore>,
|
||||
cache: Arc<SnapshotCache>,
|
||||
last_success: Arc<std::sync::Mutex<Option<Instant>>>,
|
||||
) {
|
||||
info!(
|
||||
interval_s = config.poll_interval.as_secs(),
|
||||
window_h = config.window_hours,
|
||||
"oneliner poller started"
|
||||
);
|
||||
|
||||
let mut state = PollerState {
|
||||
http,
|
||||
config,
|
||||
store,
|
||||
cache,
|
||||
etag: None,
|
||||
};
|
||||
|
||||
// Prime the store + ETag from disk cache before the first request.
|
||||
state.prime_from_cache().await;
|
||||
|
||||
// Fire an immediate poll so the UI doesn't wait a full interval.
|
||||
tick(&mut state, &last_success).await;
|
||||
|
||||
let mut interval = tokio::time::interval(state.config.poll_interval);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
// The first tick is immediate (already covered above); consume it.
|
||||
interval.tick().await;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
tick(&mut state, &last_success).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(
|
||||
state: &mut PollerState,
|
||||
last_success: &Arc<std::sync::Mutex<Option<Instant>>>,
|
||||
) {
|
||||
match state.poll_once().await {
|
||||
Ok(outcome) => {
|
||||
if matches!(outcome, PollOutcome::Success | PollOutcome::NotModified) {
|
||||
*last_success.lock().expect("poisoned") = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "poll error — will retry on next tick");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PollOutcome {
|
||||
/// Server returned 200 with a fresh snapshot — merged into the store.
|
||||
Success,
|
||||
/// Server returned 304 Not Modified — no change.
|
||||
NotModified,
|
||||
/// Transient HTTP failure (non-2xx non-304); retried next tick.
|
||||
TransientHttp(u16),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PollError {
|
||||
#[error("http: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("deserialize response: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
#[error("cache write: {0}")]
|
||||
Cache(std::io::Error),
|
||||
#[error("case store: {0}")]
|
||||
Store(#[from] crate::case_store::CaseStoreError),
|
||||
#[error("format timestamp: {0}")]
|
||||
Format(#[from] time::error::Format),
|
||||
}
|
||||
|
||||
/// Driver for a single poll cycle. Holds the long-lived `reqwest::Client`,
|
||||
/// the config (unchanging over the task's lifetime), the store and cache
|
||||
/// (shared with the UI), and the last ETag seen from the server.
|
||||
pub(crate) struct PollerState {
|
||||
http: Arc<reqwest::Client>,
|
||||
config: PollerConfig,
|
||||
store: Arc<CaseStore>,
|
||||
cache: Arc<SnapshotCache>,
|
||||
etag: Option<String>,
|
||||
}
|
||||
|
||||
impl PollerState {
|
||||
/// Load a cached snapshot (if any) from disk, merge it into the store
|
||||
/// and adopt its ETag. Runs once at task start so the UI sees data
|
||||
/// before the first network round-trip completes.
|
||||
async fn prime_from_cache(&mut self) {
|
||||
let Some(cached) = self.cache.load().await else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = self.store.merge_server_snapshot(&cached.response).await {
|
||||
warn!(error = %e, "priming store from cache failed");
|
||||
}
|
||||
self.etag = Some(cached.etag);
|
||||
debug!("store primed from cache");
|
||||
}
|
||||
|
||||
/// Issue one request, update store/cache/etag accordingly. Returns
|
||||
/// what happened so the caller can track success timestamps.
|
||||
pub(crate) async fn poll_once(&mut self) -> Result<PollOutcome, PollError> {
|
||||
let url = format!(
|
||||
"{}{}?hours={}",
|
||||
self.config.server_url.trim_end_matches('/'),
|
||||
ONELINERS_PATH,
|
||||
self.config.window_hours
|
||||
);
|
||||
|
||||
let mut req = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header(API_KEY_HEADER, &self.config.api_key);
|
||||
if let Some(etag) = &self.etag {
|
||||
req = req.header("If-None-Match", etag);
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
let status = resp.status();
|
||||
|
||||
match status {
|
||||
StatusCode::OK => {
|
||||
let new_etag = extract_etag(&resp);
|
||||
let body: OnelinersResponse = resp.json().await?;
|
||||
self.store.merge_server_snapshot(&body).await?;
|
||||
|
||||
if let Some(etag) = new_etag.clone() {
|
||||
let cached = CachedSnapshot {
|
||||
etag: etag.clone(),
|
||||
fetched_at: doctate_common::now_rfc3339(),
|
||||
response: body,
|
||||
};
|
||||
if let Err(e) = self.cache.store(&cached).await {
|
||||
warn!(error = %e, "snapshot cache write failed — continuing");
|
||||
}
|
||||
self.etag = Some(etag);
|
||||
} else {
|
||||
// Server without ETag: accept body but don't try
|
||||
// conditional requests. Unexpected from our own
|
||||
// server; defensive fallback.
|
||||
warn!("server returned 200 without ETag — conditional requests disabled");
|
||||
self.etag = None;
|
||||
}
|
||||
Ok(PollOutcome::Success)
|
||||
}
|
||||
StatusCode::NOT_MODIFIED => Ok(PollOutcome::NotModified),
|
||||
other => Ok(PollOutcome::TransientHttp(other.as_u16())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_etag(resp: &reqwest::Response) -> Option<String> {
|
||||
resp.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use doctate_common::oneliners::OnelinerEntry;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{header, header_exists, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const TEST_KEY: &str = "test-key-poller";
|
||||
const TEST_ETAG: &str = "W/\"42-72\"";
|
||||
|
||||
async fn build_state(
|
||||
tmp: &TempDir,
|
||||
server_uri: String,
|
||||
) -> (PollerState, Arc<CaseStore>, Arc<SnapshotCache>) {
|
||||
let (store_inner, _rx) = CaseStore::open(tmp.path().join("cases")).await.unwrap();
|
||||
let store = Arc::new(store_inner);
|
||||
let cache = Arc::new(SnapshotCache::new(tmp.path().join("cache.json")));
|
||||
let state = PollerState {
|
||||
http: Arc::new(reqwest::Client::new()),
|
||||
config: PollerConfig {
|
||||
server_url: server_uri,
|
||||
api_key: TEST_KEY.into(),
|
||||
poll_interval: Duration::from_secs(5),
|
||||
window_hours: 72,
|
||||
},
|
||||
store: store.clone(),
|
||||
cache: cache.clone(),
|
||||
etag: None,
|
||||
};
|
||||
(state, store, cache)
|
||||
}
|
||||
|
||||
fn response_body(case_id: &str, oneliner: Option<&str>) -> OnelinersResponse {
|
||||
OnelinersResponse {
|
||||
as_of: "2026-04-18T10:00:00Z".into(),
|
||||
window_hours: 72,
|
||||
oneliners: vec![OnelinerEntry {
|
||||
case_id: case_id.into(),
|
||||
oneliner: oneliner.map(str::to_owned),
|
||||
created_at: "2026-04-18T09:30:00Z".into(),
|
||||
updated_at: Some("2026-04-18T09:45:00Z".into()),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_on_200_merges_store_and_caches() {
|
||||
let server = MockServer::start().await;
|
||||
let case_id = "550e8400-e29b-41d4-a716-000000000001";
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ONELINERS_PATH))
|
||||
.and(query_param("hours", "72"))
|
||||
.and(header(API_KEY_HEADER, TEST_KEY))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("etag", TEST_ETAG)
|
||||
.set_body_json(response_body(case_id, Some("Kniegelenk"))),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (mut state, store, cache) = build_state(&tmp, server.uri()).await;
|
||||
assert_eq!(state.poll_once().await.unwrap(), PollOutcome::Success);
|
||||
|
||||
let list = store.list().await;
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].oneliner.as_deref(), Some("Kniegelenk"));
|
||||
assert!(list[0].synced_to_server);
|
||||
|
||||
let cached = cache.load().await.expect("cache written");
|
||||
assert_eq!(cached.etag, TEST_ETAG);
|
||||
assert_eq!(state.etag.as_deref(), Some(TEST_ETAG));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_sends_if_none_match_when_etag_known() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ONELINERS_PATH))
|
||||
.and(header("If-None-Match", TEST_ETAG))
|
||||
.respond_with(ResponseTemplate::new(304).insert_header("etag", TEST_ETAG))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (mut state, _store, _cache) = build_state(&tmp, server.uri()).await;
|
||||
state.etag = Some(TEST_ETAG.into());
|
||||
|
||||
assert_eq!(state.poll_once().await.unwrap(), PollOutcome::NotModified);
|
||||
// Mock's `.expect(1)` enforces the conditional header was sent.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_first_call_has_no_if_none_match() {
|
||||
let server = MockServer::start().await;
|
||||
let case_id = "550e8400-e29b-41d4-a716-000000000002";
|
||||
// Only a mock that does NOT require If-None-Match matches; if the
|
||||
// poller sent one, wiremock would return 404.
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ONELINERS_PATH))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("etag", TEST_ETAG)
|
||||
.set_body_json(response_body(case_id, Some("x"))),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (mut state, _store, _cache) = build_state(&tmp, server.uri()).await;
|
||||
assert!(state.etag.is_none());
|
||||
assert_eq!(state.poll_once().await.unwrap(), PollOutcome::Success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_on_304_does_not_overwrite_store() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(304).insert_header("etag", TEST_ETAG))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (mut state, store, cache) = build_state(&tmp, server.uri()).await;
|
||||
|
||||
// Seed store with a local marker — must survive a 304.
|
||||
let id = uuid::Uuid::new_v4();
|
||||
store
|
||||
.create_local(
|
||||
id,
|
||||
time::OffsetDateTime::parse(
|
||||
"2026-04-18T10:00:00Z",
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.poll_once().await.unwrap(), PollOutcome::NotModified);
|
||||
assert_eq!(store.list().await.len(), 1);
|
||||
assert!(cache.load().await.is_none(), "304 must not create cache");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_returns_transient_on_500() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (mut state, _store, _cache) = build_state(&tmp, server.uri()).await;
|
||||
assert_eq!(
|
||||
state.poll_once().await.unwrap(),
|
||||
PollOutcome::TransientHttp(500)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_returns_transient_on_401() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (mut state, _store, _cache) = build_state(&tmp, server.uri()).await;
|
||||
// 401 bubbles up as TransientHttp so the task keeps running; the
|
||||
// user surfaces the wrong-API-key state via the config panel, not
|
||||
// via a fatal poller crash.
|
||||
assert_eq!(
|
||||
state.poll_once().await.unwrap(),
|
||||
PollOutcome::TransientHttp(401)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_once_propagates_network_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Unbound address → connection refused.
|
||||
let (mut state, _store, _cache) =
|
||||
build_state(&tmp, "http://127.0.0.1:1".into()).await;
|
||||
let err = state.poll_once().await.unwrap_err();
|
||||
assert!(matches!(err, PollError::Http(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prime_from_cache_seeds_store_and_etag() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (mut state, store, cache) = build_state(&tmp, "http://unused".into()).await;
|
||||
|
||||
let case_id = "550e8400-e29b-41d4-a716-000000000003";
|
||||
cache
|
||||
.store(&CachedSnapshot {
|
||||
etag: TEST_ETAG.into(),
|
||||
fetched_at: "2026-04-18T10:00:00Z".into(),
|
||||
response: response_body(case_id, Some("cached")),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
state.prime_from_cache().await;
|
||||
assert_eq!(state.etag.as_deref(), Some(TEST_ETAG));
|
||||
let list = store.list().await;
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].oneliner.as_deref(), Some("cached"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_and_drop_aborts_task() {
|
||||
let server = MockServer::start().await;
|
||||
// Respond to any GET so the loop has work to do.
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("etag", TEST_ETAG)
|
||||
.set_body_json(response_body(
|
||||
"550e8400-e29b-41d4-a716-000000000004",
|
||||
Some("x"),
|
||||
)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store_inner, _rx) = CaseStore::open(tmp.path().join("cases")).await.unwrap();
|
||||
let store = Arc::new(store_inner);
|
||||
let cache = Arc::new(SnapshotCache::new(tmp.path().join("cache.json")));
|
||||
let poller = OnelinerPoller::spawn(
|
||||
Arc::new(reqwest::Client::new()),
|
||||
PollerConfig {
|
||||
server_url: server.uri(),
|
||||
api_key: TEST_KEY.into(),
|
||||
poll_interval: Duration::from_millis(50),
|
||||
window_hours: 72,
|
||||
},
|
||||
store.clone(),
|
||||
cache,
|
||||
);
|
||||
|
||||
// Let the immediate first poll fire.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert!(poller.last_success().is_some(), "expected at least one poll");
|
||||
assert_eq!(store.list().await.len(), 1);
|
||||
|
||||
// Drop aborts the task; subsequent store inspection must not
|
||||
// race with a concurrent merge — asserting no panic is enough.
|
||||
drop(poller);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_with_matching_mock_requires_api_key_header() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(ONELINERS_PATH))
|
||||
.and(header_exists(API_KEY_HEADER))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("etag", TEST_ETAG)
|
||||
.set_body_json(response_body(
|
||||
"550e8400-e29b-41d4-a716-000000000005",
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let (store_inner, _rx) = CaseStore::open(tmp.path().join("cases")).await.unwrap();
|
||||
let store = Arc::new(store_inner);
|
||||
let cache = Arc::new(SnapshotCache::new(tmp.path().join("cache.json")));
|
||||
let _poller = OnelinerPoller::spawn(
|
||||
Arc::new(reqwest::Client::new()),
|
||||
PollerConfig {
|
||||
server_url: server.uri(),
|
||||
api_key: TEST_KEY.into(),
|
||||
poll_interval: Duration::from_secs(10),
|
||||
window_hours: 72,
|
||||
},
|
||||
store.clone(),
|
||||
cache,
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
// Mock's `.expect(1)` is verified when `server` drops at end of test.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user