Skip to content

Commit 59f276c

Browse files
James Youngbloodmarkharding
authored andcommitted
[#102] Use UTC explicitly in all event timestamps
Changelog: fix
1 parent a67e33e commit 59f276c

28 files changed

Lines changed: 392 additions & 136 deletions

.gitlab/ci/scripts/integration-moderation.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ cleanup() {
3939
}
4040
trap cleanup EXIT
4141

42+
# Clear any stack left behind by a previous job that was hard-killed before its
43+
# cleanup trap could run (a stale broker/volume would otherwise be reused).
44+
echo "==> Clearing any stale stack…"
45+
docker compose down -v >/dev/null 2>&1 || true
46+
4247
echo "==> Bringing up the server stack…"
4348
docker compose up -d --build --wait postgres rustfs kafka server
4449

.gitlab/ci/test_moderation.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
.moderation-integration-workflow:
1010
image: $RUST_DIND_IMAGE
1111
stage: test
12+
# Enforce serial execution of moderation integration tests using a `resource_group`, so that the
13+
# Docker stack of one CI runner doing the test doesn't interfere with that of the other.
14+
resource_group: moderation-integration
1215
rules:
1316
- if: $CI_COMMIT_TAG =~ /^moderation-/
1417
when: always

services/common/kafka/src/config.rs

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,30 @@ use rdkafka::config::ClientConfig;
44

55
/// Apply the broker, security protocol, and SASL settings shared by
66
/// every client to `config`.
7+
///
8+
/// SASL properties (`sasl.mechanism`, `sasl.username`, `sasl.password`) are
9+
/// only set when their corresponding environment variables are explicitly
10+
/// configured.
711
pub(crate) fn set_defaults(config: &mut ClientConfig) {
812
let brokers =
913
env::var("POLYCENTRIC_KAFKA_BROKERS").unwrap_or_else(|_| "localhost:9092".to_string());
10-
config
11-
.set("bootstrap.servers", brokers)
12-
.set(
13-
"security.protocol",
14-
env::var("POLYCENTRIC_KAFKA_SECURITY_PROTOCOL")
15-
.unwrap_or_else(|_| "PLAINTEXT".to_string()),
16-
)
17-
.set(
18-
"sasl.mechanism",
19-
env::var("POLYCENTRIC_KAFKA_SASL_MECHANISM").unwrap_or_else(|_| "PLAIN".to_string()),
20-
)
21-
.set(
22-
"sasl.username",
23-
env::var("POLYCENTRIC_KAFKA_SASL_USERNAME").unwrap_or_default(),
24-
)
25-
.set(
26-
"sasl.password",
27-
env::var("POLYCENTRIC_KAFKA_SASL_PASSWORD").unwrap_or_default(),
28-
)
29-
.set(
30-
"broker.address.family",
31-
env::var("POLYCENTRIC_KAFKA_BROKER_ADDRESS_FAMILY")
32-
.unwrap_or_else(|_| "any".to_string()),
33-
);
14+
config.set("bootstrap.servers", brokers).set(
15+
"security.protocol",
16+
env::var("POLYCENTRIC_KAFKA_SECURITY_PROTOCOL").unwrap_or_else(|_| "PLAINTEXT".to_string()),
17+
);
18+
19+
if let Ok(value) = env::var("POLYCENTRIC_KAFKA_SASL_MECHANISM") {
20+
config.set("sasl.mechanism", value);
21+
}
22+
if let Ok(value) = env::var("POLYCENTRIC_KAFKA_SASL_USERNAME") {
23+
config.set("sasl.username", value);
24+
}
25+
if let Ok(value) = env::var("POLYCENTRIC_KAFKA_SASL_PASSWORD") {
26+
config.set("sasl.password", value);
27+
}
28+
29+
config.set(
30+
"broker.address.family",
31+
env::var("POLYCENTRIC_KAFKA_BROKER_ADDRESS_FAMILY").unwrap_or_else(|_| "any".to_string()),
32+
);
3433
}

services/common/kafka/src/consumer.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,21 @@ impl ClientContext for CustomContext {}
1414
impl ConsumerContext for CustomContext {}
1515

1616
/// Build a subscribed `StreamConsumer` with auto-commit disabled.
17+
///
18+
/// Override the Kafka `auto.offset.reset` property using the
19+
/// `POLYCENTRIC_KAFKA_AUTO_OFFSET_RESET` environment variable
20+
/// (used in moderation integration test).
1721
pub async fn build_consumer(group_id: &str, topics: &[&str]) -> StreamConsumer<CustomContext> {
1822
let mut config = ClientConfig::new();
1923
set_defaults(&mut config);
2024
config
2125
.set("group.id", group_id)
22-
.set("enable.auto.commit", "false");
26+
.set("enable.auto.commit", "false")
27+
.set(
28+
"auto.offset.reset",
29+
std::env::var("POLYCENTRIC_KAFKA_AUTO_OFFSET_RESET")
30+
.unwrap_or_else(|_| "latest".to_string()),
31+
);
2332

2433
let consumer: StreamConsumer<CustomContext> = config
2534
.create_with_context(CustomContext)

services/moderation/entity/src/created_content_model.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ pub struct Model {
1616
// Serialized `Content` proto (the bytes the digest is taken over).
1717
pub serialized_bytes: Vec<u8>,
1818

19-
pub created_at: TimeDateTime,
19+
pub created_at: TimeDateTimeWithTimeZone,
2020
}
2121

2222
impl ActiveModelBehavior for ActiveModel {}

services/moderation/entity/src/created_event_model.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub struct Model {
3737
pub event_bytes: Vec<u8>,
3838

3939
// Timestamp the event was created (from the event's created_at).
40-
pub created_at: TimeDateTime,
40+
pub created_at: TimeDateTimeWithTimeZone,
4141
}
4242

4343
impl ActiveModelBehavior for ActiveModel {}

services/moderation/entity/src/processed_content_model.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ pub struct Model {
2121
#[sea_orm(primary_key)]
2222
pub digest_bytes: Vec<u8>,
2323

24-
pub created_at: TimeDateTime,
25-
pub updated_at: TimeDateTime,
24+
pub created_at: TimeDateTimeWithTimeZone,
25+
pub updated_at: TimeDateTimeWithTimeZone,
2626

2727
pub status: Status,
2828

services/moderation/migration/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*;
22

33
mod m20220101_000001_create_table;
44
mod m20260604_000001_create_created_tables;
5+
mod m20260618_000001_timestamps_to_timestamptz;
56

67
pub struct Migrator;
78

@@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
1112
vec![
1213
Box::new(m20220101_000001_create_table::Migration),
1314
Box::new(m20260604_000001_create_created_tables::Migration),
15+
Box::new(m20260618_000001_timestamps_to_timestamptz::Migration),
1416
]
1517
}
1618
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
use sea_orm_migration::prelude::*;
2+
use sea_orm_migration::sea_orm::ConnectionTrait;
3+
4+
/// Convert the moderation timestamp columns from `timestamp without time zone`
5+
/// (naive) to `timestamptz`, so stored instants are unambiguous UTC and can't
6+
/// be reinterpreted in a reader's local timezone (issue #102).
7+
///
8+
/// Each conversion is guarded on the column's current type so it is a no-op on
9+
/// a fresh database (where the entity-derived `created_*` tables are already
10+
/// `timestamptz`) and only rewrites legacy naive columns on existing
11+
/// deployments. Existing naive values are stamped as UTC via `AT TIME ZONE
12+
/// 'UTC'` rather than relying on the session timezone.
13+
#[derive(DeriveMigrationName)]
14+
pub struct Migration;
15+
16+
const COLUMNS: &[(&str, &str)] = &[
17+
("processed_content", "created_at"),
18+
("processed_content", "updated_at"),
19+
("created_content", "created_at"),
20+
("created_event", "created_at"),
21+
];
22+
23+
#[async_trait::async_trait]
24+
impl MigrationTrait for Migration {
25+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
26+
let conn = manager.get_connection();
27+
for (table, column) in COLUMNS {
28+
conn.execute_unprepared(&format!(
29+
r#"DO $$
30+
BEGIN
31+
IF EXISTS (
32+
SELECT 1 FROM information_schema.columns
33+
WHERE table_name = '{table}' AND column_name = '{column}'
34+
AND table_schema = current_schema()
35+
AND data_type = 'timestamp without time zone'
36+
) THEN
37+
ALTER TABLE "{table}"
38+
ALTER COLUMN "{column}" TYPE timestamptz USING "{column}" AT TIME ZONE 'UTC';
39+
END IF;
40+
END $$;"#
41+
))
42+
.await?;
43+
}
44+
Ok(())
45+
}
46+
47+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
48+
let conn = manager.get_connection();
49+
for (table, column) in COLUMNS {
50+
conn.execute_unprepared(&format!(
51+
r#"DO $$
52+
BEGIN
53+
IF EXISTS (
54+
SELECT 1 FROM information_schema.columns
55+
WHERE table_name = '{table}' AND column_name = '{column}'
56+
AND table_schema = current_schema()
57+
AND data_type = 'timestamp with time zone'
58+
) THEN
59+
ALTER TABLE "{table}"
60+
ALTER COLUMN "{column}" TYPE timestamp USING "{column}" AT TIME ZONE 'UTC';
61+
END IF;
62+
END $$;"#
63+
))
64+
.await?;
65+
}
66+
Ok(())
67+
}
68+
}

services/moderation/src/db.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ pub async fn connect() -> Result<DatabaseConnection, DbErr> {
99
let schema = std::env::var("POLYCENTRIC_MODERATION_DATABASE_SCHEMA")
1010
.unwrap_or_else(|_| "moderation".to_string());
1111

12-
let mut opt = ConnectOptions::new(database_url);
12+
let mut opt = ConnectOptions::new(with_utc_timezone(&database_url));
1313
opt.set_schema_search_path(&schema);
1414
let connection = Database::connect(opt).await?;
1515

@@ -24,3 +24,13 @@ pub async fn run_migrations(connection: &DatabaseConnection) -> Result<(), DbErr
2424
Migrator::up(connection, None).await?;
2525
Ok(())
2626
}
27+
28+
/// Strictly enforce a UTC timezone connection by appending the
29+
/// `options=-c timezone=UTC` parameter.
30+
fn with_utc_timezone(url: &str) -> String {
31+
if url.contains("timezone") {
32+
return url.to_string();
33+
}
34+
let sep = if url.contains('?') { '&' } else { '?' };
35+
format!("{url}{sep}options=-c%20timezone%3DUTC")
36+
}

0 commit comments

Comments
 (0)