s3 support

This commit is contained in:
onysd 2026-08-04 23:09:11 +03:00
parent fa5cfaf14d
commit 03f10b66ee
53 changed files with 2796 additions and 102 deletions

View file

@ -479,6 +479,11 @@ ORDER BY id`, channel.ID, id32)
// 删除入口统一静默跳过(官方客户端对它禁用删除)。
continue
}
// Drop this message's media_references (storage GC); orphans the
// document/photo if this was its last live reference anywhere.
if err := removeMediaReferencesByKeyTx(ctx, tx, domain.MediaRefKindChannelMessage, channelMessageRefKey(channel.ID, id)); err != nil {
return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("remove deleted channel message media references: %w", err)
}
deleted = append(deleted, id)
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channel.ID,

View file

@ -272,6 +272,10 @@ WHERE location_key = ANY($1::text[])`, locationKeys)
return out, nil
}
func (s *MediaStore) SumFileBlobBytes(ctx context.Context) (int64, error) {
return s.q.SumFileBlobBytes(ctx)
}
func (s *MediaStore) GetSeedState(ctx context.Context, key string) (string, bool, error) {
var hash string
if err := s.db.QueryRow(ctx, `
@ -332,6 +336,7 @@ func putDocumentParams(doc domain.Document) (sqlcgen.PutDocumentParams, error) {
DcID: int32(doc.DCID),
AttributesJson: attrs,
ThumbsJson: thumbs,
OwnerUserID: doc.OwnerUserID,
}, nil
}
@ -472,6 +477,20 @@ func (c *documentMetaCache) put(id int64, doc domain.Document) {
}
}
// remove evicts id, e.g. after the row is permanently deleted (storage
// retention sweep) so a stale cache hit can't outlive the row.
func (c *documentMetaCache) remove(id int64) {
if c == nil || id == 0 {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.m[id]; ok {
c.ll.Remove(el)
delete(c.m, id)
}
}
func cloneDocument(doc domain.Document) domain.Document {
doc.FileReference = append([]byte(nil), doc.FileReference...)
if len(doc.Attributes) > 0 {
@ -514,6 +533,7 @@ func documentFromRow(row sqlcgen.GetDocumentRow) (domain.Document, error) {
DCID: int(row.DcID),
Attributes: attrs,
Thumbs: thumbs,
OwnerUserID: row.OwnerUserID,
}, nil
}
@ -532,6 +552,7 @@ func (s *MediaStore) PutPhoto(ctx context.Context, photo domain.Photo) error {
DcID: int32(photo.DCID),
HasStickers: photo.HasStickers,
SizesJson: sizes,
OwnerUserID: photo.OwnerUserID,
})
}
@ -543,7 +564,7 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool
}
return domain.Photo{}, false, err
}
photo, err := photoFromFields(row.ID, row.AccessHash, row.FileReference, int(row.Date), int(row.DcID), row.HasStickers, row.SizesJson)
photo, err := photoFromFields(row.ID, row.AccessHash, row.FileReference, int(row.Date), int(row.DcID), row.HasStickers, row.SizesJson, row.OwnerUserID)
if err != nil {
return domain.Photo{}, false, err
}
@ -572,7 +593,7 @@ func (s *MediaStore) GetPhotos(ctx context.Context, ids []int64) ([]domain.Photo
return nil, nil
}
rows, err := s.db.Query(ctx, `
SELECT id, access_hash, file_reference, date, dc_id, has_stickers, sizes::text
SELECT id, access_hash, file_reference, date, dc_id, has_stickers, sizes::text, owner_user_id
FROM photos
WHERE id = ANY($1::bigint[])
`, unique)
@ -613,14 +634,15 @@ func scanPhotoRow(row photoScanner) (domain.Photo, error) {
dcID int32
hasStickers bool
sizesJSON string
ownerUserID int64
)
if err := row.Scan(&id, &accessHash, &fileReference, &date, &dcID, &hasStickers, &sizesJSON); err != nil {
if err := row.Scan(&id, &accessHash, &fileReference, &date, &dcID, &hasStickers, &sizesJSON, &ownerUserID); err != nil {
return domain.Photo{}, err
}
return photoFromFields(id, accessHash, fileReference, int(date), int(dcID), hasStickers, sizesJSON)
return photoFromFields(id, accessHash, fileReference, int(date), int(dcID), hasStickers, sizesJSON, ownerUserID)
}
func photoFromFields(id, accessHash int64, fileReference []byte, date, dcID int, hasStickers bool, sizesJSON string) (domain.Photo, error) {
func photoFromFields(id, accessHash int64, fileReference []byte, date, dcID int, hasStickers bool, sizesJSON string, ownerUserID int64) (domain.Photo, error) {
sizes, err := decodePhotoSizes(sizesJSON)
if err != nil {
return domain.Photo{}, err
@ -633,6 +655,7 @@ func photoFromFields(id, accessHash int64, fileReference []byte, date, dcID int,
DCID: dcID,
HasStickers: hasStickers,
Sizes: sizes,
OwnerUserID: ownerUserID,
}, nil
}
@ -697,7 +720,7 @@ func (s *MediaStore) CreateStickerSet(ctx context.Context, set domain.StickerSet
return err
}
}
return nil
return addStickerSetMediaReferencesTx(ctx, qtx, set.ID, docs)
})
if err != nil {
if stickerSetShortNameConflict(err) {
@ -726,7 +749,13 @@ func (s *MediaStore) UpdateStickerSet(ctx context.Context, set domain.StickerSet
return err
}
}
return nil
// Re-register from scratch: drops references for any document no
// longer in the set (candidate for storage GC once orphaned long
// enough) and refreshes the rest.
if err := removeMediaReferencesByKeyTx(ctx, tx, domain.MediaRefKindStickerSet, stickerSetRefKey(set.ID)); err != nil {
return err
}
return addStickerSetMediaReferencesTx(ctx, qtx, set.ID, docs)
})
if err != nil {
return err
@ -751,8 +780,10 @@ WHERE id = $1
if tag.RowsAffected() == 0 {
return domain.ErrStickerSetInvalid
}
_, err = tx.Exec(ctx, `DELETE FROM user_sticker_sets WHERE sticker_set_id = $1`, setID)
return err
if _, err := tx.Exec(ctx, `DELETE FROM user_sticker_sets WHERE sticker_set_id = $1`, setID); err != nil {
return err
}
return removeMediaReferencesByKeyTx(ctx, tx, domain.MediaRefKindStickerSet, stickerSetRefKey(setID))
})
}
@ -769,11 +800,40 @@ WHERE id = $1
if tag.RowsAffected() == 0 {
return domain.ErrStickerSetInvalid
}
_, err = tx.Exec(ctx, `DELETE FROM user_sticker_sets WHERE sticker_set_id = $1`, setID)
return err
if _, err := tx.Exec(ctx, `DELETE FROM user_sticker_sets WHERE sticker_set_id = $1`, setID); err != nil {
return err
}
return removeMediaReferencesByKeyTx(ctx, tx, domain.MediaRefKindStickerSet, stickerSetRefKey(setID))
})
}
func stickerSetRefKey(setID int64) string {
return fmt.Sprintf("stickerset:%d", setID)
}
// addStickerSetMediaReferencesTx registers every document belonging to a
// sticker set as referenced (storage GC), clearing orphaned_at on each.
func addStickerSetMediaReferencesTx(ctx context.Context, qtx *sqlcgen.Queries, setID int64, docs []domain.Document) error {
refKey := stickerSetRefKey(setID)
for _, doc := range docs {
if doc.ID == 0 {
continue
}
if err := qtx.InsertMediaReference(ctx, sqlcgen.InsertMediaReferenceParams{
MediaKind: string(domain.MediaKindDocument),
MediaID: doc.ID,
RefKind: string(domain.MediaRefKindStickerSet),
RefKey: refKey,
}); err != nil {
return fmt.Errorf("register sticker set media reference: %w", err)
}
if err := qtx.ClearDocumentOrphan(ctx, doc.ID); err != nil {
return fmt.Errorf("clear sticker document orphan: %w", err)
}
}
return nil
}
func insertStickerSet(ctx context.Context, db sqlcgen.DBTX, set domain.StickerSet) error {
thumbs, err := jsonArrayOrEmpty(set.Thumbs)
if err != nil {
@ -1197,15 +1257,29 @@ func (s *MediaStore) AddProfilePhotoKind(ctx context.Context, ownerType domain.P
if err != nil {
return err
}
_, err = s.db.Exec(ctx, `
if _, err := s.db.Exec(ctx, `
INSERT INTO profile_photos (owner_peer_type, owner_peer_id, kind, photo_id, date, active, sort_order)
VALUES ($1, $2, $3, $4, $5, true, $6)
ON CONFLICT (owner_peer_type, owner_peer_id, kind, photo_id) DO UPDATE SET
date = EXCLUDED.date,
active = true,
sort_order = EXCLUDED.sort_order
`, string(ownerType), ownerID, string(kind), photoID, date, next+1)
return err
`, string(ownerType), ownerID, string(kind), photoID, date, next+1); err != nil {
return err
}
if err := s.q.InsertMediaReference(ctx, sqlcgen.InsertMediaReferenceParams{
MediaKind: string(domain.MediaKindPhoto),
MediaID: photoID,
RefKind: string(domain.MediaRefKindProfilePhoto),
RefKey: profilePhotoRefKey(ownerType, ownerID, kind, photoID),
}); err != nil {
return fmt.Errorf("register profile photo reference: %w", err)
}
return s.q.ClearPhotoOrphan(ctx, photoID)
}
func profilePhotoRefKey(ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64) string {
return fmt.Sprintf("%s:%d:kind:%s:photo:%d", ownerType, ownerID, kind, photoID)
}
func (s *MediaStore) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) {
@ -1336,7 +1410,7 @@ func (s *MediaStore) ListProfilePhotoDetailsKind(ctx context.Context, ownerType
var err error
if offset < 0 && maxID > 0 {
rows, err = s.db.Query(ctx, `
SELECT ph.id, ph.access_hash, ph.file_reference, ph.date, ph.dc_id, ph.has_stickers, ph.sizes::text AS sizes_json
SELECT ph.id, ph.access_hash, ph.file_reference, ph.date, ph.dc_id, ph.has_stickers, ph.sizes::text AS sizes_json, ph.owner_user_id
FROM profile_photos pp
JOIN photos ph ON ph.id = pp.photo_id
WHERE pp.owner_peer_type = $1
@ -1352,7 +1426,7 @@ LIMIT $5
offset = 0
}
rows, err = s.db.Query(ctx, `
SELECT ph.id, ph.access_hash, ph.file_reference, ph.date, ph.dc_id, ph.has_stickers, ph.sizes::text AS sizes_json
SELECT ph.id, ph.access_hash, ph.file_reference, ph.date, ph.dc_id, ph.has_stickers, ph.sizes::text AS sizes_json, ph.owner_user_id
FROM profile_photos pp
JOIN photos ph ON ph.id = pp.photo_id
WHERE pp.owner_peer_type = $1
@ -1429,6 +1503,19 @@ RETURNING photo_id
if err := rows.Err(); err != nil {
return nil, err
}
for _, id := range deleted {
if err := s.q.RemoveMediaReference(ctx, sqlcgen.RemoveMediaReferenceParams{
MediaKind: string(domain.MediaKindPhoto),
MediaID: id,
RefKind: string(domain.MediaRefKindProfilePhoto),
RefKey: profilePhotoRefKey(ownerType, ownerID, kind, id),
}); err != nil {
return nil, fmt.Errorf("remove profile photo reference: %w", err)
}
if err := s.q.OrphanPhotoIfUnreferenced(ctx, id); err != nil {
return nil, fmt.Errorf("orphan check profile photo: %w", err)
}
}
return deleted, nil
}

View file

@ -13,6 +13,8 @@ import (
// 写路径只在「创建」和「编辑改媒体」两处维护,删除靠读查询 JOIN 过滤 deleted、不在此维护。
// insertChannelMediaIndexTx 为一条频道消息按其媒体类别写索引行(无类别则 no-op)。
// 同时登记该消息内嵌 document/photo 的 media_references(存储回收用),与分类
// 索引共用同一事务。
func insertChannelMediaIndexTx(ctx context.Context, tx pgx.Tx, channelID int64, id, date int, media *domain.MessageMedia, entities []domain.MessageEntity) error {
for _, c := range domain.ClassifyMediaCategories(media, entities) {
if _, err := tx.Exec(ctx, `
@ -22,15 +24,18 @@ ON CONFLICT (channel_id, id, category) DO NOTHING`, channelID, id, int16(c), dat
return fmt.Errorf("insert channel media index: %w", err)
}
}
return nil
return addMediaReferencesTx(ctx, tx, media, domain.MediaRefKindChannelMessage, channelMessageRefKey(channelID, id))
}
// deleteChannelMediaIndexTx 清掉一条频道消息的全部索引行(编辑改媒体前先清后插)。
// 只在 replaceChannelMediaIndexTx 内被调用;真正的消息删除不清 *_media 分类索引
// (读时靠 JOIN deleted 过滤,见文件头注释),但仍需在此清掉 media_references,
// 否则 replace 场景下旧媒体永远不会被判定为孤儿。
func deleteChannelMediaIndexTx(ctx context.Context, tx pgx.Tx, channelID int64, id int) error {
if _, err := tx.Exec(ctx, `DELETE FROM channel_message_media WHERE channel_id = $1 AND id = $2`, channelID, id); err != nil {
return fmt.Errorf("delete channel media index: %w", err)
}
return nil
return removeMediaReferencesByKeyTx(ctx, tx, domain.MediaRefKindChannelMessage, channelMessageRefKey(channelID, id))
}
// replaceChannelMediaIndexTx 在编辑替换媒体后重建索引行(类别可能变化)。
@ -41,7 +46,8 @@ func replaceChannelMediaIndexTx(ctx context.Context, tx pgx.Tx, channelID int64,
return insertChannelMediaIndexTx(ctx, tx, channelID, id, date, media, entities)
}
// insertMessageBoxMediaIndexTx 为一条私聊 owner box 按其媒体类别写索引行。
// insertMessageBoxMediaIndexTx 为一条私聊 owner box 按其媒体类别写索引行。同时登记
// media_references(存储回收用)。
func insertMessageBoxMediaIndexTx(ctx context.Context, tx pgx.Tx, ownerUserID, peerID int64, boxID, date int, media *domain.MessageMedia, entities []domain.MessageEntity) error {
for _, c := range domain.ClassifyMediaCategories(media, entities) {
if _, err := tx.Exec(ctx, `
@ -51,15 +57,18 @@ ON CONFLICT (owner_user_id, box_id, category) DO NOTHING`, ownerUserID, boxID, p
return fmt.Errorf("insert message box media index: %w", err)
}
}
return nil
return addMediaReferencesTx(ctx, tx, media, domain.MediaRefKindMessageBox, messageBoxRefKey(ownerUserID, boxID))
}
// deleteMessageBoxMediaIndexTx 清掉一条私聊 owner box 的全部索引行。
// deleteMessageBoxMediaIndexTx 清掉一条私聊 owner box 的全部索引行。只在
// replaceMessageBoxMediaIndexTx 内被调用(编辑改媒体前先清后插);真正的消息
// 删除不清 message_box_media(读时靠 JOIN deleted 过滤),但仍需在此清掉
// media_references,否则 replace 场景下旧媒体永远不会被判定为孤儿。
func deleteMessageBoxMediaIndexTx(ctx context.Context, tx pgx.Tx, ownerUserID int64, boxID int) error {
if _, err := tx.Exec(ctx, `DELETE FROM message_box_media WHERE owner_user_id = $1 AND box_id = $2`, ownerUserID, boxID); err != nil {
return fmt.Errorf("delete message box media index: %w", err)
}
return nil
return removeMediaReferencesByKeyTx(ctx, tx, domain.MediaRefKindMessageBox, messageBoxRefKey(ownerUserID, boxID))
}
// replaceMessageBoxMediaIndexTx 在编辑替换媒体后重建索引行。

View file

@ -0,0 +1,212 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
// messageBoxRefKey/channelMessageRefKey are the ref_key encodings used by
// media_references rows registered from the private-mailbox and channel
// message write paths, respectively. Kept as named helpers so the add and
// remove sides can never drift apart on format.
func messageBoxRefKey(ownerUserID int64, boxID int) string {
return fmt.Sprintf("user:%d:box:%d", ownerUserID, boxID)
}
func channelMessageRefKey(channelID int64, messageID int) string {
return fmt.Sprintf("channel:%d:msg:%d", channelID, messageID)
}
// addMediaReferencesTx registers every document/photo embedded in media as
// referenced by refKind/refKey, clearing orphaned_at on each if it had been
// set by an earlier removal. Must run in the same transaction as the write
// that creates the reference (message send/edit).
func addMediaReferencesTx(ctx context.Context, tx sqlcgen.DBTX, media *domain.MessageMedia, refKind domain.MediaRefKind, refKey string) error {
targets := domain.ExtractMediaRefTargets(media)
if len(targets) == 0 {
return nil
}
q := sqlcgen.New(tx)
for _, t := range targets {
if err := q.InsertMediaReference(ctx, sqlcgen.InsertMediaReferenceParams{
MediaKind: string(t.Kind),
MediaID: t.ID,
RefKind: string(refKind),
RefKey: refKey,
}); err != nil {
return fmt.Errorf("insert media reference: %w", err)
}
var clearErr error
switch t.Kind {
case domain.MediaKindDocument:
clearErr = q.ClearDocumentOrphan(ctx, t.ID)
case domain.MediaKindPhoto:
clearErr = q.ClearPhotoOrphan(ctx, t.ID)
}
if clearErr != nil {
return fmt.Errorf("clear media orphan: %w", clearErr)
}
}
return nil
}
// removeMediaReferencesByKeyTx drops every media_references row registered
// under refKind/refKey (no need to know which document/photo ids those were
// -- the delete finds them) and, for each one that becomes fully
// unreferenced as a result, marks it orphaned so the storage retention
// sweep can consider it once old enough. Must run in the same transaction
// as the write that removes the reference (a message being soft-deleted).
func removeMediaReferencesByKeyTx(ctx context.Context, tx sqlcgen.DBTX, refKind domain.MediaRefKind, refKey string) error {
rows, err := tx.Query(ctx, `
DELETE FROM media_references
WHERE ref_kind = $1 AND ref_key = $2
RETURNING media_kind, media_id`, string(refKind), refKey)
if err != nil {
return fmt.Errorf("remove media references: %w", err)
}
type removedRef struct {
kind string
id int64
}
var removed []removedRef
for rows.Next() {
var r removedRef
if err := rows.Scan(&r.kind, &r.id); err != nil {
rows.Close()
return fmt.Errorf("scan removed media reference: %w", err)
}
removed = append(removed, r)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("remove media references: %w", err)
}
rows.Close()
q := sqlcgen.New(tx)
for _, r := range removed {
var orphanErr error
switch domain.MediaKind(r.kind) {
case domain.MediaKindDocument:
orphanErr = q.OrphanDocumentIfUnreferenced(ctx, r.id)
case domain.MediaKindPhoto:
orphanErr = q.OrphanPhotoIfUnreferenced(ctx, r.id)
}
if orphanErr != nil {
return fmt.Errorf("orphan check media: %w", orphanErr)
}
}
return nil
}
// ---- storage retention sweep ----
// ListOrphanedDocumentIDsOlderThan returns document ids whose orphaned_at is
// set and older than cutoff, oldest first, up to limit.
func (s *MediaStore) ListOrphanedDocumentIDsOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error) {
if limit <= 0 {
return nil, nil
}
return s.q.ListOrphanedDocumentIDsOlderThan(ctx, sqlcgen.ListOrphanedDocumentIDsOlderThanParams{
Cutoff: pgtype.Timestamptz{Time: cutoff, Valid: true},
BatchLimit: int32(limit),
})
}
// ListOrphanedPhotoIDsOlderThan returns photo ids whose orphaned_at is set
// and older than cutoff, oldest first, up to limit.
func (s *MediaStore) ListOrphanedPhotoIDsOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error) {
if limit <= 0 {
return nil, nil
}
return s.q.ListOrphanedPhotoIDsOlderThan(ctx, sqlcgen.ListOrphanedPhotoIDsOlderThanParams{
Cutoff: pgtype.Timestamptz{Time: cutoff, Valid: true},
BatchLimit: int32(limit),
})
}
// CountFileBlobRefs reports how many file_blobs rows still point at
// (backend, objectKey) -- the caller must not physically delete the object
// from that backend while this is > 0 (content-addressed storage: the same
// object can be shared by multiple documents/photos).
func (s *MediaStore) CountFileBlobRefs(ctx context.Context, backend, objectKey string) (int, error) {
n, err := s.q.CountFileBlobRefs(ctx, sqlcgen.CountFileBlobRefsParams{Backend: backend, ObjectKey: objectKey})
return int(n), err
}
// DeleteDocumentAndBlobs deletes a document row and every file_blobs row it
// owns (main body + thumbnail variants), returning what was deleted so the
// caller can physically remove each object from its backend once confirming
// (via CountFileBlobRefs, after this call) no other row still needs it.
// Assumes the document is already orphaned -- does not check references.
func (s *MediaStore) DeleteDocumentAndBlobs(ctx context.Context, id int64) ([]domain.FileBlob, error) {
var blobs []domain.FileBlob
err := withTx(ctx, s.db, "delete document and blobs", func(tx pgx.Tx) error {
qtx := s.q.WithTx(tx)
rows, err := qtx.ListFileBlobsByLocationPrefix(ctx, sqlcgen.ListFileBlobsByLocationPrefixParams{
ExactKey: fmt.Sprintf("doc:%d", id),
PrefixPattern: fmt.Sprintf("doc:%d:%%", id),
})
if err != nil {
return fmt.Errorf("list document blobs: %w", err)
}
for _, r := range rows {
blobs = append(blobs, domain.FileBlob{
LocationKey: r.LocationKey, Backend: domain.MediaBackend(r.Backend), ObjectKey: r.ObjectKey, Size: r.Size,
})
if err := qtx.DeleteFileBlobRow(ctx, r.LocationKey); err != nil {
return fmt.Errorf("delete file blob row: %w", err)
}
}
if err := qtx.DeleteDocumentRow(ctx, id); err != nil {
return fmt.Errorf("delete document row: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
s.documents.remove(id)
return blobs, nil
}
// DeletePhotoAndBlobs deletes a photo row and every file_blobs row it owns
// (one per rendition size), returning what was deleted so the caller can
// physically remove each object from its backend once confirming (via
// CountFileBlobRefs, after this call) no other row still needs it. Assumes
// the photo is already orphaned -- does not check references.
func (s *MediaStore) DeletePhotoAndBlobs(ctx context.Context, id int64) ([]domain.FileBlob, error) {
var blobs []domain.FileBlob
err := withTx(ctx, s.db, "delete photo and blobs", func(tx pgx.Tx) error {
qtx := s.q.WithTx(tx)
rows, err := qtx.ListFileBlobsByLocationPrefix(ctx, sqlcgen.ListFileBlobsByLocationPrefixParams{
ExactKey: fmt.Sprintf("photo:%d", id),
PrefixPattern: fmt.Sprintf("photo:%d:%%", id),
})
if err != nil {
return fmt.Errorf("list photo blobs: %w", err)
}
for _, r := range rows {
blobs = append(blobs, domain.FileBlob{
LocationKey: r.LocationKey, Backend: domain.MediaBackend(r.Backend), ObjectKey: r.ObjectKey, Size: r.Size,
})
if err := qtx.DeleteFileBlobRow(ctx, r.LocationKey); err != nil {
return fmt.Errorf("delete file blob row: %w", err)
}
}
if err := qtx.DeletePhotoRow(ctx, id); err != nil {
return fmt.Errorf("delete photo row: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return blobs, nil
}

View file

@ -0,0 +1,109 @@
package postgres
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// TestMediaReferenceOrphanTransitions proves the core storage-retention
// safety invariant: a document's orphaned_at is set only once every
// reference to it is gone, and cleared the instant a new one appears --
// so the retention sweep never targets media still visible in a
// conversation, regardless of how many places reference it.
func TestMediaReferenceOrphanTransitions(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
s := NewMediaStore(pool)
const docID = int64(9100000000000000101)
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM media_references WHERE media_kind = 'document' AND media_id = $1`, docID)
_, _ = pool.Exec(context.Background(), `DELETE FROM documents WHERE id = $1`, docID)
})
if err := s.PutDocument(ctx, domain.Document{ID: docID, MimeType: "text/plain", Size: 10}); err != nil {
t.Fatalf("put document: %v", err)
}
media := &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &domain.Document{ID: docID}}
// A freshly created document has no orphaned_at yet either way -- it's
// simply unreferenced until a message send registers the first
// reference, at which point normal tracking takes over.
orphaned, err := documentOrphanedAt(ctx, pool, docID)
if err != nil {
t.Fatalf("query orphaned_at: %v", err)
}
if orphaned {
t.Fatal("expected a freshly inserted document to not be marked orphaned yet")
}
// Adding a reference (as if a message carrying it was sent) clears it.
mustAddRef(t, pool, media, domain.MediaRefKindMessageBox, "user:1:box:1")
if orphaned, err := documentOrphanedAt(ctx, pool, docID); err != nil || orphaned {
t.Fatalf("expected referenced document to not be orphaned, orphaned=%v err=%v", orphaned, err)
}
// A second, independent reference (e.g. forwarded to another box).
mustAddRef(t, pool, media, domain.MediaRefKindMessageBox, "user:2:box:5")
// Removing only one of the two references must NOT orphan the document.
mustRemoveRefsByKey(t, pool, domain.MediaRefKindMessageBox, "user:1:box:1")
if orphaned, err := documentOrphanedAt(ctx, pool, docID); err != nil || orphaned {
t.Fatalf("expected document with a remaining reference to survive, orphaned=%v err=%v", orphaned, err)
}
// Removing the last reference orphans it.
mustRemoveRefsByKey(t, pool, domain.MediaRefKindMessageBox, "user:2:box:5")
if orphaned, err := documentOrphanedAt(ctx, pool, docID); err != nil || !orphaned {
t.Fatalf("expected document with no remaining reference to be orphaned, orphaned=%v err=%v", orphaned, err)
}
// A reference reappearing after orphaning (e.g. re-sent) clears it again.
mustAddRef(t, pool, media, domain.MediaRefKindMessageBox, "user:3:box:9")
if orphaned, err := documentOrphanedAt(ctx, pool, docID); err != nil || orphaned {
t.Fatalf("expected re-referenced document to no longer be orphaned, orphaned=%v err=%v", orphaned, err)
}
}
func mustAddRef(t *testing.T, pool *pgxpool.Pool, media *domain.MessageMedia, refKind domain.MediaRefKind, refKey string) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := addMediaReferencesTx(ctx, tx, media, refKind, refKey); err != nil {
t.Fatalf("add media reference: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit: %v", err)
}
}
func mustRemoveRefsByKey(t *testing.T, pool *pgxpool.Pool, refKind domain.MediaRefKind, refKey string) {
t.Helper()
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin tx: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if err := removeMediaReferencesByKeyTx(ctx, tx, refKind, refKey); err != nil {
t.Fatalf("remove media references: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit: %v", err)
}
}
func documentOrphanedAt(ctx context.Context, pool *pgxpool.Pool, id int64) (bool, error) {
var orphaned bool
err := pool.QueryRow(ctx, `SELECT orphaned_at IS NOT NULL FROM documents WHERE id = $1`, id).Scan(&orphaned)
return orphaned, err
}

