Skip to content

Commit 7cacce2

Browse files
committed
Allow for our moderation service to be run on multiple different servers at the same time
1 parent 8c61780 commit 7cacce2

4 files changed

Lines changed: 172 additions & 45 deletions

File tree

services/common/kafka/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod config;
55
mod consumer;
66
mod producer;
77

8+
pub use rdkafka::Offset;
89
pub use rdkafka::message::{BorrowedMessage, Message};
910

1011
pub use consumer::{CommitMode, Consumer, CustomContext, build_consumer};

services/moderation/src/main.rs

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ mod polycentric;
77
mod providers;
88
mod repository;
99

10-
use common_kafka::{BorrowedMessage, CommitMode, Consumer, Message};
10+
use std::collections::HashMap;
11+
use std::time::Duration;
12+
13+
use common_kafka::{BorrowedMessage, CommitMode, Consumer, Message, Offset};
1114
use common_object_store::{ObjectStore, ObjectStoreConfig};
1215
use context::Context;
1316
use polycentric::{PolycentricClient, PublishError};
17+
use polycentric_common::models::collections;
1418
use polycentric_common::models::protos_v2::{
1519
Content, ContentDigest, Event, EventBundle, EventKey, ImageSet, content::ContentBody,
1620
};
@@ -20,11 +24,19 @@ use providers::azure::{AzureClient, ModerationRequest};
2024
use prost::Message as ProstMessage;
2125
use sea_orm::sea_query::value::prelude::serde_json;
2226

27+
/// Duration before retrying a Retry event
28+
const RETRY_BACKOFF: Duration = Duration::from_secs(2);
29+
30+
/// Number of times a message is retried before it is skipped (committed
31+
/// past without being processed).
32+
const MAX_RETRIES: u32 = 5;
33+
2334
/// Whether the consumed message's offset should be committed.
2435
enum Outcome {
2536
/// Done with this message — commit so it is not redelivered.
2637
Commit,
27-
/// Transient failure — leave uncommitted so it is retried.
38+
/// Transient failure — seek back so the message is re-delivered and
39+
/// retried (see the consume loop). After [`MAX_RETRIES`] it is skipped.
2840
Retry,
2941
}
3042

@@ -221,6 +233,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
221233
// Listen to a Kafka topic of all events.
222234
let consumer = common_kafka::build_consumer("events", &["events"]).await;
223235

236+
// Failure counts for messages currently being retried.
237+
let mut attempts: HashMap<(i32, i64), u32> = HashMap::new();
238+
224239
loop {
225240
let message = match consumer.recv().await {
226241
Ok(message) => message,
@@ -230,13 +245,47 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
230245
}
231246
};
232247

