owpengram-server/internal/store/postgres/media_refs.go

328 lines
12 KiB
Go

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 ----
// OrphanDocumentIfUnreferenced marks a document orphaned right now if
// nothing currently references it (media_references), for a caller that
// wants an immediate answer instead of waiting for the age-based
// ListOrphanedDocumentIDsOlderThan sweep -- e.g. a human deliberately
// pruning catalog entries, not an accidental delete the sweep's grace period
// exists to protect against. Returns whether it just became orphaned; false
// if something still references it (safe: the document is left alone) or it
// was already orphaned.
func (s *MediaStore) OrphanDocumentIfUnreferenced(ctx context.Context, id int64) (bool, error) {
tag, err := s.db.Exec(ctx, `
UPDATE documents SET orphaned_at = now()
WHERE id = $1
AND orphaned_at IS NULL
AND NOT EXISTS (SELECT 1 FROM media_references WHERE media_kind = 'document' AND media_id = $1)`,
id)
if err != nil {
return false, fmt.Errorf("orphan document if unreferenced: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// 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
}
// ListDocumentIDsForHardRetentionOlderThan returns document ids older than
// cutoff (by upload/created_at) that still own at least one file_blobs row,
// oldest first, up to limit -- the "hard" retention sweep's candidate list.
// Unlike ListOrphanedDocumentIDsOlderThan, this ignores media_references
// entirely: a document still referenced by a live message is exactly as
// eligible as an orphaned one.
func (s *MediaStore) ListDocumentIDsForHardRetentionOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error) {
if limit <= 0 {
return nil, nil
}
return s.q.ListDocumentIDsForHardRetentionOlderThan(ctx, sqlcgen.ListDocumentIDsForHardRetentionOlderThanParams{
Cutoff: pgtype.Timestamptz{Time: cutoff, Valid: true},
BatchLimit: int32(limit),
})
}
// ListPhotoIDsForHardRetentionOlderThan is the photo counterpart of
// ListDocumentIDsForHardRetentionOlderThan.
func (s *MediaStore) ListPhotoIDsForHardRetentionOlderThan(ctx context.Context, cutoff time.Time, limit int) ([]int64, error) {
if limit <= 0 {
return nil, nil
}
return s.q.ListPhotoIDsForHardRetentionOlderThan(ctx, sqlcgen.ListPhotoIDsForHardRetentionOlderThanParams{
Cutoff: pgtype.Timestamptz{Time: cutoff, Valid: true},
BatchLimit: int32(limit),
})
}
// DeleteFileBlobsForDocument deletes every file_blobs row a document 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. Unlike
// DeleteDocumentAndBlobs, this deliberately does NOT delete the documents
// row itself -- "hard" retention mode keeps the metadata (dimensions, mime
// type, filename) so a message can still render "this document is no longer
// available" instead of disappearing outright. A subsequent
// upload.getFile/GetFileBlob lookup for a location key this call removed
// correctly finds nothing and reports not-found.
func (s *MediaStore) DeleteFileBlobsForDocument(ctx context.Context, id int64) ([]domain.FileBlob, error) {
var blobs []domain.FileBlob
err := withTx(ctx, s.db, "delete document blob bytes (hard retention)", 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)
}
}
return nil
})
if err != nil {
return nil, err
}
return blobs, nil
}
// DeleteFileBlobsForPhoto is the photo counterpart of
// DeleteFileBlobsForDocument -- see its doc comment. Deliberately does not
// delete the photos row.
func (s *MediaStore) DeleteFileBlobsForPhoto(ctx context.Context, id int64) ([]domain.FileBlob, error) {
var blobs []domain.FileBlob
err := withTx(ctx, s.db, "delete photo blob bytes (hard retention)", 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)
}
}
return nil
})
if err != nil {
return nil, err
}
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
}