View file

@ -174,6 +174,11 @@ func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DB
if row.ownerUserID == 0 || row.boxID == 0 {
continue
}
// Drop this box's media_references (storage GC); orphans the
// document/photo if this was its last live reference anywhere.
if err := removeMediaReferencesByKeyTx(ctx, db, domain.MediaRefKindMessageBox, messageBoxRefKey(row.ownerUserID, row.boxID)); err != nil {
return res, fmt.Errorf("remove deleted message media references: %w", err)
}
idsByOwner[row.ownerUserID] = append(idsByOwner[row.ownerUserID], row.boxID)
if row.peer.ID != 0 {
if peersByOwner[row.ownerUserID] == nil {

View file

@ -108,7 +108,7 @@ WHERE location_key = sqlc.arg(location_key)::text;
-- documents -------------------------------------------------------------------
-- name: PutDocument :exec
INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs)
INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs, owner_user_id)
VALUES (
sqlc.arg(id)::bigint,
sqlc.arg(access_hash)::bigint,
@ -118,8 +118,13 @@ VALUES (
sqlc.arg(size)::bigint,
sqlc.arg(dc_id)::int,
sqlc.arg(attributes_json)::jsonb,
sqlc.arg(thumbs_json)::jsonb
sqlc.arg(thumbs_json)::jsonb,
sqlc.arg(owner_user_id)::bigint
)
-- owner_user_id is intentionally NOT in the UPDATE SET list: a document id is
-- only ever (re-)upserted by its original uploader's own request replay, and
-- keeping the first-write owner sticky avoids any risk of a later call
-- (e.g. a forward re-touching the row) reassigning ownership.
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
file_reference = EXCLUDED.file_reference,
@ -133,21 +138,26 @@ ON CONFLICT (id) DO UPDATE SET
-- name: GetDocument :one
SELECT id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes::text AS attributes_json,
thumbs::text AS thumbs_json
thumbs::text AS thumbs_json,
owner_user_id
FROM documents
WHERE id = sqlc.arg(id)::bigint;
-- name: GetDocuments :many
SELECT id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes::text AS attributes_json,
thumbs::text AS thumbs_json
thumbs::text AS thumbs_json,
owner_user_id
FROM documents
WHERE id = ANY(sqlc.arg(ids)::bigint[]);
-- name: DeleteDocumentRow :exec
DELETE FROM documents WHERE id = sqlc.arg(id)::bigint;
-- photos ----------------------------------------------------------------------
-- name: PutPhoto :exec
INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes)
INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes, owner_user_id)
VALUES (
sqlc.arg(id)::bigint,
sqlc.arg(access_hash)::bigint,
@ -155,8 +165,10 @@ VALUES (
sqlc.arg(date)::int,
sqlc.arg(dc_id)::int,
sqlc.arg(has_stickers)::boolean,
sqlc.arg(sizes_json)::jsonb
sqlc.arg(sizes_json)::jsonb,
sqlc.arg(owner_user_id)::bigint
)
-- owner_user_id intentionally not updated on conflict, see PutDocument.
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
file_reference = EXCLUDED.file_reference,
@ -167,10 +179,87 @@ ON CONFLICT (id) DO UPDATE SET
-- name: GetPhoto :one
SELECT id, access_hash, file_reference, date, dc_id, has_stickers,
sizes::text AS sizes_json
sizes::text AS sizes_json,
owner_user_id
FROM photos
WHERE id = sqlc.arg(id)::bigint;
-- name: DeletePhotoRow :exec
DELETE FROM photos WHERE id = sqlc.arg(id)::bigint;
-- media_references / storage retention -----------------------------------------
-- name: InsertMediaReference :exec
INSERT INTO media_references (media_kind, media_id, ref_kind, ref_key)
VALUES (sqlc.arg(media_kind)::text, sqlc.arg(media_id)::bigint, sqlc.arg(ref_kind)::text, sqlc.arg(ref_key)::text)
ON CONFLICT DO NOTHING;
-- name: ClearDocumentOrphan :exec
UPDATE documents SET orphaned_at = NULL
WHERE id = sqlc.arg(media_id)::bigint AND orphaned_at IS NOT NULL;
-- name: ClearPhotoOrphan :exec
UPDATE photos SET orphaned_at = NULL
WHERE id = sqlc.arg(media_id)::bigint AND orphaned_at IS NOT NULL;
-- name: RemoveMediaReference :exec
DELETE FROM media_references
WHERE media_kind = sqlc.arg(media_kind)::text
AND media_id = sqlc.arg(media_id)::bigint
AND ref_kind = sqlc.arg(ref_kind)::text
AND ref_key = sqlc.arg(ref_key)::text;
-- name: OrphanDocumentIfUnreferenced :exec
UPDATE documents SET orphaned_at = now()
WHERE id = sqlc.arg(media_id)::bigint
AND orphaned_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM media_references WHERE media_kind = 'document' AND media_id = sqlc.arg(media_id)::bigint
);
-- name: OrphanPhotoIfUnreferenced :exec
UPDATE photos SET orphaned_at = now()
WHERE id = sqlc.arg(media_id)::bigint
AND orphaned_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM media_references WHERE media_kind = 'photo' AND media_id = sqlc.arg(media_id)::bigint
);
-- name: ListOrphanedDocumentIDsOlderThan :many
SELECT id FROM documents
WHERE orphaned_at IS NOT NULL AND orphaned_at < sqlc.arg(cutoff)::timestamptz
ORDER BY orphaned_at ASC
LIMIT sqlc.arg(batch_limit)::int;
-- name: ListOrphanedPhotoIDsOlderThan :many
SELECT id FROM photos
WHERE orphaned_at IS NOT NULL AND orphaned_at < sqlc.arg(cutoff)::timestamptz
ORDER BY orphaned_at ASC
LIMIT sqlc.arg(batch_limit)::int;
-- name: CountFileBlobRefs :one
SELECT COUNT(*)::int FROM file_blobs WHERE backend = sqlc.arg(backend)::text AND object_key = sqlc.arg(object_key)::text;
-- name: DeleteFileBlobRow :exec
DELETE FROM file_blobs WHERE location_key = sqlc.arg(location_key)::text;
-- name: ListFileBlobsByLocationPrefix :many
-- Matches a media's main blob (exact_key, e.g. "doc:123") plus every
-- variant keyed off it (prefix_pattern, e.g. "doc:123:%" for thumbnails /
-- "photo:456:%" for each rendition size) -- a document/photo can own
-- multiple file_blobs rows.
SELECT location_key, backend, object_key, size
FROM file_blobs
WHERE location_key = sqlc.arg(exact_key)::text
OR location_key LIKE sqlc.arg(prefix_pattern)::text;
-- name: SumFileBlobBytes :one
-- Physical bytes actually held by the blob backend (dedup-aware: identical
-- content uploaded by different users is one row here). Used by the
-- low-space guard's cached usage gauge and the admin panel's "physical
-- usage" stat.
SELECT COALESCE(SUM(size), 0)::bigint FROM file_blobs;
-- sticker_sets ----------------------------------------------------------------
-- name: PutStickerSet :exec

