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-corequi doit émettre des activités ActivityPub vers des inbox distantes (authAccept{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
OutboundDeliverysépare la concern transport ; l'implémentationHttpSignedDeliveryré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→deliveredoufailed. Le multi-step retry /next_retry_atrecalculé par code HTTP arrive avec RFC 0010 dans une 2nde extension. - Pas de UI admin pour visualiser/replay la file —
rustwarden-uitraitera 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 :
outbound_status_round_trips_through_string—OutboundStatus::parse(s)↔as_str().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 queDeliveryStatuscar l'« in/out » est déjà dans le nom du module. -
drain_onevsdrain_batch:drain_one; un batch demanderait du locking row-level pas encore implémenté en SeaORM-mock — différé à RFC 0010. -
actor_user_idnullable : nullable dans cette RFC carNoopDeliveryn'a pas besoin de signer ; RFC 0010 le rendra obligatoire pourHttpSignedDelivery.
Tracking issue
À ouvrir au moment de la PR.
Score d'évaluation
| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | Pattern OutboundDelivery réutilisable au-delà du fediverse (webhooks, PubSubHubbub). |
| Stabilité API | 4 | Additif, isolé sous feature. Le statut transport (Noop) sera changé pour Http : remplacement d'une impl trait, pas une cassure. |
| Performance | 3 | Polling 1 ligne/tick ; un batch viendra. Suffisant pour Phase 2 (volume très faible). |
| Sécurité | 3 | Le slice ne signe pas — clairement marqué non-prod. RFC 0010 ferme le trou. |
| Complexité d'implémentation | 3 | ~600 lignes (migration + entité + service + worker + trait + 10 tests). |
| Maintenance | 4 | Pas de nouvelle dépendance externe ; pure SeaORM + tokio. |
| Documentation | 5 | Rustdoc EN, doctest, CHANGELOG, migration guide ; statut transport explicitement documenté. |
| Total | 26/35 | ≥ 25 → Accepted |
GitRust