Federation: replicate content, re-derive judgement (UR-008)
Implements §9a. The replication surface is four reads and no writes: a change feed, fetch by content_id, a batch have, and a human-facing peer directory — plus a capabilities endpoint carrying the accepted envelope versions, which lets a client discover a schema mismatch in one request instead of a 400 per manifest across a library sweep. Pull, never push: a pulling server chooses what it ingests and when. Push would let any peer inject work into the validation queue — the same abuse surface as anonymous upload, at higher volume. Nothing inherits a peer's judgement. A pulled manifest runs the full §6 stage 1 and 2 validation and this server's own cast check, and the fetched body must hash to the content_id that was asked for — the check that stops an intermediary or a misbehaving peer substituting content under a trusted id. A peer's retraction flags for review rather than delisting, because auto-delisting would hand every peer a remote delete primitive; only the opt-in per-peer abuse channel delists, because a takedown propagating at the speed of manual review is the wrong failure mode for that one case. A test caught a real bug in the first cut: the feed cursor was a ULID, and ULIDs are only monotonic *between* milliseconds — two generated in the same millisecond carry independent random components and can sort opposite to write order. A peer resuming from `seq > cursor` would then silently skip an entry: replication losing manifests with no error anywhere. The cursor is now an AUTOINCREMENT integer, and the test asserts strict monotonicity rather than merely sortedness. Peer administration is deliberately not an API. §9a requires that a peering exist only because an operator typed a URL, so nothing a remote server returns can establish or widen one; there_is_no_endpoint_that_creates_a_peering asserts that absence rather than trusting it. 212 tests. Coverage 25/32 (78%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: UR-008 | PR-006
This commit is contained in:
@@ -26,6 +26,9 @@ pub struct Worker {
|
||||
pub tmdb: Arc<TmdbClient>,
|
||||
pub batch: usize,
|
||||
pub poll_interval: Duration,
|
||||
/// Stamped as `origin` on this server's own change-feed entries (§9a), so a
|
||||
/// peer can tell what a manifest originated from and ignore its own echoes.
|
||||
pub server_id: String,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
@@ -62,6 +65,9 @@ impl Worker {
|
||||
for job in jobs {
|
||||
let result = match job.kind.as_str() {
|
||||
JOB_CAST_CHECK => self.run_cast_check(&job.payload).await,
|
||||
crate::federation::JOB_FEDERATION_PULL => {
|
||||
self.run_federation_pull(&job.payload).await
|
||||
}
|
||||
other => {
|
||||
tracing::warn!(kind = other, "unknown job kind, dropping");
|
||||
Ok(())
|
||||
@@ -93,6 +99,54 @@ impl Worker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pulls one peer's change feed and ingests what is new (§9a).
|
||||
///
|
||||
/// Transport failures are retryable: a peer being down is not a verdict on
|
||||
/// its content, exactly as a TMDB outage is not a verdict on an upload.
|
||||
///
|
||||
/// TRACES: UR-008 | PR-006
|
||||
async fn run_federation_pull(&self, payload: &str) -> Result<(), JobError> {
|
||||
let job: crate::federation::FederationPullJob =
|
||||
serde_json::from_str(payload).map_err(|e| JobError::Fatal(e.into()))?;
|
||||
|
||||
let peer_id = job.peer_id.clone();
|
||||
let peer = self
|
||||
.db
|
||||
.read(move |conn| repo::peer_by_id(conn, &peer_id))
|
||||
.await
|
||||
.map_err(JobError::Fatal)?;
|
||||
|
||||
// A peer removed or disabled between enqueue and run is not an error.
|
||||
let Some(peer) = peer.filter(|p| p.enabled) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let puller = crate::federation::Puller::new(self.server_id.clone());
|
||||
let now = now_iso();
|
||||
match puller.pull(&self.db, &peer, &now).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
peer = %peer.url, examined = outcome.examined, ingested = outcome.ingested,
|
||||
known = outcome.skipped_known, flagged = outcome.flagged,
|
||||
rejected = outcome.rejected, capped = outcome.capped,
|
||||
"federation pull complete"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
let pid = peer.id.clone();
|
||||
let now2 = now.clone();
|
||||
let m2 = msg.clone();
|
||||
let _ = self
|
||||
.db
|
||||
.write(move |tx| repo::record_pull(tx, &pid, None, &now2, Some(&m2)))
|
||||
.await;
|
||||
Err(JobError::Retry(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: UR-003, UR-005 | SR-004
|
||||
async fn run_cast_check(&self, payload: &str) -> Result<(), JobError> {
|
||||
let job: CastCheckJob =
|
||||
@@ -286,6 +340,7 @@ impl Worker {
|
||||
matched.iter().map(|m| (m.tmdb_person_id, m.name.clone(), m.adult)).collect();
|
||||
let unmatched = unmatched.to_vec();
|
||||
let now = now_iso();
|
||||
let server_id = self.server_id.clone();
|
||||
|
||||
self.db
|
||||
.write(move |tx| {
|
||||
@@ -317,6 +372,17 @@ impl Worker {
|
||||
reason.as_deref(),
|
||||
Some(ratio),
|
||||
)?;
|
||||
|
||||
// §9a: the change feed carries locally-*listed* manifests
|
||||
// only. A `flagged` one is served with reduced ranking here
|
||||
// but is not offered for replication — nominating something
|
||||
// this server itself doubts would push a local judgement
|
||||
// call outward, which is exactly what federation must not do.
|
||||
if verdict == Verdict::Listed {
|
||||
if let Some(cid) = repo::content_id_of(tx, &id)? {
|
||||
repo::append_change(tx, &cid, "add", None, &server_id, &now)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(c) = contributor {
|
||||
@@ -391,6 +457,11 @@ pub fn iso_in(secs: u64) -> String {
|
||||
format_unix(unix_now() + secs)
|
||||
}
|
||||
|
||||
/// A timestamp `secs` in the past, for windowed counts (§9a `MaxIngestPerHour`).
|
||||
pub fn iso_ago(secs: u64) -> String {
|
||||
format_unix(unix_now().saturating_sub(secs))
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
Reference in New Issue
Block a user