View file

@ -48,6 +48,26 @@ func (q *Queries) AddProfilePhoto(ctx context.Context, arg AddProfilePhotoParams
return err
}
const clearDocumentOrphan = `-- name: ClearDocumentOrphan :exec
UPDATE documents SET orphaned_at = NULL
WHERE id = $1::bigint AND orphaned_at IS NOT NULL
`
func (q *Queries) ClearDocumentOrphan(ctx context.Context, mediaID int64) error {
_, err := q.db.Exec(ctx, clearDocumentOrphan, mediaID)
return err
}
const clearPhotoOrphan = `-- name: ClearPhotoOrphan :exec
UPDATE photos SET orphaned_at = NULL
WHERE id = $1::bigint AND orphaned_at IS NOT NULL
`
func (q *Queries) ClearPhotoOrphan(ctx context.Context, mediaID int64) error {
_, err := q.db.Exec(ctx, clearPhotoOrphan, mediaID)
return err
}
const countAvailableReactions = `-- name: CountAvailableReactions :one
SELECT count(*)::int AS total FROM available_reactions
`
@ -59,6 +79,22 @@ func (q *Queries) CountAvailableReactions(ctx context.Context) (int32, error) {
return total, err
}
const countFileBlobRefs = `-- name: CountFileBlobRefs :one
SELECT COUNT(*)::int FROM file_blobs WHERE backend = $1::text AND object_key = $2::text
`
type CountFileBlobRefsParams struct {
Backend string
ObjectKey string
}
func (q *Queries) CountFileBlobRefs(ctx context.Context, arg CountFileBlobRefsParams) (int32, error) {
row := q.db.QueryRow(ctx, countFileBlobRefs, arg.Backend, arg.ObjectKey)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const countProfilePhotos = `-- name: CountProfilePhotos :one
SELECT count(*)::int AS total
FROM profile_photos
@ -199,6 +235,15 @@ func (q *Queries) DeactivateProfilePhotos(ctx context.Context, arg DeactivatePro
return items, nil
}
const deleteDocumentRow = `-- name: DeleteDocumentRow :exec
DELETE FROM documents WHERE id = $1::bigint
`
func (q *Queries) DeleteDocumentRow(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deleteDocumentRow, id)
return err
}
const deleteExpiredUploadParts = `-- name: DeleteExpiredUploadParts :many
WITH doomed AS (
SELECT owner_user_id, file_id, part
@ -240,6 +285,24 @@ func (q *Queries) DeleteExpiredUploadParts(ctx context.Context, arg DeleteExpire
return items, nil
}
const deleteFileBlobRow = `-- name: DeleteFileBlobRow :exec
DELETE FROM file_blobs WHERE location_key = $1::text
`
func (q *Queries) DeleteFileBlobRow(ctx context.Context, locationKey string) error {
_, err := q.db.Exec(ctx, deleteFileBlobRow, locationKey)
return err
}
const deletePhotoRow = `-- name: DeletePhotoRow :exec
DELETE FROM photos WHERE id = $1::bigint
`
func (q *Queries) DeletePhotoRow(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deletePhotoRow, id)
return err
}
const deleteUploadParts = `-- name: DeleteUploadParts :many
DELETE FROM upload_parts
WHERE owner_user_id = $1::bigint
@ -275,7 +338,8 @@ func (q *Queries) DeleteUploadParts(ctx context.Context, arg DeleteUploadPartsPa
const getDocument = `-- name: GetDocument :one
SELECT id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes::text AS attributes_json,
thumbs::text AS thumbs_json
thumbs::text AS thumbs_json,
owner_user_id
FROM documents
WHERE id = $1::bigint
`
@ -290,6 +354,7 @@ type GetDocumentRow struct {
DcID int32
AttributesJson string
ThumbsJson string
OwnerUserID int64
}
func (q *Queries) GetDocument(ctx context.Context, id int64) (GetDocumentRow, error) {
@ -305,6 +370,7 @@ func (q *Queries) GetDocument(ctx context.Context, id int64) (GetDocumentRow, er
&i.DcID,
&i.AttributesJson,
&i.ThumbsJson,
&i.OwnerUserID,
)
return i, err
}
@ -312,7 +378,8 @@ func (q *Queries) GetDocument(ctx context.Context, id int64) (GetDocumentRow, er
const getDocuments = `-- name: GetDocuments :many
SELECT id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes::text AS attributes_json,
thumbs::text AS thumbs_json
thumbs::text AS thumbs_json,
owner_user_id
FROM documents
WHERE id = ANY($1::bigint[])
`
@ -327,6 +394,7 @@ type GetDocumentsRow struct {
DcID int32
AttributesJson string
ThumbsJson string
OwnerUserID int64
}
func (q *Queries) GetDocuments(ctx context.Context, ids []int64) ([]GetDocumentsRow, error) {
@ -348,6 +416,7 @@ func (q *Queries) GetDocuments(ctx context.Context, ids []int64) ([]GetDocuments
&i.DcID,
&i.AttributesJson,
&i.ThumbsJson,
&i.OwnerUserID,
); err != nil {
return nil, err
}
@ -390,7 +459,8 @@ func (q *Queries) GetFileBlob(ctx context.Context, locationKey string) (GetFileB
const getPhoto = `-- name: GetPhoto :one
SELECT id, access_hash, file_reference, date, dc_id, has_stickers,
sizes::text AS sizes_json
sizes::text AS sizes_json,
owner_user_id
FROM photos
WHERE id = $1::bigint
`
@ -403,6 +473,7 @@ type GetPhotoRow struct {
DcID int32
HasStickers bool
SizesJson string
OwnerUserID int64
}
func (q *Queries) GetPhoto(ctx context.Context, id int64) (GetPhotoRow, error) {
@ -416,6 +487,7 @@ func (q *Queries) GetPhoto(ctx context.Context, id int64) (GetPhotoRow, error) {
&i.DcID,
&i.HasStickers,
&i.SizesJson,
&i.OwnerUserID,
)
return i, err
}
@ -686,6 +758,31 @@ func (q *Queries) GetUploadPartUsage(ctx context.Context, ownerUserID int64) (Ge
return i, err
}
const insertMediaReference = `-- name: InsertMediaReference :exec
INSERT INTO media_references (media_kind, media_id, ref_kind, ref_key)
VALUES ($1::text, $2::bigint, $3::text, $4::text)
ON CONFLICT DO NOTHING
`
type InsertMediaReferenceParams struct {
MediaKind string
MediaID int64
RefKind string
RefKey string
}
// media_references / storage retention -----------------------------------------
func (q *Queries) InsertMediaReference(ctx context.Context, arg InsertMediaReferenceParams) error {
_, err := q.db.Exec(ctx, insertMediaReference,
arg.MediaKind,
arg.MediaID,
arg.RefKind,
arg.RefKey,
)
return err
}
const listAvailableReactions = `-- name: ListAvailableReactions :many
SELECT
reaction, title, inactive, premium,
@ -728,6 +825,118 @@ func (q *Queries) ListAvailableReactions(ctx context.Context) ([]AvailableReacti
return items, nil
}
const listFileBlobsByLocationPrefix = `-- name: ListFileBlobsByLocationPrefix :many
SELECT location_key, backend, object_key, size
FROM file_blobs
WHERE location_key = $1::text
OR location_key LIKE $2::text
`
type ListFileBlobsByLocationPrefixParams struct {
ExactKey string
PrefixPattern string
}
type ListFileBlobsByLocationPrefixRow struct {
LocationKey string
Backend string
ObjectKey string
Size int64
}
// Matches a media's main blob (exact_key, e.g. "doc:123") plus every
// variant keyed off it (prefix_pattern, e.g. "doc:123:%" for thumbnails /
// "photo:456:%" for each rendition size) -- a document/photo can own
// multiple file_blobs rows.
func (q *Queries) ListFileBlobsByLocationPrefix(ctx context.Context, arg ListFileBlobsByLocationPrefixParams) ([]ListFileBlobsByLocationPrefixRow, error) {
rows, err := q.db.Query(ctx, listFileBlobsByLocationPrefix, arg.ExactKey, arg.PrefixPattern)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListFileBlobsByLocationPrefixRow
for rows.Next() {
var i ListFileBlobsByLocationPrefixRow
if err := rows.Scan(
&i.LocationKey,
&i.Backend,
&i.ObjectKey,
&i.Size,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOrphanedDocumentIDsOlderThan = `-- name: ListOrphanedDocumentIDsOlderThan :many
SELECT id FROM documents
WHERE orphaned_at IS NOT NULL AND orphaned_at < $1::timestamptz
ORDER BY orphaned_at ASC
LIMIT $2::int
`
type ListOrphanedDocumentIDsOlderThanParams struct {
Cutoff pgtype.Timestamptz
BatchLimit int32
}
func (q *Queries) ListOrphanedDocumentIDsOlderThan(ctx context.Context, arg ListOrphanedDocumentIDsOlderThanParams) ([]int64, error) {
rows, err := q.db.Query(ctx, listOrphanedDocumentIDsOlderThan, arg.Cutoff, arg.BatchLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listOrphanedPhotoIDsOlderThan = `-- name: ListOrphanedPhotoIDsOlderThan :many
SELECT id FROM photos
WHERE orphaned_at IS NOT NULL AND orphaned_at < $1::timestamptz
ORDER BY orphaned_at ASC
LIMIT $2::int
`
type ListOrphanedPhotoIDsOlderThanParams struct {
Cutoff pgtype.Timestamptz
BatchLimit int32
}
func (q *Queries) ListOrphanedPhotoIDsOlderThan(ctx context.Context, arg ListOrphanedPhotoIDsOlderThanParams) ([]int64, error) {
rows, err := q.db.Query(ctx, listOrphanedPhotoIDsOlderThan, arg.Cutoff, arg.BatchLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProfilePhotos = `-- name: ListProfilePhotos :many
SELECT photo_id
FROM profile_photos
@ -925,6 +1134,34 @@ func (q *Queries) NextProfilePhotoOrder(ctx context.Context, arg NextProfilePhot
return max_order, err
}
const orphanDocumentIfUnreferenced = `-- name: OrphanDocumentIfUnreferenced :exec
UPDATE documents SET orphaned_at = now()
WHERE id = $1::bigint
AND orphaned_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM media_references WHERE media_kind = 'document' AND media_id = $1::bigint
)
`
func (q *Queries) OrphanDocumentIfUnreferenced(ctx context.Context, mediaID int64) error {
_, err := q.db.Exec(ctx, orphanDocumentIfUnreferenced, mediaID)
return err
}
const orphanPhotoIfUnreferenced = `-- name: OrphanPhotoIfUnreferenced :exec
UPDATE photos SET orphaned_at = now()
WHERE id = $1::bigint
AND orphaned_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM media_references WHERE media_kind = 'photo' AND media_id = $1::bigint
)
`
func (q *Queries) OrphanPhotoIfUnreferenced(ctx context.Context, mediaID int64) error {
_, err := q.db.Exec(ctx, orphanPhotoIfUnreferenced, mediaID)
return err
}
const putAvailableReaction = `-- name: PutAvailableReaction :exec
INSERT INTO available_reactions (
@ -995,7 +1232,7 @@ func (q *Queries) PutAvailableReaction(ctx context.Context, arg PutAvailableReac
const putDocument = `-- name: PutDocument :exec
INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs)
INSERT INTO documents (id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs, owner_user_id)
VALUES (
$1::bigint,
$2::bigint,
@ -1005,7 +1242,8 @@ VALUES (
$6::bigint,
$7::int,
$8::jsonb,
$9::jsonb
$9::jsonb,
$10::bigint
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
@ -1028,9 +1266,14 @@ type PutDocumentParams struct {
DcID int32
AttributesJson []byte
ThumbsJson []byte
OwnerUserID int64
}
// documents -------------------------------------------------------------------
// owner_user_id is intentionally NOT in the UPDATE SET list: a document id is
// only ever (re-)upserted by its original uploader's own request replay, and
// keeping the first-write owner sticky avoids any risk of a later call
// (e.g. a forward re-touching the row) reassigning ownership.
func (q *Queries) PutDocument(ctx context.Context, arg PutDocumentParams) error {
_, err := q.db.Exec(ctx, putDocument,
arg.ID,
@ -1042,6 +1285,7 @@ func (q *Queries) PutDocument(ctx context.Context, arg PutDocumentParams) error
arg.DcID,
arg.AttributesJson,
arg.ThumbsJson,
arg.OwnerUserID,
)
return err
}
@ -1089,7 +1333,7 @@ func (q *Queries) PutFileBlob(ctx context.Context, arg PutFileBlobParams) error
const putPhoto = `-- name: PutPhoto :exec
INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes)
INSERT INTO photos (id, access_hash, file_reference, date, dc_id, has_stickers, sizes, owner_user_id)
VALUES (
$1::bigint,
$2::bigint,
@ -1097,7 +1341,8 @@ VALUES (
$4::int,
$5::int,
$6::boolean,
$7::jsonb
$7::jsonb,
$8::bigint
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
@ -1116,9 +1361,11 @@ type PutPhotoParams struct {
DcID int32
HasStickers bool
SizesJson []byte
OwnerUserID int64
}
// photos ----------------------------------------------------------------------
// owner_user_id intentionally not updated on conflict, see PutDocument.
func (q *Queries) PutPhoto(ctx context.Context, arg PutPhotoParams) error {
_, err := q.db.Exec(ctx, putPhoto,
arg.ID,
@ -1128,6 +1375,7 @@ func (q *Queries) PutPhoto(ctx context.Context, arg PutPhotoParams) error {
arg.DcID,
arg.HasStickers,
arg.SizesJson,
arg.OwnerUserID,
)
return err
}
@ -1244,6 +1492,31 @@ func (q *Queries) PutStickerSet(ctx context.Context, arg PutStickerSetParams) er
return err
}
const removeMediaReference = `-- name: RemoveMediaReference :exec
DELETE FROM media_references
WHERE media_kind = $1::text
AND media_id = $2::bigint
AND ref_kind = $3::text
AND ref_key = $4::text
`
type RemoveMediaReferenceParams struct {
MediaKind string
MediaID int64
RefKind string
RefKey string
}
func (q *Queries) RemoveMediaReference(ctx context.Context, arg RemoveMediaReferenceParams) error {
_, err := q.db.Exec(ctx, removeMediaReference,
arg.MediaKind,
arg.MediaID,
arg.RefKind,
arg.RefKey,
)
return err
}
const saveUploadPart = `-- name: SaveUploadPart :exec
INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, backend, object_key, size, sha256)
@ -1295,3 +1568,18 @@ func (q *Queries) SaveUploadPart(ctx context.Context, arg SaveUploadPartParams)
)
return err
}
const sumFileBlobBytes = `-- name: SumFileBlobBytes :one
SELECT COALESCE(SUM(size), 0)::bigint FROM file_blobs
`
// Physical bytes actually held by the blob backend (dedup-aware: identical
// content uploaded by different users is one row here). Used by the
// low-space guard's cached usage gauge and the admin panel's "physical
// usage" stat.
func (q *Queries) SumFileBlobBytes(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, sumFileBlobBytes)
var column_1 int64
err := row.Scan(&column_1)
return column_1, err
}

View file

@ -79,6 +79,34 @@ type AccountPrivacyRule struct {
UpdatedAt pgtype.Timestamptz
}
type AccountRating struct {
UserID int64
Level int32
Stars int64
CurrentLevelStars int64
NextLevelStars *int64
StarsComponent int64
ActivityComponent int64
PenaltyComponent int64
ManualComponent int64
PendingStars int64
PendingDate pgtype.Timestamptz
ComputedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
Version int64
}
type AccountRatingEvent struct {
ID int64
UserID int64
Kind string
Amount int64
Reason string
Actor string
CommandKey *string
CreatedAt pgtype.Timestamptz
}
type AccountReactionSetting struct {
UserID int64
MessagesNotifyFrom string
@ -218,6 +246,21 @@ type AttachMenuUserState struct {
UpdatedAt pgtype.Timestamptz
}
type AuthDeliveryReport struct {
ID int64
AuthKeyID []byte
SessionID int64
ClientType string
PhoneHash []byte
CodeHash []byte
IssuedUserID int64
DeliveryID string
Channel string
Mnc string
Fingerprint []byte
CreatedAt pgtype.Timestamptz
}
type AuthKey struct {
AuthKeyID int64
Body []byte
@ -448,6 +491,20 @@ type BotUserPermission struct {
UpdatedAt pgtype.Timestamptz
}
type BotVerifierSetting struct {
BotID int64
IconDocumentID int64
CompanyName string
DefaultDescription string
CanModifyCustomDescription bool
Enabled bool
GrantedBy string
GrantReason string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
Version int64
}
type BusinessAutomationDelivery struct {
OwnerUserID int64
PeerUserID int64
@ -577,6 +634,18 @@ type ChannelAdminLogEvent struct {
CreatedAt pgtype.Timestamptz
}
type ChannelAntispamDecision struct {
ID int64
ChannelID int64
MessageID int32
AuthorUserID int64
EvidenceSchemaVersion int16
Evidence []byte
EvidenceHash []byte
ReportID *int64
CreatedAt pgtype.Timestamptz
}
type ChannelBoostSlot struct {
UserID int64
Slot int32
@ -689,25 +758,27 @@ type ChannelMediaCategoryCount struct {
}
type ChannelMember struct {
ChannelID int64
UserID int64
InviterUserID int64
Role string
Status string
JoinedAt int32
LeftAt int32
AdminRights []byte
BannedRights []byte
Rank string
AvailableMinID int32
AvailableMinPts int32
ReadInboxMaxID int32
ReadInboxDate int32
ReadOutboxMaxID int32
UnreadMark bool
SlowmodeLastSendDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ChannelID int64
UserID int64
InviterUserID int64
Role string
Status string
JoinedAt int32
LeftAt int32
AdminRights []byte
BannedRights []byte
Rank string
AvailableMinID int32
AvailableMinPts int32
ReadInboxMaxID int32
ReadInboxDate int32
ReadOutboxMaxID int32
UnreadMark bool
SlowmodeLastSendDate int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
HistoryClearAnchorID int32
HistoryClearAnchorDate int32
}
type ChannelMessage struct {
@ -910,6 +981,55 @@ type ChatlistMembership struct {
UpdatedAt pgtype.Timestamptz
}
type ClientTelemetryEvent struct {
ID int64
UserID int64
Kind string
PeerType string
PeerID int64
SubjectIds []int64
Payload []byte
Fingerprint []byte
CreatedAt pgtype.Timestamptz
}
type CollectibleUsername struct {
ID int64
Username string
UsernameLower string
Status string
OwnerPeerType string
OwnerPeerID int64
PurchaseDate pgtype.Timestamptz
Currency string
Amount int64
CryptoCurrency string
CryptoAmount int64
Url string
OriginalOwnerPeerType string
OriginalOwnerPeerID int64
TransferCount int32
Version int64
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type CollectibleUsernameTransfer struct {
ID int64
CollectibleID int64
Kind string
FromPeerType string
FromPeerID int64
ToPeerType string
ToPeerID int64
Currency string
Amount int64
Actor string
Reason string
CommandKey *string
CreatedAt pgtype.Timestamptz
}
type Community struct {
ID int64
AccessHash int64
@ -1008,6 +1128,41 @@ type CountryCode struct {
OrderIndex int32
}
type CustomVerification struct {
ID int64
VerifierBotID int64
PeerType string
PeerID int64
IconDocumentID int64
Description string
GrantedByUserID int64
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
Version int64
}
type CustomVerificationRequest struct {
ID int64
VerifierBotID int64
ApplicantUserID int64
PeerType string
PeerID int64
PeerTitle string
PeerUsername string
Reason string
RequestedDescription string
Status string
DecidedBy string
DecisionReason string
InternalNote string
CorrelationID string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ApprovedAt pgtype.Timestamptz
RejectedAt pgtype.Timestamptz
Version int64
}
type Dialog struct {
UserID int64
PeerType string
@ -1095,6 +1250,8 @@ type Document struct {
Attributes []byte
Thumbs []byte
CreatedAt pgtype.Timestamptz
OwnerUserID int64
OrphanedAt pgtype.Timestamptz
}
type EncryptedFile struct {
@ -1286,6 +1443,14 @@ type LoginCodeMessageDelivery struct {
ExpiresAt pgtype.Timestamptz
}
type MediaReference struct {
MediaKind string
MediaID int64
RefKind string
RefKey string
CreatedAt pgtype.Timestamptz
}
type MessageBox struct {
OwnerUserID int64
BoxID int32
@ -1343,6 +1508,126 @@ type MessageBoxMedium struct {
MessageDate int32
}
type ModerationAction struct {
ID int64
CaseID int64
DecisionID int64
Kind string
Payload []byte
Status string
Attempts int32
AvailableAt pgtype.Timestamptz
LeaseUntil pgtype.Timestamptz
LastError string
CommandID string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ModerationAppeal struct {
ID int64
CaseID int64
AppellantUserID int64
AppealText string
TextHash []byte
Fingerprint []byte
Status string
PreviousCaseStatus string
Reviewer string
ReviewReason string
CreatedAt pgtype.Timestamptz
ReviewedAt pgtype.Timestamptz
}
type ModerationAppealLink struct {
ID int64
CaseID int64
AppellantUserID int64
TokenHash []byte
ExpiresAt pgtype.Timestamptz
AppealID *int64
CreatedAt pgtype.Timestamptz
ConsumedAt pgtype.Timestamptz
}
type ModerationCase struct {
ID int64
TargetPeerType string
TargetPeerID int64
Status string
Severity int16
AssignedTo string
Version int64
ReportCount int32
DistinctReporterCount int32
FirstReportAt pgtype.Timestamptz
LastReportAt pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type ModerationCaseReport struct {
CaseID int64
ReportID int64
AttachedAt pgtype.Timestamptz
}
type ModerationDecision struct {
ID int64
CaseID int64
AppealID *int64
Kind string
Actor string
Reason string
CommandID string
Fingerprint []byte
CreatedAt pgtype.Timestamptz
}
type ModerationLegacyEphemeralMigration struct {
LegacyReportID int64
ModerationReportID int64
MigratedAt pgtype.Timestamptz
}
type ModerationMediaHold struct {
ReportID int64
ItemOrdinal int16
MediaKind string
StorageKey string
CreatedAt pgtype.Timestamptz
ReleasedAt pgtype.Timestamptz
}
type ModerationReport struct {
ID int64
ReporterUserID int64
Source string
TargetPeerType string
TargetPeerID int64
Reason string
ReportOption string
ReportComment string
CommentHash []byte
Fingerprint []byte
TaxonomyVersion int16
CreatedAt pgtype.Timestamptz
}
type ModerationReportItem struct {
ReportID int64
Ordinal int16
ItemKind string
PeerType string
PeerID int64
ItemID int64
SecondaryID int64
AuthorUserID int64
EvidenceSchemaVersion int16
Evidence []byte
EvidenceHash []byte
}
type NotifySetting struct {
OwnerUserID int64
ScopeKind string
@ -1412,6 +1697,11 @@ type PeerUsername struct {
PeerType string
PeerID int64
UpdatedAt pgtype.Timestamptz
Username string
Active bool
Editable bool
SortOrder int32
CollectibleID *int64
}
type Photo struct {
@ -1423,6 +1713,8 @@ type Photo struct {
HasStickers bool
Sizes []byte
CreatedAt pgtype.Timestamptz
OwnerUserID int64
OrphanedAt pgtype.Timestamptz
}
type Poll struct {
@ -1518,6 +1810,24 @@ type PrivateMessageReaction struct {
UpdatedAt pgtype.Timestamptz
}
type PrivateNoForwardsChat struct {
UserLowID int64
UserHighID int64
EnabledByUserID *int64
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type PrivateNoForwardsRequest struct {
PrivateMessageSenderUserID int64
PrivateMessageID int64
RequesterUserID int64
ResponderUserID int64
ExpiresAt int32
HandledAt int32
CreatedAt pgtype.Timestamptz
}
type ProfilePhoto struct {
OwnerPeerType string
OwnerPeerID int64
@ -1568,6 +1878,16 @@ type SavedDialogPin struct {
CreatedAt pgtype.Timestamptz
}
type SavedMessageReactionTag struct {
UserID int64
MessageBoxID int32
ReactionType string
ReactionValue string
ChosenOrder int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type SavedMusic struct {
UserID int64
DocumentID int64
@ -1645,6 +1965,21 @@ type SeedState struct {
UpdatedAt pgtype.Timestamptz
}
type SponsoredMessageImpression struct {
ID int64
UserID int64
RandomIDHash []byte
TargetPeerType string
TargetPeerID int64
AuthorUserID int64
EvidenceSchemaVersion int16
Evidence []byte
EvidenceHash []byte
ReportID *int64
CreatedAt pgtype.Timestamptz
ExpiresAt pgtype.Timestamptz
}
type StarGiftAdminGrantCommand struct {
RecipientUserID int64
CommandKey string
@ -1781,6 +2116,22 @@ type StarGiftCatalogRevision struct {
BackgroundTextColor *int32
}
// Purchase-time snapshot of per-admin channel gift notification intents; delivery uses deterministic private-message replay.
type StarGiftChannelNotificationJob struct {
SavedGiftID int64
TargetUserID int64
GiftDate int32
Action []byte
Attempts int32
NextAttemptAt int32
LeaseUntil int32
DeliveredAt int32
MessageID int32
LastError string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type StarGiftCollectibleBackdrop struct {
ID int64
CollectibleRevisionID int64
@ -2072,7 +2423,7 @@ type StarGiftUpgradeCommand struct {
SourceEditPts int32
}
// Owner-local service-message aliases (unique outputs and separate prepaid-upgrade notifications) for one saved gift aggregate.
// Viewer-local private service-message aliases to saved gift aggregates; the saved gift owner may be that user or an authorized channel.
type StarGiftUserMessageRef struct {
OwnerUserID int64
MsgID int32
@ -2115,6 +2466,55 @@ type StarsBalance struct {
UpdatedAt pgtype.Timestamptz
}
type StarsGiveaway struct {
ID int64
BuyerUserID int64
FormID int64
ChannelID int64
LaunchMessageID int32
RandomID int64
Stars int64
Users int32
PerUserStars int64
YearlyBoosts int32
UntilDate int32
PurposeJson []byte
State string
CreatedAt int32
}
type StarsPurchaseCommand struct {
BuyerUserID int64
FormID int64
RequestFingerprint []byte
RecipientUserID *int64
Stars int64
Currency string
Amount int64
BalanceAfter int64
TransactionID string
CreatedAt int32
Kind string
SpendPeerType *string
SpendPeerID *int64
PurposeJson []byte
}
type StarsPurchaseForm struct {
BuyerUserID int64
FormID int64
RecipientUserID *int64
Stars int64
Currency string
Amount int64
IssuedAt int32
ExpiresAt int32
Kind string
SpendPeerType *string
SpendPeerID *int64
PurposeJson []byte
}
type StarsTransaction struct {
ID int64
UserID int64
@ -2502,18 +2902,21 @@ type UserBusinessProfile struct {
}
type UserChannelMemberIndex struct {
UserID int64
ChannelID int64
Status string
Megagroup bool
Broadcast bool
Deleted bool
UpdatedAt pgtype.Timestamptz
Role string
LeftAt int32
Forum bool
PublicUsername bool
CanPinMessages bool
UserID int64
ChannelID int64
Status string
Megagroup bool
Broadcast bool
Deleted bool
UpdatedAt pgtype.Timestamptz
Role string
LeftAt int32
Forum bool
PublicUsername bool
CanPinMessages bool
AvailableMinID int32
HistoryClearAnchorID int32
HistoryClearUpdatedAt int32
}
type UserRecentReaction struct {
@ -2530,6 +2933,7 @@ type UserSavedReactionTag struct {
ReactionType string
ReactionValue string
Title string
// Legacy unused column; visible counts are aggregated from saved_message_reaction_tags.
ReactionCount int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
@ -2607,6 +3011,67 @@ type UserUpdateWatermark struct {
UpdatedAt pgtype.Timestamptz
}
type VerificationApplication struct {
ID int64
ApplicantUserID int64
TargetType string
TargetID int64
TargetTitle string
TargetUsername string
TargetAccessHash int64
Category string
Description string
OfficialWebsite string
SocialLinks []string
PressLinks []string
AdditionalNote string
Status string
ReviewerAdminID string
DecisionReason string
InternalNote string
CorrelationID string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
SubmittedAt pgtype.Timestamptz
ReviewedAt pgtype.Timestamptz
Version int64
}
type VerificationApplicationEvent struct {
ID int64
ApplicationID int64
Kind string
FromStatus string
ToStatus string
Actor string
Reason string
Note string
CorrelationID string
CreatedAt pgtype.Timestamptz
}
type VerificationIcon struct {
ID int64
DocumentID int64
OwnerBotID int64
Name string
Active bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type VerificationNotificationOutbox struct {
ID int64
ApplicationID int64
RecipientUserID int64
Kind string
Payload []byte
Attempts int32
DeliveredAt pgtype.Timestamptz
LastError string
CreatedAt pgtype.Timestamptz
}
type WebAuthorization struct {
Hash int64
RequestID int64

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 20260714003127 {
t.Fatalf("migration status = %+v, want clean version 20260714003127", status)
if status.Dirty || status.Empty || status.Version != 20260714003129 {
t.Fatalf("migration status = %+v, want clean version 20260714003129", status)
}
}