0011-fediverse-outbound-worker.md 9934 octets

RFC 0011 — Fediverse: file outbound + worker (slice Phase 2 minimal)

  • Auteur : EBO (rustwarden)
  • Date : 2026-05-09
  • Statut : Accepted (auto-revue mainteneur unique 2026-05-09, score 26/35)
  • Apps consommatrices concernées : toute app utilisant rustwarden-core qui doit émettre des activités ActivityPub vers des inbox distantes (auth Accept{AuthRequest}, invitations, signaux lifecycle).
  • Plan associé : docs/plans/2026-05-09-fediverse-identity-federation.md, Phases 2 → 4 (livraison incrémentale).
  • Dépendances : RFC 0001 (FediverseConfig), RFC 0005 (clés signature, livraison réelle reportée).

Summary

Introduire la table fediverse_outbound_queue (PK id, activity_id, payload_json, target_inbox, target_domain, status, attempts, next_retry_at, …) et le service FediverseOutboundService exposant l'API d'enqueue (enqueue_activity) + une boucle worker OutboundWorker qui draine la file. Le transport HTTP signé est délégué à un trait OutboundDelivery injectable, dont l'implémentation NoopDelivery (loguer + marquer livré) est livrée par cette RFC ; l'impl HttpSignedDelivery réelle arrivera avec RFC 0010.

Motivation

Sans cette infrastructure, la RFC 0006 (ap-native-auth-flow) n'a pas de canal asynchrone fiable pour livrer son Accept{AuthRequest} au client b.tld après que l'utilisateur a cliqué Approve sur a.tld. Une livraison synchrone bloque le handler de consent jusqu'à 30 s + impose un retry naïf en mémoire qui se perd au redémarrage.

Sans cette RFC, on bloque aussi RFC 0009 (invitations cross-instance) et RFC 0010 (signaux Delete{Actor}).

Non-goals (slice minimal Phase 2)

  • Pas de signature HTTP RFC 9421 dans cette RFC. Le trait OutboundDelivery sépare la concern transport ; l'implémentation HttpSignedDelivery réelle est livrée par RFC 0010 avec les clés acteur de RFC 0005.
  • Pas de stratégie retry/backoff complète (5 min → 48 h). Cette RFC livre seulement : pending → delivered ou failed. Le multi-step retry / next_retry_at recalculé par code HTTP arrive avec RFC 0010 dans une 2nde extension.
  • Pas de UI admin pour visualiser/replay la file — rustwarden-ui traitera plus tard.
  • Pas de poison-detection (instances mortes) — différé.

API proposée

Migration m20260510_000012_fediverse_outbound_queue

CREATE TABLE fediverse_outbound_queue (
    id UUID PRIMARY KEY,
    activity_id TEXT NOT NULL,
    payload_json TEXT NOT NULL,
    target_inbox TEXT NOT NULL,
    target_domain TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',  -- pending|delivered|failed|dead
    attempts INTEGER NOT NULL DEFAULT 0,
    max_attempts INTEGER NOT NULL DEFAULT 8,
    last_error TEXT NULL,
    next_retry_at TIMESTAMPTZ NULL,
    created_at TIMESTAMPTZ NOT NULL,
    delivered_at TIMESTAMPTZ NULL,
    actor_user_id UUID NULL                  -- signataire (RFC 0010 pour HTTP-Sig)
);
CREATE INDEX fediverse_outbound_pending_idx
    ON fediverse_outbound_queue (status, next_retry_at)
    WHERE status = 'pending';

Énumération + types

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutboundStatus { Pending, Delivered, Failed, Dead }

#[derive(Debug, Clone)]
pub struct EnqueueRequest {
    pub activity_id: String,
    pub payload_json: String,
    pub target_inbox: String,
    pub target_domain: String,
    pub actor_user_id: Option<Uuid>,
}

Trait pluggable

/// Transport layer for outbound activities. Decoupling lets RFC 0010
/// swap a real HTTP-Sig client in without touching the queue/worker.
#[async_trait]
pub trait OutboundDelivery: Send + Sync {
    /// POST `payload_json` to `target_inbox`. Implementations are
    /// responsible for HTTP signing, TLS, headers, body framing.
    async fn deliver(&self, item: &OutboundItem) -> Result<(), AppError>;
}

/// No-op delivery for the Phase-2 slice: logs and reports success so
/// the queue/worker plumbing can be tested end-to-end without the
/// real HTTP-Sig stack. Replaced by `HttpSignedDelivery` in RFC 0010.
pub struct NoopDelivery;

Service (enqueue + worker)

pub struct FediverseOutboundService;

impl FediverseOutboundService {
    pub async fn enqueue(
        db: &DatabaseConnection,
        request: EnqueueRequest,
    ) -> Result<fediverse_outbound_queue::Model, AppError>;

    /// Pop one pending item and deliver it via `transport`. Returns
    /// `Ok(Some(_))` if an item was processed, `Ok(None)` if the queue
    /// was empty.
    pub async fn drain_one<T: OutboundDelivery>(
        db: &DatabaseConnection,
        transport: &T,
    ) -> Result<Option<fediverse_outbound_queue::Model>, AppError>;
}

