perf: sync protocol and core hardening updates
This commit is contained in:
parent
152fed3b87
commit
4390ebf5a9
283 changed files with 29231 additions and 2295 deletions
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE private_messages
|
||||
DROP COLUMN IF EXISTS recipient_delivered,
|
||||
DROP COLUMN IF EXISTS request_fingerprint;
|
||||
25
deploy/migrations/0062_private_message_idempotency.up.sql
Normal file
25
deploy/migrations/0062_private_message_idempotency.up.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
ALTER TABLE private_messages
|
||||
ADD COLUMN request_fingerprint bytea NOT NULL DEFAULT '\x',
|
||||
ADD COLUMN recipient_delivered boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- 旧行没有保存原始请求指纹,保留空 fingerprint,使后续重放显式返回
|
||||
-- RANDOM_ID_DUPLICATE,禁止从可能已编辑的消息投影猜测/修复原请求。
|
||||
-- recipient_delivered 可由同一私聊事实表精确回填,用于区分「被 block 后本就
|
||||
-- 不投递」与「声明已投递但 recipient box 丢失」两种状态。
|
||||
UPDATE private_messages AS p
|
||||
SET recipient_delivered = true
|
||||
WHERE p.sender_user_id <> p.recipient_user_id
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM message_boxes AS b
|
||||
WHERE b.private_message_id = p.id
|
||||
AND b.owner_user_id = p.recipient_user_id
|
||||
);
|
||||
|
||||
-- Keep these defaults after the expand step. A pre-0062 process does not name
|
||||
-- either column in INSERT, so dropping them while that process can still serve
|
||||
-- traffic turns an otherwise compatible rolling deployment into a NOT NULL
|
||||
-- failure. The empty fingerprint is an explicit "unknown legacy receipt"
|
||||
-- sentinel: the new replay path rejects it as RANDOM_ID_DUPLICATE before it can
|
||||
-- interpret recipient_delivered. It must never be reconstructed from mutable
|
||||
-- message_boxes.
|
||||
19
deploy/migrations/0063_channel_update_retention.down.sql
Normal file
19
deploy/migrations/0063_channel_update_retention.down.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Once a retained floor advances, channel_update_events below it are physically gone. Dropping
|
||||
-- the checkpoint would make an old client pts look like an ordinary empty difference and silently
|
||||
-- lose the required channelDifferenceTooLong recovery boundary. Refuse that irreversible down.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.channel_update_checkpoints
|
||||
WHERE retained_through_pts > 0
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'cannot roll back channel update retention: retained floor has advanced'
|
||||
USING ERRCODE = '55000';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DROP INDEX IF EXISTS public.channel_update_events_retention_seek_idx;
|
||||
DROP TABLE IF EXISTS public.channel_update_checkpoints;
|
||||
35
deploy/migrations/0063_channel_update_retention.up.sql
Normal file
35
deploy/migrations/0063_channel_update_retention.up.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
-- Channel-scoped update retention checkpoint.
|
||||
--
|
||||
-- channel_update_events may now be pruned in bounded per-channel transactions. The checkpoint is
|
||||
-- the durable protocol boundary: request pts below retained_through_pts must receive
|
||||
-- updates.channelDifferenceTooLong; latest_event_date/latest_pts remain after row deletion so an
|
||||
-- account-level updates.getDifference can still emit UpdateChannelTooLong for an offline member.
|
||||
CREATE TABLE IF NOT EXISTS public.channel_update_checkpoints (
|
||||
channel_id bigint PRIMARY KEY REFERENCES public.channels(id) ON DELETE CASCADE,
|
||||
retained_through_pts integer NOT NULL DEFAULT 0 CHECK (retained_through_pts >= 0),
|
||||
latest_event_date integer NOT NULL DEFAULT 0 CHECK (latest_event_date >= 0),
|
||||
latest_pts integer NOT NULL DEFAULT 0 CHECK (latest_pts >= 0),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT channel_update_checkpoints_floor_check CHECK (retained_through_pts <= latest_pts)
|
||||
);
|
||||
|
||||
-- Backfill every existing channel, including channels without an event row, so read paths can use
|
||||
-- the checkpoint directly without a full event-log fallback.
|
||||
INSERT INTO public.channel_update_checkpoints (
|
||||
channel_id, retained_through_pts, latest_event_date, latest_pts
|
||||
)
|
||||
SELECT c.id,
|
||||
0,
|
||||
COALESCE(MAX(e.date), 0)::integer,
|
||||
c.pts
|
||||
FROM public.channels c
|
||||
LEFT JOIN public.channel_update_events e ON e.channel_id = c.id
|
||||
GROUP BY c.id, c.pts
|
||||
ON CONFLICT (channel_id) DO UPDATE SET
|
||||
latest_event_date = GREATEST(channel_update_checkpoints.latest_event_date, EXCLUDED.latest_event_date),
|
||||
latest_pts = GREATEST(channel_update_checkpoints.latest_pts, EXCLUDED.latest_pts),
|
||||
updated_at = now();
|
||||
|
||||
-- Global retention candidate seek. Exact per-channel deletion continues to use PK(channel_id,pts).
|
||||
CREATE INDEX IF NOT EXISTS channel_update_events_retention_seek_idx
|
||||
ON public.channel_update_events (date, channel_id, pts);
|
||||
20
deploy/migrations/0064_user_update_retention.down.sql
Normal file
20
deploy/migrations/0064_user_update_retention.down.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- TDesktop has no account-level differenceTooLong fallback. If any confirmed prefix has already
|
||||
-- been deleted, removing this floor/observed state would turn a durable history hole into a false
|
||||
-- empty difference. A rollback is safe only before retention has advanced anywhere.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.user_update_retention
|
||||
WHERE retained_through_pts > 0
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'cannot roll back user update retention: retained floor has advanced'
|
||||
USING ERRCODE = '55000';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DROP INDEX IF EXISTS public.user_update_events_retention_global_idx;
|
||||
DROP TABLE IF EXISTS public.user_update_retention;
|
||||
ALTER TABLE public.update_states DROP COLUMN IF EXISTS observed_pts;
|
||||
22
deploy/migrations/0064_user_update_retention.up.sql
Normal file
22
deploy/migrations/0064_user_update_retention.up.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- 服务端已发送/构造的 update_states.pts 不是客户端确认:difference 响应可能在网络中
|
||||
-- 丢失。observed_pts 只由客户端后续请求实际带回的 pts(或 getState 显式建立的快照
|
||||
-- baseline)推进,retention 只能使用这个水位。
|
||||
ALTER TABLE public.update_states
|
||||
ADD COLUMN observed_pts integer NOT NULL DEFAULT 0 CHECK (observed_pts >= 0);
|
||||
|
||||
-- 账号级 durable update 安全前缀回收水位。
|
||||
-- 这里只记录“所有当前授权设备都已明确确认”的连续前缀;TDesktop 不支持
|
||||
-- updates.differenceTooLong,故该表绝不能用于任意 TTL 硬裁剪。
|
||||
CREATE TABLE public.user_update_retention (
|
||||
user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
retained_through_pts integer NOT NULL DEFAULT 0 CHECK (retained_through_pts >= 0),
|
||||
retained_through_date integer NOT NULL DEFAULT 0 CHECK (retained_through_date >= 0),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX user_update_retention_updated_idx
|
||||
ON public.user_update_retention (updated_at, user_id);
|
||||
|
||||
-- retention 先按时间挑全局最老的安全候选,再按 (user_id, pts) 删除连续前缀。
|
||||
CREATE INDEX user_update_events_retention_global_idx
|
||||
ON public.user_update_events (date, user_id, pts);
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX IF EXISTS public.auth_keys_orphan_retention_idx;
|
||||
4
deploy/migrations/0065_auth_key_orphan_retention.up.sql
Normal file
4
deploy/migrations/0065_auth_key_orphan_retention.up.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- Bounded orphan auth-key GC seeks by creation time. Authorization and temp-key references are
|
||||
-- rechecked in the DELETE statement; active raw keys are supplied by the connection registry.
|
||||
CREATE INDEX IF NOT EXISTS auth_keys_orphan_retention_idx
|
||||
ON public.auth_keys (created_at, auth_key_id);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
DROP INDEX IF EXISTS dispatch_outbox_logical_shard_head_idx;
|
||||
DROP INDEX IF EXISTS dispatch_outbox_user_pts_uidx;
|
||||
39
deploy/migrations/0066_dispatch_outbox_user_lanes.up.sql
Normal file
39
deploy/migrations/0066_dispatch_outbox_user_lanes.up.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
-- 一条 durable user update 只能有一个在线投递任务。历史 schema 的
|
||||
-- ON CONFLICT DO NOTHING 没有对应唯一键,先显式保留最早任务并删除重复项。
|
||||
WITH duplicates AS (
|
||||
SELECT id
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
row_number() OVER (
|
||||
PARTITION BY target_user_id, pts
|
||||
-- 若历史重复中仍有可投递任务,优先保留它;不能让一个更早的
|
||||
-- failed 副本覆盖健康 pending 副本并人为阻塞该用户 lane。
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'pending' THEN 0
|
||||
WHEN 'dispatching' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
id ASC
|
||||
) AS rn
|
||||
FROM dispatch_outbox
|
||||
) ranked
|
||||
WHERE ranked.rn > 1
|
||||
)
|
||||
DELETE FROM dispatch_outbox d
|
||||
USING duplicates x
|
||||
WHERE d.id = x.id;
|
||||
|
||||
CREATE UNIQUE INDEX dispatch_outbox_user_pts_uidx
|
||||
ON dispatch_outbox (target_user_id, pts);
|
||||
|
||||
-- ClaimDispatchOutboxShards 固定用 256 logical shards;表达式必须与查询
|
||||
-- 完全一致,避免每个 worker 为筛自己的 lane 全表扫描。
|
||||
CREATE INDEX dispatch_outbox_logical_shard_head_idx
|
||||
ON dispatch_outbox (
|
||||
mod(target_user_id, 256::bigint),
|
||||
target_user_id,
|
||||
pts,
|
||||
id
|
||||
);
|
||||
7
deploy/migrations/0067_auth_key_last_used.down.sql
Normal file
7
deploy/migrations/0067_auth_key_last_used.down.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
DROP INDEX IF EXISTS public.temp_auth_key_bindings_perm_idx;
|
||||
DROP INDEX IF EXISTS public.auth_keys_orphan_last_used_idx;
|
||||
ALTER TABLE public.auth_keys DROP COLUMN IF EXISTS last_used_at;
|
||||
|
||||
-- Restore the 0065 schema used by the pre-last_used orphan collector.
|
||||
CREATE INDEX IF NOT EXISTS auth_keys_orphan_retention_idx
|
||||
ON public.auth_keys (created_at, auth_key_id);
|
||||
17
deploy/migrations/0067_auth_key_last_used.up.sql
Normal file
17
deploy/migrations/0067_auth_key_last_used.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- A physical connection touches last_used_at atomically while loading its key. Orphan GC uses
|
||||
-- this watermark in addition to the in-memory active-key snapshot, closing the Get->Register
|
||||
-- race without turning the per-frame encrypted fast path into a database write.
|
||||
ALTER TABLE public.auth_keys
|
||||
ADD COLUMN IF NOT EXISTS last_used_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS auth_keys_orphan_last_used_idx
|
||||
ON public.auth_keys (last_used_at, auth_key_id);
|
||||
|
||||
-- DeleteOrphaned now seeks exclusively by last_used_at. Keeping the transitional 0065
|
||||
-- created_at index would duplicate auth-key insert/delete maintenance without serving a query.
|
||||
DROP INDEX IF EXISTS public.auth_keys_orphan_retention_idx;
|
||||
|
||||
-- Delete/revoke and orphan-retention probes both resolve perm->temp bindings. The original
|
||||
-- primary key only covers temp_auth_key_id, so the reverse predicate otherwise scans the table.
|
||||
CREATE INDEX IF NOT EXISTS temp_auth_key_bindings_perm_idx
|
||||
ON public.temp_auth_key_bindings (perm_auth_key_id);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE public.private_messages
|
||||
DROP COLUMN IF EXISTS recipient_pts,
|
||||
DROP COLUMN IF EXISTS recipient_box_id,
|
||||
DROP COLUMN IF EXISTS sender_pts,
|
||||
DROP COLUMN IF EXISTS sender_box_id;
|
||||
15
deploy/migrations/0068_private_message_send_receipt.up.sql
Normal file
15
deploy/migrations/0068_private_message_send_receipt.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Exact random_id replay must not rebuild the original send result from mutable message_boxes:
|
||||
-- edit advances box pts/body and delete hides the row. Keep the immutable allocation receipt on
|
||||
-- the shared private message instead; 0073 adds the immutable first snapshot/delete receipt used
|
||||
-- to build the client acknowledgement without allocating new pts or update facts.
|
||||
ALTER TABLE public.private_messages
|
||||
ADD COLUMN sender_box_id integer NOT NULL DEFAULT 0 CHECK (sender_box_id >= 0),
|
||||
ADD COLUMN sender_pts integer NOT NULL DEFAULT 0 CHECK (sender_pts >= 0),
|
||||
ADD COLUMN recipient_box_id integer NOT NULL DEFAULT 0 CHECK (recipient_box_id >= 0),
|
||||
ADD COLUMN recipient_pts integer NOT NULL DEFAULT 0 CHECK (recipient_pts >= 0);
|
||||
|
||||
-- Keep zero defaults for writers released before this migration. Zero is not
|
||||
-- a valid immutable receipt: when a row has a valid request fingerprint but a
|
||||
-- legacy writer omitted these columns, the new replay path fails fast instead
|
||||
-- of deriving the first response from mutable message_boxes. New writers
|
||||
-- always persist positive sender receipt values before commit.
|
||||
12
deploy/migrations/0069_dispatch_outbox_user_heads.down.sql
Normal file
12
deploy/migrations/0069_dispatch_outbox_user_heads.down.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
CREATE INDEX IF NOT EXISTS dispatch_outbox_logical_shard_head_idx
|
||||
ON dispatch_outbox (
|
||||
mod(target_user_id, 256::bigint),
|
||||
target_user_id,
|
||||
pts,
|
||||
id
|
||||
);
|
||||
|
||||
DROP TRIGGER IF EXISTS dispatch_outbox_delete_user_head ON dispatch_outbox;
|
||||
DROP TRIGGER IF EXISTS dispatch_outbox_insert_user_head ON dispatch_outbox;
|
||||
DROP FUNCTION IF EXISTS dispatch_outbox_maintain_user_head();
|
||||
DROP TABLE IF EXISTS dispatch_outbox_user_heads;
|
||||
80
deploy/migrations/0069_dispatch_outbox_user_heads.up.sql
Normal file
80
deploy/migrations/0069_dispatch_outbox_user_heads.up.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
-- Claim 只需要查看每个用户当前未完成 head。把 head 持久化后,领取复杂度由
|
||||
-- “扫描全部 outbox 积压并 DISTINCT ON”降为“扫描有积压的用户 lane”。
|
||||
-- 迁移期间阻止并发写,保证 backfill 与随后安装的触发器之间没有缺口。
|
||||
LOCK TABLE dispatch_outbox IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
CREATE TABLE dispatch_outbox_user_heads (
|
||||
target_user_id bigint PRIMARY KEY,
|
||||
head_id bigint NOT NULL,
|
||||
head_pts integer NOT NULL CHECK (head_pts >= 0),
|
||||
logical_shard smallint GENERATED ALWAYS AS (
|
||||
mod(target_user_id, 256::bigint)::smallint
|
||||
) STORED,
|
||||
CHECK (logical_shard >= 0 AND logical_shard < 256)
|
||||
);
|
||||
|
||||
CREATE INDEX dispatch_outbox_user_heads_shard_idx
|
||||
ON dispatch_outbox_user_heads (logical_shard, target_user_id);
|
||||
|
||||
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
|
||||
SELECT DISTINCT ON (target_user_id)
|
||||
target_user_id,
|
||||
id,
|
||||
pts
|
||||
FROM dispatch_outbox
|
||||
ORDER BY target_user_id ASC, pts ASC, id ASC;
|
||||
|
||||
CREATE FUNCTION dispatch_outbox_maintain_user_head()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
removed_head bigint;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
|
||||
VALUES (NEW.target_user_id, NEW.id, NEW.pts)
|
||||
ON CONFLICT (target_user_id) DO UPDATE
|
||||
SET head_id = EXCLUDED.head_id,
|
||||
head_pts = EXCLUDED.head_pts
|
||||
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
|
||||
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- 删除非 head 不需要重算。删除 head 时用 (target_user_id, pts, id)
|
||||
-- 索引找下一条;failed head 同样会一直阻塞,直到被显式删除。
|
||||
DELETE FROM dispatch_outbox_user_heads
|
||||
WHERE target_user_id = OLD.target_user_id
|
||||
AND head_id = OLD.id
|
||||
RETURNING head_id INTO removed_head;
|
||||
|
||||
IF removed_head IS NOT NULL THEN
|
||||
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
|
||||
SELECT target_user_id, id, pts
|
||||
FROM dispatch_outbox
|
||||
WHERE target_user_id = OLD.target_user_id
|
||||
ORDER BY pts ASC, id ASC
|
||||
LIMIT 1
|
||||
ON CONFLICT (target_user_id) DO UPDATE
|
||||
SET head_id = EXCLUDED.head_id,
|
||||
head_pts = EXCLUDED.head_pts
|
||||
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
|
||||
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER dispatch_outbox_insert_user_head
|
||||
AFTER INSERT ON dispatch_outbox
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION dispatch_outbox_maintain_user_head();
|
||||
|
||||
CREATE TRIGGER dispatch_outbox_delete_user_head
|
||||
AFTER DELETE ON dispatch_outbox
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION dispatch_outbox_maintain_user_head();
|
||||
|
||||
-- Claim 已不再读取表达式 shard 索引;移除它避免每次 enqueue 的重复写放大。
|
||||
DROP INDEX IF EXISTS dispatch_outbox_logical_shard_head_idx;
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
DROP TRIGGER IF EXISTS dispatch_outbox_update_user_head ON dispatch_outbox;
|
||||
|
||||
CREATE OR REPLACE FUNCTION dispatch_outbox_maintain_user_head()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
removed_head bigint;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
|
||||
VALUES (NEW.target_user_id, NEW.id, NEW.pts)
|
||||
ON CONFLICT (target_user_id) DO UPDATE
|
||||
SET head_id = EXCLUDED.head_id,
|
||||
head_pts = EXCLUDED.head_pts
|
||||
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
|
||||
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
DELETE FROM dispatch_outbox_user_heads
|
||||
WHERE target_user_id = OLD.target_user_id
|
||||
AND head_id = OLD.id
|
||||
RETURNING head_id INTO removed_head;
|
||||
|
||||
IF removed_head IS NOT NULL THEN
|
||||
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
|
||||
SELECT target_user_id, id, pts
|
||||
FROM dispatch_outbox
|
||||
WHERE target_user_id = OLD.target_user_id
|
||||
ORDER BY pts ASC, id ASC
|
||||
LIMIT 1
|
||||
ON CONFLICT (target_user_id) DO UPDATE
|
||||
SET head_id = EXCLUDED.head_id,
|
||||
head_pts = EXCLUDED.head_pts
|
||||
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
|
||||
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE dispatch_outbox_user_heads
|
||||
DROP COLUMN updated_at,
|
||||
DROP COLUMN next_attempt_at,
|
||||
DROP COLUMN status;
|
||||
82
deploy/migrations/0070_dispatch_outbox_head_readiness.up.sql
Normal file
82
deploy/migrations/0070_dispatch_outbox_head_readiness.up.sql
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
-- Keep claim readiness on the one-row-per-user head itself. Otherwise PostgreSQL may
|
||||
-- legally reorder the head/outbox join and start from every eligible backlog row,
|
||||
-- reintroducing the full-backlog scan that 0069 is meant to remove.
|
||||
LOCK TABLE dispatch_outbox IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
ALTER TABLE dispatch_outbox_user_heads
|
||||
ADD COLUMN status varchar(16) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'dispatching', 'failed')),
|
||||
ADD COLUMN next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
UPDATE dispatch_outbox_user_heads h
|
||||
SET status = d.status,
|
||||
next_attempt_at = d.next_attempt_at,
|
||||
updated_at = d.updated_at
|
||||
FROM dispatch_outbox d
|
||||
WHERE d.target_user_id = h.target_user_id
|
||||
AND d.id = h.head_id;
|
||||
|
||||
CREATE OR REPLACE FUNCTION dispatch_outbox_maintain_user_head()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
removed_head bigint;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
INSERT INTO dispatch_outbox_user_heads (
|
||||
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
|
||||
) VALUES (
|
||||
NEW.target_user_id, NEW.id, NEW.pts, NEW.status, NEW.next_attempt_at, NEW.updated_at
|
||||
)
|
||||
ON CONFLICT (target_user_id) DO UPDATE
|
||||
SET head_id = EXCLUDED.head_id,
|
||||
head_pts = EXCLUDED.head_pts,
|
||||
status = EXCLUDED.status,
|
||||
next_attempt_at = EXCLUDED.next_attempt_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
|
||||
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
|
||||
RETURN NULL;
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
UPDATE dispatch_outbox_user_heads
|
||||
SET status = NEW.status,
|
||||
next_attempt_at = NEW.next_attempt_at,
|
||||
updated_at = NEW.updated_at
|
||||
WHERE target_user_id = NEW.target_user_id
|
||||
AND head_id = NEW.id;
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
DELETE FROM dispatch_outbox_user_heads
|
||||
WHERE target_user_id = OLD.target_user_id
|
||||
AND head_id = OLD.id
|
||||
RETURNING head_id INTO removed_head;
|
||||
|
||||
IF removed_head IS NOT NULL THEN
|
||||
INSERT INTO dispatch_outbox_user_heads (
|
||||
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
|
||||
)
|
||||
SELECT target_user_id, id, pts, status, next_attempt_at, updated_at
|
||||
FROM dispatch_outbox
|
||||
WHERE target_user_id = OLD.target_user_id
|
||||
ORDER BY pts ASC, id ASC
|
||||
LIMIT 1
|
||||
ON CONFLICT (target_user_id) DO UPDATE
|
||||
SET head_id = EXCLUDED.head_id,
|
||||
head_pts = EXCLUDED.head_pts,
|
||||
status = EXCLUDED.status,
|
||||
next_attempt_at = EXCLUDED.next_attempt_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
|
||||
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER dispatch_outbox_update_user_head
|
||||
AFTER UPDATE OF status, next_attempt_at, updated_at ON dispatch_outbox
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION dispatch_outbox_maintain_user_head();
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX IF EXISTS public.temp_auth_key_bindings_expiry_idx;
|
||||
5
deploy/migrations/0071_temp_auth_key_expiry_seek.up.sql
Normal file
5
deploy/migrations/0071_temp_auth_key_expiry_seek.up.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- DeleteExpiredTempAuthKeys orders/filters the binding table by expiry and then deletes the parent
|
||||
-- auth_keys rows in a bounded batch. The PK starts with temp_auth_key_id, so it cannot serve this
|
||||
-- maintenance seek.
|
||||
CREATE INDEX IF NOT EXISTS temp_auth_key_bindings_expiry_idx
|
||||
ON public.temp_auth_key_bindings (expires_at, temp_auth_key_id);
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
CREATE INDEX IF NOT EXISTS dispatch_outbox_pending_ready_idx
|
||||
ON dispatch_outbox (next_attempt_at, target_user_id, pts, id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dispatch_outbox_dispatching_stale_ready_idx
|
||||
ON dispatch_outbox (updated_at, target_user_id, pts, id)
|
||||
WHERE status = 'dispatching';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS dispatch_outbox_user_heads_shard_idx
|
||||
ON dispatch_outbox_user_heads (logical_shard, target_user_id);
|
||||
|
||||
ALTER TABLE dispatch_outbox_user_heads
|
||||
DROP CONSTRAINT IF EXISTS dispatch_outbox_user_heads_outbox_fkey;
|
||||
|
||||
DROP INDEX IF EXISTS dispatch_outbox_user_heads_dispatching_shard_idx;
|
||||
DROP INDEX IF EXISTS dispatch_outbox_user_heads_pending_shard_idx;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
-- Claim only eligible durable heads. Partial indexes keep work proportional to ready
|
||||
-- user lanes instead of every account that has a deferred/failed backlog.
|
||||
CREATE INDEX dispatch_outbox_user_heads_pending_shard_idx
|
||||
ON dispatch_outbox_user_heads (
|
||||
logical_shard,
|
||||
next_attempt_at,
|
||||
target_user_id,
|
||||
head_pts,
|
||||
head_id
|
||||
)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX dispatch_outbox_user_heads_dispatching_shard_idx
|
||||
ON dispatch_outbox_user_heads (
|
||||
logical_shard,
|
||||
updated_at,
|
||||
target_user_id,
|
||||
head_pts,
|
||||
head_id
|
||||
)
|
||||
WHERE status = 'dispatching';
|
||||
|
||||
-- A head must always reference the exact outbox row it represents. The base schema already has
|
||||
-- dispatch_outbox_target_user_id_id_key on these columns; reuse it instead of maintaining a
|
||||
-- duplicate unique index on every enqueue/delete. Deferred validation lets the AFTER DELETE
|
||||
-- trigger promote/delete the head in the same tx.
|
||||
ALTER TABLE dispatch_outbox_user_heads
|
||||
ADD CONSTRAINT dispatch_outbox_user_heads_outbox_fkey
|
||||
FOREIGN KEY (target_user_id, head_id)
|
||||
REFERENCES dispatch_outbox (target_user_id, id)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
DROP INDEX IF EXISTS dispatch_outbox_user_heads_shard_idx;
|
||||
DROP INDEX IF EXISTS dispatch_outbox_pending_ready_idx;
|
||||
DROP INDEX IF EXISTS dispatch_outbox_dispatching_stale_ready_idx;
|
||||
17
deploy/migrations/0073_send_replay_snapshots.down.sql
Normal file
17
deploy/migrations/0073_send_replay_snapshots.down.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
ALTER TABLE public.channel_messages
|
||||
DROP CONSTRAINT IF EXISTS channel_messages_delete_ids_array,
|
||||
DROP CONSTRAINT IF EXISTS channel_messages_send_snapshot_object,
|
||||
DROP COLUMN IF EXISTS delete_message_ids,
|
||||
DROP COLUMN IF EXISTS delete_date,
|
||||
DROP COLUMN IF EXISTS delete_pts_count,
|
||||
DROP COLUMN IF EXISTS delete_pts,
|
||||
DROP COLUMN IF EXISTS send_snapshot;
|
||||
|
||||
ALTER TABLE public.private_messages
|
||||
DROP CONSTRAINT IF EXISTS private_messages_sender_delete_ids_array,
|
||||
DROP CONSTRAINT IF EXISTS private_messages_sender_snapshot_object,
|
||||
DROP COLUMN IF EXISTS sender_delete_message_ids,
|
||||
DROP COLUMN IF EXISTS sender_delete_date,
|
||||
DROP COLUMN IF EXISTS sender_delete_pts_count,
|
||||
DROP COLUMN IF EXISTS sender_delete_pts,
|
||||
DROP COLUMN IF EXISTS sender_snapshot;
|
||||
23
deploy/migrations/0073_send_replay_snapshots.up.sql
Normal file
23
deploy/migrations/0073_send_replay_snapshots.up.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- Lost-response random_id replay must be able to acknowledge the original send
|
||||
-- after mutable message rows were edited or deleted. Keep the first sender echo
|
||||
-- and the exact durable delete event alongside the idempotency key.
|
||||
ALTER TABLE public.private_messages
|
||||
ADD COLUMN sender_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN sender_delete_pts integer NOT NULL DEFAULT 0 CHECK (sender_delete_pts >= 0),
|
||||
ADD COLUMN sender_delete_pts_count integer NOT NULL DEFAULT 0 CHECK (sender_delete_pts_count >= 0),
|
||||
ADD COLUMN sender_delete_date integer NOT NULL DEFAULT 0 CHECK (sender_delete_date >= 0),
|
||||
ADD COLUMN sender_delete_message_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD CONSTRAINT private_messages_sender_snapshot_object CHECK (jsonb_typeof(sender_snapshot) IS NOT DISTINCT FROM 'object'),
|
||||
ADD CONSTRAINT private_messages_sender_delete_ids_array CHECK (jsonb_typeof(sender_delete_message_ids) IS NOT DISTINCT FROM 'array');
|
||||
|
||||
ALTER TABLE public.channel_messages
|
||||
ADD COLUMN send_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN delete_pts integer NOT NULL DEFAULT 0 CHECK (delete_pts >= 0),
|
||||
ADD COLUMN delete_pts_count integer NOT NULL DEFAULT 0 CHECK (delete_pts_count >= 0),
|
||||
ADD COLUMN delete_date integer NOT NULL DEFAULT 0 CHECK (delete_date >= 0),
|
||||
ADD COLUMN delete_message_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD CONSTRAINT channel_messages_send_snapshot_object CHECK (jsonb_typeof(send_snapshot) IS NOT DISTINCT FROM 'object'),
|
||||
ADD CONSTRAINT channel_messages_delete_ids_array CHECK (jsonb_typeof(delete_message_ids) IS NOT DISTINCT FROM 'array');
|
||||
|
||||
-- Existing rows predate immutable replay snapshots. Leaving them as {} makes a
|
||||
-- replay fail fast instead of guessing the first response from mutable state.
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX IF EXISTS public.dispatch_outbox_user_heads_failed_cleanup_idx;
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- Terminal failed rows are short-lived diagnostic quarantine entries. Cleanup starts from the
|
||||
-- one-row-per-user durable head so it can lock in the same head→outbox order as claim/completion
|
||||
-- without scanning healthy lanes.
|
||||
CREATE INDEX dispatch_outbox_user_heads_failed_cleanup_idx
|
||||
ON public.dispatch_outbox_user_heads (
|
||||
updated_at,
|
||||
target_user_id,
|
||||
head_id
|
||||
)
|
||||
WHERE status = 'failed';
|
||||
1
deploy/migrations/0075_uploaded_media_receipts.down.sql
Normal file
1
deploy/migrations/0075_uploaded_media_receipts.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.uploaded_media_receipts;
|
||||
12
deploy/migrations/0075_uploaded_media_receipts.up.sql
Normal file
12
deploy/migrations/0075_uploaded_media_receipts.up.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- upload.saveFilePart data is transient, while messages.sendMedia may be replayed after its first
|
||||
-- response is lost. Preserve the immutable Photo/Document materialization selected for each
|
||||
-- (owner,file_id) so InputMediaUploaded* remains idempotent after part cleanup.
|
||||
CREATE TABLE public.uploaded_media_receipts (
|
||||
owner_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
file_id bigint NOT NULL CHECK (file_id <> 0),
|
||||
intent_hash bytea NOT NULL CHECK (octet_length(intent_hash) = 32),
|
||||
media_kind text NOT NULL CHECK (media_kind IN ('photo', 'document')),
|
||||
media_id bigint NOT NULL CHECK (media_id <> 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (owner_user_id, file_id)
|
||||
);
|
||||
1
deploy/migrations/0076_album_group_reservations.down.sql
Normal file
1
deploy/migrations/0076_album_group_reservations.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS album_group_reservations;
|
||||
17
deploy/migrations/0076_album_group_reservations.up.sql
Normal file
17
deploy/migrations/0076_album_group_reservations.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- sendMultiMedia 必须在解析上传媒体、逐条发送之前持久预留 grouped_id。
|
||||
-- 这张表把每个发送 random_id 固定到会话作用域内的相册组,使中途失败后
|
||||
-- 客户端只重试失败子集时仍能恢复首次整包使用的 grouped_id;intent_hash
|
||||
-- 同时阻止内容已改变的相同 random_id 借旧预留错误合并。
|
||||
CREATE TABLE album_group_reservations (
|
||||
sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
peer_type TEXT NOT NULL CHECK (peer_type IN ('user', 'channel')),
|
||||
peer_id BIGINT NOT NULL CHECK (peer_id > 0),
|
||||
random_id BIGINT NOT NULL CHECK (random_id <> 0),
|
||||
intent_hash BYTEA NOT NULL CHECK (octet_length(intent_hash) = 32),
|
||||
grouped_id BIGINT NOT NULL CHECK (grouped_id <> 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (sender_user_id, peer_type, peer_id, random_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE album_group_reservations IS
|
||||
'Durable pre-send binding from album item random_id to grouped_id; never reconstructed from a retry subset.';
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- Contract rollback: only safe after every pre-0062/0068 writer has drained.
|
||||
ALTER TABLE public.private_messages
|
||||
ALTER COLUMN request_fingerprint DROP DEFAULT,
|
||||
ALTER COLUMN recipient_delivered DROP DEFAULT,
|
||||
ALTER COLUMN sender_box_id DROP DEFAULT,
|
||||
ALTER COLUMN sender_pts DROP DEFAULT,
|
||||
ALTER COLUMN recipient_box_id DROP DEFAULT,
|
||||
ALTER COLUMN recipient_pts DROP DEFAULT;
|
||||
17
deploy/migrations/0077_correct_private_send_defaults.up.sql
Normal file
17
deploy/migrations/0077_correct_private_send_defaults.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- 0062 and 0068 originally removed their expand-phase defaults immediately.
|
||||
-- Databases that already applied those revisions therefore reject INSERTs from
|
||||
-- an older telesrv process during a rolling deployment. Restore the permanent
|
||||
-- legacy-writer sentinels; fresh databases receive the same defaults directly
|
||||
-- from the corrected original migrations.
|
||||
ALTER TABLE public.private_messages
|
||||
ALTER COLUMN request_fingerprint SET DEFAULT '\x'::bytea,
|
||||
ALTER COLUMN recipient_delivered SET DEFAULT false,
|
||||
ALTER COLUMN sender_box_id SET DEFAULT 0,
|
||||
ALTER COLUMN sender_pts SET DEFAULT 0,
|
||||
ALTER COLUMN recipient_box_id SET DEFAULT 0,
|
||||
ALTER COLUMN recipient_pts SET DEFAULT 0;
|
||||
|
||||
-- Empty fingerprint and zero receipt fields mean "unknown legacy send". They
|
||||
-- are deliberately not backfilled: the immutable first response cannot be
|
||||
-- reconstructed from message_boxes after edit/delete. Replay must reject or
|
||||
-- fail fast through the store invariants instead of normalizing these values.
|
||||
3
deploy/migrations/0078_channel_send_fingerprint.down.sql
Normal file
3
deploy/migrations/0078_channel_send_fingerprint.down.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE public.channel_messages
|
||||
DROP CONSTRAINT IF EXISTS channel_messages_request_fingerprint_size,
|
||||
DROP COLUMN IF EXISTS request_fingerprint;
|
||||
16
deploy/migrations/0078_channel_send_fingerprint.up.sql
Normal file
16
deploy/migrations/0078_channel_send_fingerprint.up.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Persist the immutable client intent beside a channel random_id receipt.
|
||||
--
|
||||
-- The empty default is intentional for rolling deploys: binaries that predate
|
||||
-- this migration can continue to insert channel/service messages. Empty
|
||||
-- fingerprints are legacy/unknown receipts and the replay path rejects them;
|
||||
-- it never guesses intent from an editable message projection.
|
||||
ALTER TABLE public.channel_messages
|
||||
ADD COLUMN request_fingerprint bytea NOT NULL DEFAULT '\x';
|
||||
|
||||
ALTER TABLE public.channel_messages
|
||||
ADD CONSTRAINT channel_messages_request_fingerprint_size
|
||||
CHECK (octet_length(request_fingerprint) IN (0, 32)) NOT VALID;
|
||||
|
||||
-- NOT VALID avoids a blocking historical-table validation scan during the
|
||||
-- rolling migration while PostgreSQL still enforces the check for every new
|
||||
-- or updated row. A later maintenance window may validate it online.
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS login_code_message_deliveries;
|
||||
22
deploy/migrations/0079_login_code_message_deliveries.up.sql
Normal file
22
deploy/migrations/0079_login_code_message_deliveries.up.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- A phone_code_hash identifies exactly one account-visible 777000 login-code
|
||||
-- notification. Store only its SHA-256 digest plus compact immutable allocation
|
||||
-- facts; the secret code body remains solely in private_messages/message_boxes.
|
||||
CREATE TABLE login_code_message_deliveries (
|
||||
delivery_key bytea PRIMARY KEY,
|
||||
code_fingerprint bytea NOT NULL,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
private_message_id bigint NOT NULL CHECK (private_message_id > 0),
|
||||
message_box_id integer NOT NULL CHECK (message_box_id > 0),
|
||||
pts integer NOT NULL CHECK (pts > 0),
|
||||
message_date integer NOT NULL CHECK (message_date > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT login_code_message_deliveries_key_size CHECK (octet_length(delivery_key) = 32),
|
||||
CONSTRAINT login_code_message_deliveries_fingerprint_size CHECK (octet_length(code_fingerprint) = 32),
|
||||
CONSTRAINT login_code_message_deliveries_user_box_unique UNIQUE (user_id, message_box_id),
|
||||
CONSTRAINT login_code_message_deliveries_user_pts_unique UNIQUE (user_id, pts)
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN login_code_message_deliveries.delivery_key IS
|
||||
'SHA-256(phone_code_hash); raw phone_code_hash is never persisted';
|
||||
COMMENT ON COLUMN login_code_message_deliveries.code_fingerprint IS
|
||||
'HMAC-SHA-256(code), keyed by the non-persisted raw phone_code_hash';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
DROP INDEX IF EXISTS login_code_message_deliveries_expiry_idx;
|
||||
|
||||
ALTER TABLE login_code_message_deliveries
|
||||
DROP COLUMN IF EXISTS expires_at;
|
||||
15
deploy/migrations/0080_login_code_delivery_expiry.up.sql
Normal file
15
deploy/migrations/0080_login_code_delivery_expiry.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
-- Compact login-code idempotency receipts are only needed while the opaque
|
||||
-- code can still be used/replayed. Keep them seek-prunable instead of growing
|
||||
-- one row per login attempt forever.
|
||||
ALTER TABLE login_code_message_deliveries
|
||||
ADD COLUMN expires_at timestamptz;
|
||||
|
||||
UPDATE login_code_message_deliveries
|
||||
SET expires_at = created_at + interval '24 hours'
|
||||
WHERE expires_at IS NULL;
|
||||
|
||||
ALTER TABLE login_code_message_deliveries
|
||||
ALTER COLUMN expires_at SET NOT NULL;
|
||||
|
||||
CREATE INDEX login_code_message_deliveries_expiry_idx
|
||||
ON login_code_message_deliveries (expires_at, delivery_key);
|
||||
Loading…
Add table
Add a link
Reference in a new issue