M4: sync pull and push (E-31, E-32), audit writer and diff, FCM sender, push batching, scheduler, pruning, rotate-epoch
- src/sync/: pull with UNION ALL paging and cursor_ahead; push processing of SPEC §14.4.4 steps 1 to 12 in one BEGIN IMMEDIATE with a savepoint per mutation, LWW with delete-wins, tombstones, clock_skew, replay from sync_mutations, sync_batch for more than 3 notifiable changes; 90-day pruning. - src/audit/: SqlAuditWriter and the §14.4.7 diff (now the default in AppState::new). - src/push/: bounded PushHub (100, drop oldest), FCM HTTP v1 client with service-account JWT (RS256, jsonwebtoken on aws-lc-rs) and token cache, sender with 1/5/25 s retries and token clearing; disabled without credentials. - src/tasks/: start installs push and spawns the 06:00 generation (and at start) and 04:00 pruning loops on the injectable clock. - cli: rotate-epoch; serve uses into_make_service_with_connect_info; stale doc comments updated; /i/ empty code gets the HTML 404. - tests: sync_api, push_fcm (local stub of Google's endpoints), scheduler_daily; skeleton routes test now asserts no route is 501. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,11 +1,82 @@
|
||||
//! Background tasks started by `serve`: the recurring scheduler (SPEC §9.5), the push sender (§14.6) and
|
||||
//! `sync_mutations` pruning (§14.1). M4 owns `src/tasks/**`; in M2a this starts nothing.
|
||||
//! Background tasks started by `serve` (SPEC §10.4, D-015): the push sender (SPEC §14.6), the recurring scheduler at
|
||||
//! start and daily 06:00 Europe/Stockholm (§9.5) and the `sync_mutations` pruning daily at 04:00 (§14.1, D-021).
|
||||
|
||||
pub mod scheduler;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::error::AppError;
|
||||
use crate::push::{Endpoints, FcmClient, NoopPushQueue, PushHub, PushQueue, RetryPolicy, Sender};
|
||||
|
||||
/// Starts the background tasks (spawned on the runtime; this returns once they are started).
|
||||
pub async fn start(state: AppState) -> Result<(), AppError> {
|
||||
let _ = state;
|
||||
Ok(())
|
||||
/// The push queue for `state`: the real queue with its sender spawned when the credentials file exists and parses,
|
||||
/// otherwise push disabled with one warning (SPEC §10.4). Never fails start-up.
|
||||
pub fn start_push(
|
||||
state: &AppState,
|
||||
endpoints: Endpoints,
|
||||
retry: RetryPolicy,
|
||||
) -> Arc<dyn PushQueue> {
|
||||
let config = &state.config;
|
||||
match FcmClient::from_file(
|
||||
&config.fcm_credentials,
|
||||
&config.fcm_project_id,
|
||||
endpoints,
|
||||
state.clock.clone(),
|
||||
) {
|
||||
Ok(Some(client)) => {
|
||||
let hub = Arc::new(PushHub::new());
|
||||
let sender = Sender::new(state.db.clone(), hub.clone(), client, retry);
|
||||
tokio::spawn(sender.run());
|
||||
tracing::info!(project = %config.fcm_project_id, "push enabled");
|
||||
hub
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
path = %config.fcm_credentials.display(),
|
||||
"FCM credentials file missing: push is disabled"
|
||||
);
|
||||
Arc::new(NoopPushQueue)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %config.fcm_credentials.display(),
|
||||
detail = %e.message,
|
||||
"FCM credentials unusable: push is disabled"
|
||||
);
|
||||
Arc::new(NoopPushQueue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the background tasks and returns `state` with the real push queue installed (the router must be built
|
||||
/// from the returned state). The tasks are spawned on the runtime; this returns once they are started.
|
||||
pub async fn start(mut state: AppState) -> Result<AppState, AppError> {
|
||||
state.push = start_push(&state, Endpoints::google(), RetryPolicy::default());
|
||||
|
||||
let generation = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let clock = generation.clock.clone();
|
||||
scheduler::run_daily(
|
||||
clock,
|
||||
scheduler::GENERATION_TIME,
|
||||
true,
|
||||
scheduler::POLL_INTERVAL,
|
||||
|| scheduler::generation_job(&generation),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let pruning = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let clock = pruning.clock.clone();
|
||||
scheduler::run_daily(
|
||||
clock,
|
||||
scheduler::PRUNE_TIME,
|
||||
false,
|
||||
scheduler::POLL_INTERVAL,
|
||||
|| scheduler::prune_job(&pruning),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Daily jobs at a local Europe/Stockholm time (SPEC §9.5, §14.1, D-021), DST-aware, with an injectable clock.
|
||||
//!
|
||||
//! The loop never trusts a single long sleep: it sleeps at most [`POLL_INTERVAL`] and re-reads the clock, so a clock
|
||||
//! that jumps forward (NTP after boot on the Pi, which has no RTC) runs a missed job once at the next poll, and a
|
||||
//! clock that jumps backward more than a day re-plans the next run instead of waiting for the old instant.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use chrono::{DateTime, Days, Duration, NaiveTime, TimeZone, Utc};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::api::recurring;
|
||||
use crate::error::AppError;
|
||||
use crate::sync;
|
||||
use crate::time::{Clock, STOCKHOLM};
|
||||
|
||||
/// Local time of the recurring generation (SPEC §9.5).
|
||||
pub const GENERATION_TIME: NaiveTime = recurring::GENERATION_TIME;
|
||||
/// Local time of the `sync_mutations` pruning (D-021).
|
||||
pub const PRUNE_TIME: NaiveTime = match NaiveTime::from_hms_opt(4, 0, 0) {
|
||||
Some(t) => t,
|
||||
None => NaiveTime::MIN,
|
||||
};
|
||||
/// Longest sleep between two looks at the clock.
|
||||
pub const POLL_INTERVAL: StdDuration = StdDuration::from_secs(30);
|
||||
|
||||
/// The first instant strictly after `after` whose Europe/Stockholm wall time is `at`. On a day where `at` falls into
|
||||
/// a DST gap (never the case for 04:00 or 06:00 in Stockholm) that day is skipped; in an overlap the earlier instant
|
||||
/// is used.
|
||||
pub fn next_run(after: DateTime<Utc>, at: NaiveTime) -> Result<DateTime<Utc>, AppError> {
|
||||
let mut date = after.with_timezone(&STOCKHOLM).date_naive();
|
||||
// Today, tomorrow, and two spare days for a gap.
|
||||
for _ in 0..4 {
|
||||
if let Some(local) = STOCKHOLM.from_local_datetime(&date.and_time(at)).earliest() {
|
||||
let instant = local.with_timezone(&Utc);
|
||||
if instant > after {
|
||||
return Ok(instant);
|
||||
}
|
||||
}
|
||||
date = date
|
||||
.checked_add_days(Days::new(1))
|
||||
.ok_or_else(|| AppError::internal("date overflow planning the next run"))?;
|
||||
}
|
||||
Err(AppError::internal("no next run within four days"))
|
||||
}
|
||||
|
||||
/// Runs `job` once at start (if `run_at_start`) and then every day at local `at`, reading "now" from `clock`.
|
||||
/// Never returns. `poll` is [`POLL_INTERVAL`] in production; tests pass a few milliseconds.
|
||||
pub async fn run_daily<F, Fut>(
|
||||
clock: Arc<dyn Clock>,
|
||||
at: NaiveTime,
|
||||
run_at_start: bool,
|
||||
poll: StdDuration,
|
||||
mut job: F,
|
||||
) where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
if run_at_start {
|
||||
job().await;
|
||||
}
|
||||
let mut due = plan(clock.now(), at).await;
|
||||
loop {
|
||||
let now = clock.now();
|
||||
if now >= due {
|
||||
job().await;
|
||||
due = plan(clock.now().max(due), at).await;
|
||||
continue;
|
||||
}
|
||||
if due - now > Duration::days(1) + Duration::hours(2) {
|
||||
// The clock went backward: plan from the new now.
|
||||
due = plan(now, at).await;
|
||||
continue;
|
||||
}
|
||||
let wait = (due - now).to_std().unwrap_or(StdDuration::ZERO).min(poll);
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`next_run`], retried after a poll interval if it fails (it cannot for valid times; logged, never panics).
|
||||
async fn plan(after: DateTime<Utc>, at: NaiveTime) -> DateTime<Utc> {
|
||||
match next_run(after, at) {
|
||||
Ok(instant) => instant,
|
||||
Err(e) => {
|
||||
tracing::error!(detail = %e.message, "cannot plan the next daily run");
|
||||
after + Duration::days(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 06:00 job: M3's generation for every non-deleted, non-paused template (SPEC §9.5). Errors are logged; the
|
||||
/// next run retries (generation is idempotent).
|
||||
pub async fn generation_job(state: &AppState) {
|
||||
match recurring::generate_all_due(state).await {
|
||||
Ok(created) => tracing::info!(created, "recurring generation done"),
|
||||
Err(e) => tracing::warn!(
|
||||
error_code = e.code.as_str(),
|
||||
detail = %e.message,
|
||||
"recurring generation failed"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 04:00 job: delete `sync_mutations` rows older than 90 days (SPEC §14.1, D-021).
|
||||
pub async fn prune_job(state: &AppState) {
|
||||
match sync::prune_mutations(&state.db, state.clock.now()).await {
|
||||
Ok(deleted) => tracing::info!(deleted, "sync_mutations pruned"),
|
||||
Err(e) => tracing::warn!(
|
||||
error_code = e.code.as_str(),
|
||||
detail = %e.message,
|
||||
"sync_mutations pruning failed"
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user