248+
let coord = (message.partition(), message.offset());
249+
233250
match process(&ctx, &message).await {
234251
Outcome::Commit => {
252+
attempts.remove(&coord);
235253
if let Err(e) = consumer.commit_message(&message, CommitMode::Async) {
236254
warn!("failed to commit offset: {}", e);
237255
}
238256
}
239-
Outcome::Retry => {}
257+
Outcome::Retry => {
258+
let failures = {
259+
let count = attempts.entry(coord).or_insert(0);
260+
*count += 1;
261+
*count
262+
};
263+
264+
if failures > MAX_RETRIES {
265+
// Retries exhausted — give up and commit past this message
266+
// so the partition can make progress.
267+
warn!(
268+
"message at partition {} offset {} failed {} times; skipping",
269+
coord.0, coord.1, failures
270+
);
271+
attempts.remove(&coord);
272+
if let Err(e) = consumer.commit_message(&message, CommitMode::Async) {
273+
warn!("failed to commit offset after skip: {}", e);
274+
}
275+
} else {
276+
// Seek back so the next poll re-delivers this message, then
277+
// back off to avoid a hot loop.
278+
if let Err(e) = consumer.seek(
279+
message.topic(),
280+
message.partition(),
281+
Offset::Offset(message.offset()),
282+
Duration::from_secs(5),
283+
) {
284+
warn!("failed to seek for retry: {}", e);
285+
}
286+
tokio::time::sleep(RETRY_BACKOFF).await;
287+
}
288+
}
240289
}
241290
}
242291
}
@@ -376,7 +425,24 @@ async fn process(ctx: &Context, message: &BorrowedMessage<'_>) -> Outcome {
376425
/// moderation DB. Returns `Retry` on any failure (transient, persistence,
377426
/// or a not-ready identity) so the label is never silently dropped.
378427
async fn publish_labels(ctx: &Context, target: EventKey, labels: Vec<String>) -> Outcome {
379-
match ctx.polycentric.publish_labels(target, labels).await {
428+
let signer = ctx.polycentric.public_key();
429+
let head = match repository::chain_head(
430+
&ctx.db,
431+
collections::LABELS,
432+
ctx.polycentric.identity(),
433+
signer.key_type,
434+
&signer.key,
435+
)
436+
.await
437+
{
438+
Ok(head) => head,
439+
Err(e) => {
440+
warn!("chain_head error: {}", e);
441+
return Outcome::Retry;
442+
}
443+
};
444+
445+
match ctx.polycentric.publish_labels(target, labels, head).await {
380446
Ok(created) => match repository::persist_created(&ctx.db, &created).await {
381447
Ok(()) => Outcome::Commit,
382448
Err(e) => {

services/moderation/src/polycentric.rs

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ impl std::fmt::Display for PublishError {
3434
}
3535
}
3636

37+
/// Chain position for the next event we author in a collection, read from
38+
/// the durable Postgres store (not local in-memory state) so that multiple
39+
/// moderation processes extend a single, shared chain consistently.
40+
pub struct ChainHead {
41+
/// Sequence to assign the next event (`max(sequence) + 1`, or 1).
42+
pub next_sequence: u64,
43+
/// Canonically-latest signature in the collection (empty if none).
44+
pub previous_signature: Vec<u8>,
45+
/// Merkle root over the collection's canonical signatures (empty if none).
46+
pub previous_root: Vec<u8>,
47+
}
48+
3749
/// Signed event and content that should persist to the database.
3850
pub struct CreatedEvent {
3951
/// The decoded event (carries key, digest, previous_root, etc.).
@@ -155,13 +167,22 @@ impl PolycentricClient {
155167
Ok(count)
156168
}
157169

158-
/// Build, sign, and push a `Labels` event for `target`, then record it
159-
/// locally so the next event chains correctly. Returns the created
160-
/// event for persistence by the caller.
170+
/// The hex identity string this service publishes under.
171+
pub fn identity(&self) -> &str {
172+
&self.identity
173+
}
174+
175+
/// The public key this service signs events with.
176+
pub fn public_key(&self) -> &PublicKey {
177+
&self.public_key
178+
}
179+
180+
/// Build, sign, and push a `Labels` event for `target`.
161181
pub async fn publish_labels(
162182
&self,
163183
target: EventKey,
164184
label_values: Vec<String>,
185+
head: ChainHead,
165186
) -> Result<CreatedEvent, PublishError> {
166187
let identity_sequence = self.identity_sequence.load(Ordering::Relaxed);
167188
if identity_sequence == 0 {
@@ -172,38 +193,36 @@ impl PolycentricClient {
172193

173194
let (content_bytes, digest) = labels_content(&target, &label_values);
174195

175-
// Compute the chain position synchronously, without holding the lock
176-
// across any await.
177-
let event = {
196+
// The sequence and prior-chain references come from the durable
197+
// Postgres store (`head`); only the vector clock is derived locally,
198+
// from the static identity chain loaded at bootstrap.
199+
let sequence = head.next_sequence;
200+
let vector_clock = {
178201
let core = self.core.lock().unwrap();
179-
let sequence = core.next_sequence(&self.identity, collections::LABELS);
180-
let vector_clock = core
181-
.build_vector_clock(
182-
&self.identity,
183-
collections::LABELS,
184-
identity_sequence,
185-
&self.public_key,
186-
sequence,
187-
None,
188-
)
189-
.map_err(|e| PublishError::NotReady(e.to_string()))?;
190-
let previous_root = core.previous_root(&self.identity, collections::LABELS);
191-
let previous_signature = core.previous_signature(&self.identity, collections::LABELS);
192-
193-
Event {
194-
key: Some(EventKey {
195-
collection: collections::LABELS,
196-
identity: self.identity.clone(),
197-
signed_by: Some(self.public_key.clone()),
198-
sequence,
199-
}),
202+
core.build_vector_clock(
203+
&self.identity,
204+
collections::LABELS,
200205
identity_sequence,
201-
vector_clock: Some(vector_clock),
202-
previous_signature,
203-
previous_root,
204-
content_digest: Some(digest),
205-
created_at: now_millis(),
206-
}
206+
&self.public_key,
207+
sequence,
208+
None,
209+
)
210+
.map_err(|e| PublishError::NotReady(e.to_string()))?
211+
};
212+
213+
let event = Event {
214+
key: Some(EventKey {
215+
collection: collections::LABELS,
216+
identity: self.identity.clone(),
217+
signed_by: Some(self.public_key.clone()),
218+
sequence,
219+
}),
220+
identity_sequence,
221+
vector_clock: Some(vector_clock),
222+
previous_signature: head.previous_signature,
223+
previous_root: head.previous_root,
224+
content_digest: Some(digest),
225+
created_at: now_millis(),
207226
};
208227

209228
let event_bytes = event.encode_to_vec();
@@ -223,7 +242,7 @@ impl PolycentricClient {
223242
// Push to every server; success on any is enough to consider it
224243
// published (servers gossip among themselves).
225244
let request = PutEventsRequest {
226-
event_bundles: vec![bundle.clone()],
245+
event_bundles: vec![bundle],
227246
};
228247
let mut pushed = false;
229248
for server in &self.servers {
@@ -238,9 +257,8 @@ impl PolycentricClient {
238257
));
239258
}
240259

241-
// Record locally so subsequent events extend this chain.
242-
self.core.lock().unwrap().copy_bundles(vec![bundle]);
243-
260+
// No local state to update: the next event's chain position is read
261+
// back from Postgres once the caller persists this one.
244262
Ok(CreatedEvent {
245263
event,
246264
signature,

services/moderation/src/repository.rs

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ use moderation_entity::processed_content_model::{
22
ActiveModel, Entity as ProcessedContent, Model as ProcessedContentModel, Status,
33
};
44
use moderation_entity::{created_content_model, created_event_model};
5+
use polycentric_common::merkle;
56
use sea_orm::{
6-
ActiveModelTrait, ActiveValue::NotSet, ActiveValue::Set, ConnectionTrait, DatabaseConnection,
7-
DbErr, EntityTrait, TransactionTrait, prelude::TimeDateTime, sea_query::OnConflict,
8-
sea_query::value::prelude::serde_json,
7+
ActiveModelTrait, ActiveValue::NotSet, ActiveValue::Set, ColumnTrait, ConnectionTrait,
8+
DatabaseConnection, DbErr, EntityTrait, QueryFilter, TransactionTrait, prelude::TimeDateTime,
9+
sea_query::OnConflict, sea_query::value::prelude::serde_json,
910
};
1011
use time::OffsetDateTime;
1112

12-
use crate::polycentric::CreatedEvent;
13+
use crate::polycentric::{ChainHead, CreatedEvent};
1314

1415
/// Current wall-clock time as the `TimeDateTime` (naive) the schema uses.
1516
fn now() -> TimeDateTime {
@@ -87,6 +88,47 @@ pub async fn persist_created(db: &DatabaseConnection, created: &CreatedEvent) ->
8788
txn.commit().await
8889
}
8990

91+
/// Read the next chain position for events we author in
92+
pub async fn chain_head<C: ConnectionTrait>(
93+
db: &C,
94+
collection: i32,
95+
identity: &str,
96+
public_key_type: i32,
97+
public_key: &[u8],
98+
) -> Result<ChainHead, DbErr> {
99+
let rows = created_event_model::Entity::find()
100+
.filter(created_event_model::Column::Collection.eq(collection))
101+
.filter(created_event_model::Column::Identity.eq(identity))
102+
.filter(created_event_model::Column::PublicKeyType.eq(public_key_type))
103+
.filter(created_event_model::Column::PublicKey.eq(public_key.to_vec()))
104+
.all(db)
105+
.await?;
106+
107+
let next_sequence = rows
108+
.iter()
109+
.map(|r| r.sequence)
110+
.max()
111+
.map(|s| s as u64 + 1)
112+
.unwrap_or(1);
113+
114+
// Canonical ordering depends on each event's full bytes (vector-clock
115+
// sum, created_at) paired with its signature — both stored per row.
116+
let canonical = merkle::canonical_signatures(
117+
rows.iter()
118+
.map(|r| (r.event_bytes.as_slice(), r.signature.as_slice())),
119+
);
120+
let previous_signature = canonical.last().cloned().unwrap_or_default();
121+
let previous_root = merkle::merkle_tree_hash(&canonical)
122+
.map(|h| h.to_vec())
123+
.unwrap_or_default();
124+
125+
Ok(ChainHead {
126+
next_sequence,
127+
previous_signature,
128+
previous_root,
129+
})
130+
}
131+
90132
/// Returns if already stored content with this digest
91133
pub async fn created_content_exists<C: ConnectionTrait>(
92134
db: &C,

0 commit comments

Comments
 (0)