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:
2026-10-17 22:27:23 +02:00
parent 853c038c58
commit 15056dc7df
37 changed files with 6312 additions and 102 deletions
+77 -6
View File
@@ -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"
),
}
}