233 lines
8.5 KiB
Go
233 lines
8.5 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
|
|
}
|
|
|
|
// 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
|
|
}
|