feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
+30 -39
View File
@@ -9,51 +9,42 @@ import (
const expectedNotificationRedisKeyTable = `| Logical artifact | Redis key |
| --- | --- |
| ` + "`notification_record`" + ` | ` + "`notification:records:<notification_id>`" + ` |
| ` + "`notification_route`" + ` | ` + "`notification:routes:<notification_id>:<route_id>`" + ` |
| temporary route lease | ` + "`notification:route_leases:<notification_id>:<route_id>`" + ` |
| ` + "`notification_idempotency_record`" + ` | ` + "`notification:idempotency:<producer>:<idempotency_key>`" + ` |
| ` + "`notification_dead_letter_entry`" + ` | ` + "`notification:dead_letters:<notification_id>:<route_id>`" + ` |
| malformed intent record | ` + "`notification:malformed_intents:<stream_entry_id>`" + ` |
| stream offset record | ` + "`notification:stream_offsets:<stream>`" + ` |
| ingress stream | ` + "`notification:intents`" + ` |
| route schedule sorted set | ` + "`notification:route_schedule`" + ` |`
| ingress stream | ` + "`notification:intents`" + ` |`
const expectedNotificationRedisRecordFieldsTable = `| Record | Frozen fields |
const expectedNotificationPostgresTable = `| Table | Frozen columns |
| --- | --- |
| ` + "`notification_record`" + ` | ` + "`notification_id`" + `, ` + "`notification_type`" + `, ` + "`producer`" + `, ` + "`audience_kind`" + `, normalized ` + "`recipient_user_ids`" + `, normalized ` + "`payload_json`" + `, ` + "`idempotency_key`" + `, ` + "`request_fingerprint`" + `, optional ` + "`request_id`" + `, optional ` + "`trace_id`" + `, ` + "`occurred_at_ms`" + `, ` + "`accepted_at_ms`" + `, ` + "`updated_at_ms`" + ` |
| ` + "`notification_route`" + ` | ` + "`notification_id`" + `, ` + "`route_id`" + `, ` + "`channel`" + `, ` + "`recipient_ref`" + `, ` + "`status`" + `, ` + "`attempt_count`" + `, ` + "`max_attempts`" + `, ` + "`next_attempt_at_ms`" + `, optional ` + "`resolved_email`" + `, optional ` + "`resolved_locale`" + `, optional ` + "`last_error_classification`" + `, optional ` + "`last_error_message`" + `, optional ` + "`last_error_at_ms`" + `, ` + "`created_at_ms`" + `, ` + "`updated_at_ms`" + `, optional ` + "`published_at_ms`" + `, optional ` + "`dead_lettered_at_ms`" + `, optional ` + "`skipped_at_ms`" + ` |
| ` + "`notification_idempotency_record`" + ` | ` + "`producer`" + `, ` + "`idempotency_key`" + `, ` + "`notification_id`" + `, ` + "`request_fingerprint`" + `, ` + "`created_at_ms`" + `, ` + "`expires_at_ms`" + ` |
| ` + "`notification_dead_letter_entry`" + ` | ` + "`notification_id`" + `, ` + "`route_id`" + `, ` + "`channel`" + `, ` + "`recipient_ref`" + `, ` + "`final_attempt_count`" + `, ` + "`max_attempts`" + `, ` + "`failure_classification`" + `, ` + "`failure_message`" + `, ` + "`created_at_ms`" + `, optional ` + "`recovery_hint`" + ` |
| malformed intent record | ` + "`stream_entry_id`" + `, optional ` + "`notification_type`" + `, optional ` + "`producer`" + `, optional ` + "`idempotency_key`" + `, ` + "`failure_code`" + `, ` + "`failure_message`" + `, ` + "`raw_fields_json`" + `, ` + "`recorded_at_ms`" + ` |
| stream offset record | ` + "`stream`" + `, ` + "`last_processed_entry_id`" + `, ` + "`updated_at_ms`" + ` |`
| ` + "`records`" + ` | ` + "`notification_id`" + `, ` + "`notification_type`" + `, ` + "`producer`" + `, ` + "`audience_kind`" + `, ` + "`recipient_user_ids`" + ` (jsonb), ` + "`payload_json`" + `, ` + "`idempotency_key`" + `, ` + "`request_fingerprint`" + `, ` + "`request_id`" + `, ` + "`trace_id`" + `, ` + "`occurred_at`" + `, ` + "`accepted_at`" + `, ` + "`updated_at`" + `, ` + "`idempotency_expires_at`" + `; ` + "`UNIQUE (producer, idempotency_key)`" + ` |
| ` + "`routes`" + ` | ` + "`notification_id`" + `, ` + "`route_id`" + `, ` + "`channel`" + `, ` + "`recipient_ref`" + `, ` + "`status`" + `, ` + "`attempt_count`" + `, ` + "`max_attempts`" + `, ` + "`next_attempt_at`" + `, ` + "`resolved_email`" + `, ` + "`resolved_locale`" + `, ` + "`last_error_classification`" + `, ` + "`last_error_message`" + `, ` + "`last_error_at`" + `, ` + "`created_at`" + `, ` + "`updated_at`" + `, ` + "`published_at`" + `, ` + "`dead_lettered_at`" + `, ` + "`skipped_at`" + `; PRIMARY KEY ` + "`(notification_id, route_id)`" + ` |
| ` + "`dead_letters`" + ` | ` + "`notification_id`" + `, ` + "`route_id`" + `, ` + "`channel`" + `, ` + "`recipient_ref`" + `, ` + "`final_attempt_count`" + `, ` + "`max_attempts`" + `, ` + "`failure_classification`" + `, ` + "`failure_message`" + `, ` + "`recovery_hint`" + `, ` + "`created_at`" + `; PRIMARY KEY ` + "`(notification_id, route_id)`" + ` cascading from ` + "`routes`" + ` |
| ` + "`malformed_intents`" + ` | ` + "`stream_entry_id`" + `, ` + "`notification_type`" + `, ` + "`producer`" + `, ` + "`idempotency_key`" + `, ` + "`failure_code`" + `, ` + "`failure_message`" + `, ` + "`raw_fields`" + ` (jsonb), ` + "`recorded_at`" + ` |`
var expectedNotificationRedisDocumentationSnippets = []string{
"Each route represents exactly one `(channel, recipient_ref)` pair.",
"every derived `recipient_ref` receives one `push` route slot and one `email` route slot, except that an empty administrator email list materializes one synthetic `config:<notification_type>` recipient slot with only a skipped `email` route",
"a route slot whose channel is outside the notification type channel matrix is materialized as `skipped`",
"`recipient_ref` is `user:<user_id>` for user-targeted routes",
"`recipient_ref` is `email:<normalized_address>` for configured administrator email routes",
"synthetic recipient slot `config:<notification_type>` with one skipped `email` route so the configuration gap remains durable and operator-visible",
"`route_id` is mandatory and equals `<channel>:<recipient_ref>`",
"durable records are stored as strict JSON blobs",
"timestamps are stored in Unix milliseconds",
var expectedNotificationPersistenceDocumentationSnippets = []string{
"the durable `records` row IS the idempotency reservation",
"`next_attempt_at` is non-NULL only while the route is a scheduling candidate",
"`payload_json` stores the canonical normalized JSON string used for idempotency fingerprinting",
"`recipient_user_ids` is JSONB and omitted for `audience_kind=admin_email`",
"record-level retention deletes cascade to `routes` and `dead_letters` via `ON DELETE CASCADE`",
"dynamic Redis key segments are base64url-encoded",
"`notification:route_schedule` is one shared sorted set for both `push` and `email`",
"`notification_record.payload_json` stores the canonical normalized JSON string used for idempotency fingerprinting",
"temporary route lease keys store one opaque worker token and use `NOTIFICATION_ROUTE_LEASE_TTL`; they are service-local coordination state rather than durable records",
"score = `next_attempt_at_ms` and member = full Redis route key with encoded dynamic segments",
"`status=pending` and `next_attempt_at_ms = accepted_at_ms`",
"`failed` routes remain scheduled for retry",
"`published`, `dead_letter`, and `skipped` are absent from the schedule",
"only the current lease holder may finalize one due publication attempt",
"temporary route lease keys store one opaque worker token and use `NOTIFICATION_ROUTE_LEASE_TTL`",
"retained on Redis as a per-replica exclusivity hint atop the SQL claim",
"the outbound streams `gateway:client-events` and `mail:delivery_commands` remain Redis Streams",
"Notification Service emits one entry through `XADD` before committing the route's PostgreSQL state transition",
"`routes_due_idx` (the partial index on `next_attempt_at`) replaces the former `notification:route_schedule` ZSET",
"`push` publishers filter for `route_id` prefix `push:`",
"`email` publishers filter for prefix `email:`",
"only the current lease holder finalises one due publication attempt",
"the durable transition is a `Complete*` SQL transaction with optimistic concurrency on `routes.updated_at`",
"newly accepted publishable routes enter the partial index immediately",
"after failed attempt `N`, the next delay is `clamp(NOTIFICATION_ROUTE_BACKOFF_MIN * 2^(N-1), NOTIFICATION_ROUTE_BACKOFF_MIN, NOTIFICATION_ROUTE_BACKOFF_MAX)`",
"no jitter is added to the retry delay",
"creates `notification_dead_letter_entry`, and is removed from `notification:route_schedule`",
"`notification_record` and `notification_route` use `NOTIFICATION_RECORD_TTL`",
"`notification_idempotency_record` uses `NOTIFICATION_IDEMPOTENCY_TTL`",
"`notification_dead_letter_entry` and malformed intent records use `NOTIFICATION_DEAD_LETTER_TTL`",
"stream offset records do not use TTL",
"creates `notification_dead_letter_entry`",
"`records` and their cascaded `routes` / `dead_letters` use `NOTIFICATION_RECORD_RETENTION`",
"the per-record idempotency window (`records.idempotency_expires_at`) uses `NOTIFICATION_IDEMPOTENCY_TTL`",
"`malformed_intents` use `NOTIFICATION_MALFORMED_INTENT_RETENTION`",
"the retention worker runs once per `NOTIFICATION_CLEANUP_INTERVAL`",
"stream offset records do not expire",
}
func TestNotificationRedisDocsStayInSync(t *testing.T) {
@@ -74,9 +65,9 @@ func TestNotificationRedisDocsStayInSync(t *testing.T) {
require.Contains(t, docsIndex, "- [Operator runbook](runbook.md)")
require.Contains(t, readme, expectedNotificationRedisKeyTable)
require.Contains(t, readme, expectedNotificationRedisRecordFieldsTable)
require.Contains(t, readme, expectedNotificationPostgresTable)
for _, snippet := range expectedNotificationRedisDocumentationSnippets {
for _, snippet := range expectedNotificationPersistenceDocumentationSnippets {
normalizedSnippet := normalizeWhitespace(snippet)
require.Contains(t, normalizedReadme, normalizedSnippet)
}