feat: add notification deduplication trigger (#14172)

This commit is contained in:
Danny Kopping
2024-08-21 11:18:03 +02:00
committed by GitHub
parent d9f419308a
commit 9c8c6a952d
12 changed files with 168 additions and 28 deletions
+26 -1
View File
@@ -223,6 +223,24 @@ CREATE TYPE workspace_transition AS ENUM (
'delete'
);
CREATE FUNCTION compute_notification_message_dedupe_hash() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.dedupe_hash := MD5(CONCAT_WS(':',
NEW.notification_template_id,
NEW.user_id,
NEW.method,
NEW.payload::text,
ARRAY_TO_STRING(NEW.targets, ','),
DATE_TRUNC('day', NEW.created_at AT TIME ZONE 'UTC')::text
));
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION compute_notification_message_dedupe_hash() IS 'Computes a unique hash which will be used to prevent duplicate messages from being enqueued on the same day';
CREATE FUNCTION delete_deleted_oauth2_provider_app_token_api_key() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -678,9 +696,12 @@ CREATE TABLE notification_messages (
updated_at timestamp with time zone,
leased_until timestamp with time zone,
next_retry_after timestamp with time zone,
queued_seconds double precision
queued_seconds double precision,
dedupe_hash text
);
COMMENT ON COLUMN notification_messages.dedupe_hash IS 'Auto-generated by insert/update trigger, used to prevent duplicate notifications from being enqueued on the same day';
CREATE TABLE notification_preferences (
user_id uuid NOT NULL,
notification_template_id uuid NOT NULL,
@@ -1846,6 +1867,8 @@ CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true);
CREATE INDEX provisioner_job_logs_id_job_id_idx ON provisioner_job_logs USING btree (job_id, id);
@@ -1918,6 +1941,8 @@ CREATE TRIGGER trigger_update_users AFTER INSERT OR UPDATE ON users FOR EACH ROW
CREATE TRIGGER trigger_upsert_user_links BEFORE INSERT OR UPDATE ON user_links FOR EACH ROW EXECUTE FUNCTION insert_user_links_fail_if_user_deleted();
CREATE TRIGGER update_notification_message_dedupe_hash BEFORE INSERT OR UPDATE ON notification_messages FOR EACH ROW EXECUTE FUNCTION compute_notification_message_dedupe_hash();
ALTER TABLE ONLY api_keys
ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
@@ -0,0 +1,4 @@
DROP TRIGGER IF EXISTS update_notification_message_dedupe_hash ON notification_messages;
DROP FUNCTION IF EXISTS compute_notification_message_dedupe_hash();
ALTER TABLE IF EXISTS notification_messages
DROP COLUMN IF EXISTS dedupe_hash;
@@ -0,0 +1,33 @@
-- Add a column to store the hash.
ALTER TABLE IF EXISTS notification_messages
ADD COLUMN IF NOT EXISTS dedupe_hash TEXT NULL;
COMMENT ON COLUMN notification_messages.dedupe_hash IS 'Auto-generated by insert/update trigger, used to prevent duplicate notifications from being enqueued on the same day';
-- Ensure that multiple notifications with identical hashes cannot be inserted into the table.
CREATE UNIQUE INDEX ON notification_messages (dedupe_hash);
-- Computes a hash from all unique messages fields and the current day; this will help prevent duplicate messages from being sent within the same day.
-- It is possible that a message could be sent at 23:59:59 and again at 00:00:00, but this should be good enough for now.
-- This could have been a unique index, but we cannot immutably create an index on a timestamp with a timezone.
CREATE OR REPLACE FUNCTION compute_notification_message_dedupe_hash() RETURNS TRIGGER AS
$$
BEGIN
NEW.dedupe_hash := MD5(CONCAT_WS(':',
NEW.notification_template_id,
NEW.user_id,
NEW.method,
NEW.payload::text,
ARRAY_TO_STRING(NEW.targets, ','),
DATE_TRUNC('day', NEW.created_at AT TIME ZONE 'UTC')::text
));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION compute_notification_message_dedupe_hash IS 'Computes a unique hash which will be used to prevent duplicate messages from being enqueued on the same day';
CREATE TRIGGER update_notification_message_dedupe_hash
BEFORE INSERT OR UPDATE
ON notification_messages
FOR EACH ROW
EXECUTE FUNCTION compute_notification_message_dedupe_hash();
+2
View File
@@ -2116,6 +2116,8 @@ type NotificationMessage struct {
LeasedUntil sql.NullTime `db:"leased_until" json:"leased_until"`
NextRetryAfter sql.NullTime `db:"next_retry_after" json:"next_retry_after"`
QueuedSeconds sql.NullFloat64 `db:"queued_seconds" json:"queued_seconds"`
// Auto-generated by insert/update trigger, used to prevent duplicate notifications from being enqueued on the same day
DedupeHash sql.NullString `db:"dedupe_hash" json:"dedupe_hash"`
}
type NotificationPreference struct {
+8 -4
View File
@@ -3274,7 +3274,7 @@ WITH acquired AS (
FOR UPDATE OF nm
SKIP LOCKED
LIMIT $4)
RETURNING id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after, queued_seconds)
RETURNING id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after, queued_seconds, dedupe_hash)
SELECT
-- message
nm.id,
@@ -3449,14 +3449,15 @@ func (q *sqlQuerier) DeleteOldNotificationMessages(ctx context.Context) error {
}
const enqueueNotificationMessage = `-- name: EnqueueNotificationMessage :exec
INSERT INTO notification_messages (id, notification_template_id, user_id, method, payload, targets, created_by)
INSERT INTO notification_messages (id, notification_template_id, user_id, method, payload, targets, created_by, created_at)
VALUES ($1,
$2,
$3,
$4::notification_method,
$5::jsonb,
$6,
$7)
$7,
$8)
`
type EnqueueNotificationMessageParams struct {
@@ -3467,6 +3468,7 @@ type EnqueueNotificationMessageParams struct {
Payload json.RawMessage `db:"payload" json:"payload"`
Targets []uuid.UUID `db:"targets" json:"targets"`
CreatedBy string `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
func (q *sqlQuerier) EnqueueNotificationMessage(ctx context.Context, arg EnqueueNotificationMessageParams) error {
@@ -3478,6 +3480,7 @@ func (q *sqlQuerier) EnqueueNotificationMessage(ctx context.Context, arg Enqueue
arg.Payload,
pq.Array(arg.Targets),
arg.CreatedBy,
arg.CreatedAt,
)
return err
}
@@ -3528,7 +3531,7 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe
}
const getNotificationMessagesByStatus = `-- name: GetNotificationMessagesByStatus :many
SELECT id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after, queued_seconds
SELECT id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after, queued_seconds, dedupe_hash
FROM notification_messages
WHERE status = $1
LIMIT $2::int
@@ -3564,6 +3567,7 @@ func (q *sqlQuerier) GetNotificationMessagesByStatus(ctx context.Context, arg Ge
&i.LeasedUntil,
&i.NextRetryAfter,
&i.QueuedSeconds,
&i.DedupeHash,
); err != nil {
return nil, err
}
+3 -2
View File
@@ -13,14 +13,15 @@ WHERE nt.id = @notification_template_id
AND u.id = @user_id;
-- name: EnqueueNotificationMessage :exec
INSERT INTO notification_messages (id, notification_template_id, user_id, method, payload, targets, created_by)
INSERT INTO notification_messages (id, notification_template_id, user_id, method, payload, targets, created_by, created_at)
VALUES (@id,
@notification_template_id,
@user_id,
@method::notification_method,
@payload::jsonb,
@targets,
@created_by);
@created_by,
@created_at);
-- Acquires the lease for a given count of notification messages, to enable concurrent dequeuing and subsequent sending.
-- Only rows that aren't already leased (or ones which are leased but have exceeded their lease period) are returned.
+1
View File
@@ -88,6 +88,7 @@ const (
UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text)));
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false);
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
UniqueOrganizationsSingleDefaultOrg UniqueConstraint = "organizations_single_default_org" // CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true);
UniqueProvisionerKeysOrganizationIDNameIndex UniqueConstraint = "provisioner_keys_organization_id_name_idx" // CREATE UNIQUE INDEX provisioner_keys_organization_id_name_idx ON provisioner_keys USING btree (organization_id, lower((name)::text));
UniqueTemplateUsageStatsStartTimeTemplateIDUserIDIndex UniqueConstraint = "template_usage_stats_start_time_template_id_user_id_idx" // CREATE UNIQUE INDEX template_usage_stats_start_time_template_id_user_id_idx ON template_usage_stats USING btree (start_time, template_id, user_id);