L'extraction du prochain item utilise SELECT ... ORDER BY created_at LIMIT 1 filtré sur status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= NOW()). La promotion pending → delivered|failed est atomique (UPDATE … WHERE id = $1 AND status = 'pending').

Worker

/// Background loop that drains the queue. Spawned by
/// `RustwardenBuilder::with_fediverse()` when the feature is on.
pub struct OutboundWorker<T: OutboundDelivery> {
    db: DatabaseConnection,
    transport: T,
    poll_interval: Duration,
}

impl<T: OutboundDelivery> OutboundWorker<T> {
    pub fn new(db: DatabaseConnection, transport: T, poll_interval: Duration) -> Self;

    /// Run forever (until cancellation). Yields the tokio runtime
    /// between polls. Errors during a single delivery are logged and
    /// the loop continues.
    pub async fn run(self);
}

Analyse de compatibilité

  • Non-breaking : nouvelle table, nouveau service, nouveaux items publics derrière feature.
  • Migration up/down réversible (DROP TABLE + DROP INDEX).
  • Aucune modification d'API publique existante.

Alternatives considérées

Alternative A (recommandée) — Table SeaORM + worker tokio + trait OutboundDelivery

Pros : aucune nouvelle dépendance externe ; intégration naturelle dans la config tokio existante ; trait isole le travail signature/HTTP futur.

Cons : un seul worker au démarrage (multi-shard plus tard).

Alternative B — apalis ou sidekiq-rs

Pros : retry/visibilité-timeout déjà gérés.

Cons : nouvelle dép externe, dépendance Redis pour apalis-redis. Décision §3.4 du master plan : « Pas d'apalis ».

Rejeté.

Alternative C — Livraison synchrone dans le handler

Cons : bloque la requête utilisateur jusqu'à la latence remote, perd les requêtes au redémarrage, pas de retry possible.

Rejeté.

Alternative D — Ne rien faire, livraison ad-hoc dans chaque service

Cons : duplication du code de retry, incohérence entre services, audit difficile.

Rejeté.

Testing plan

10 tests :

Pure :

  1. outbound_status_round_trips_through_string — OutboundStatus::parse(s) ↔ as_str().
  2. enqueue_request_clone_works.

DB-mocked (sea-orm/mock) : 3. enqueue_persists_pending_row — assert champ status = "pending", attempts = 0. 4. enqueue_uses_provided_activity_id — la valeur fournie est conservée verbatim. 5. drain_one_returns_none_when_empty. 6. drain_one_marks_delivered_on_success — NoopDelivery retourne Ok → row passe à delivered. 7. drain_one_marks_failed_on_error — un fake delivery qui retourne Err → row passe à failed. 8. drain_one_increments_attempts — attempts passe de 0 à 1 après une livraison.

Worker : 9. worker_run_stops_on_drop — un drop de la struct termine la boucle (smoke test, scoped tokio). 10. noop_delivery_returns_ok — sanity check.

Migration guide

### Added (rustwarden-core, feature `experimental-fediverse`)

- Migration `m20260510_000012_fediverse_outbound_queue` : nouvelle table
  + index partiel (status='pending').
- Service `FediverseOutboundService` (enqueue + drain_one).
- Trait `OutboundDelivery` + impl `NoopDelivery` (placeholder).
- `OutboundWorker<T>` polling-loop pour drainer la file.

### Migration steps

1. Apply migration 12 (`cargo run --bin migrate`).
2. Aucune action au niveau code consommateur — la file est consommée
   automatiquement par le worker démarré par `with_fediverse()` (RFC 0006
   branchera l'enqueue côté handler `consent`).

Open questions

Toutes résolues à l'auto-revue :

  • Naming OutboundStatus : retenu plutôt que DeliveryStatus car l'« in/out » est déjà dans le nom du module.
  • drain_one vs drain_batch : drain_one ; un batch demanderait du locking row-level pas encore implémenté en SeaORM-mock — différé à RFC 0010.
  • actor_user_id nullable : nullable dans cette RFC car NoopDelivery n'a pas besoin de signer ; RFC 0010 le rendra obligatoire pour HttpSignedDelivery.

Tracking issue

À ouvrir au moment de la PR.


Score d'évaluation

CritèreNote (1-5)Commentaire
Généralité4Pattern OutboundDelivery réutilisable au-delà du fediverse (webhooks, PubSubHubbub).
Stabilité API4Additif, isolé sous feature. Le statut transport (Noop) sera changé pour Http : remplacement d'une impl trait, pas une cassure.
Performance3Polling 1 ligne/tick ; un batch viendra. Suffisant pour Phase 2 (volume très faible).
Sécurité3Le slice ne signe pas — clairement marqué non-prod. RFC 0010 ferme le trou.
Complexité d'implémentation3~600 lignes (migration + entité + service + worker + trait + 10 tests).
Maintenance4Pas de nouvelle dépendance externe ; pure SeaORM + tokio.
Documentation5Rustdoc EN, doctest, CHANGELOG, migration guide ; statut transport explicitement documenté.
Total26/35≥ 25 → Accepted