Files
silo-server/internal/diagnostics/repo.go
Quick104andClaude Fable 5 31e6f1ef41 fix(diagnostics): address PR #450 review findings
- service.go: reject trailing data after the decoded manifest object.
  Decoder.More() only reports array/object iteration, so a stray closing
  delimiter (e.g. {...}}) slipped through where json.Unmarshal used to
  reject it. Require the stream to reach io.EOF after decoding on both
  the received and embedded sides; add a regression test.
- repo.go: split the list projection from cleanup. reportListSelectSQL
  keeps the app_build JSONB extraction for the admin list; new
  reportCleanupSelectSQL omits it so retention/stale batches don't touch
  each candidate's manifest JSONB just to delete a row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
2026-07-21 14:53:47 -04:00

673 lines
21 KiB
Go

package diagnostics
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
diagnosticQuotaLockNamespace = 173
defaultShortIDRetries = 8
defaultAdminListLimit = 50
maxAdminListLimit = 200
)
type PostgresRepository struct {
pool *pgxpool.Pool
}
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
return &PostgresRepository{pool: pool}
}
func (r *PostgresRepository) InsertReceiving(ctx context.Context, input InsertReceivingInput) (InsertReceivingResult, error) {
if err := validateInsertReceivingInput(input); err != nil {
return InsertReceivingResult{}, err
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return InsertReceivingResult{}, fmt.Errorf("begin diagnostic report insert: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::int4, $2::int4)`,
diagnosticQuotaLockNamespace, input.UserID); err != nil {
return InsertReceivingResult{}, fmt.Errorf("acquire diagnostic quota lock: %w", err)
}
now := input.Now
if now.IsZero() {
now = time.Now().UTC()
}
if err := r.checkQuotas(ctx, tx, input, now); err != nil {
return InsertReceivingResult{}, err
}
shortIDGenerator := input.ShortIDGenerator
if shortIDGenerator == nil {
shortIDGenerator = NewShortID
}
reportIDGenerator := input.ReportIDGenerator
if reportIDGenerator == nil {
reportIDGenerator = uuid.NewString
}
retries := input.ShortIDCollisionRetries
if retries <= 0 {
retries = defaultShortIDRetries
}
crashSummary := truncateCrashSummary(input.CrashSummary)
sessionIDs := input.PlaybackSessionIDs
if sessionIDs == nil {
// pgx binds a nil slice as SQL NULL, which bypasses the column's
// '{}' default and violates its NOT NULL constraint.
sessionIDs = []string{}
}
for attempt := 0; attempt < retries; attempt++ {
id := reportIDGenerator()
if _, err := uuid.Parse(id); err != nil {
return InsertReceivingResult{}, fmt.Errorf("%w: report id must be a UUID", ErrInvalidReportInput)
}
shortID, err := shortIDGenerator()
if err != nil {
return InsertReceivingResult{}, fmt.Errorf("generate diagnostic short id: %w", err)
}
shortID, err = ParseShortID(shortID)
if err != nil {
return InsertReceivingResult{}, fmt.Errorf("%w: generated short id is invalid", err)
}
var insertedID, insertedShortID string
err = tx.QueryRow(ctx, `
INSERT INTO client_diagnostic_reports (
id, short_id, user_id, profile_id, state, captured_at, report_type,
platform, app_version, crash_summary, manifest, playback_session_ids,
blob_bytes
)
VALUES ($1::uuid, $2, $3, $4, 'receiving', $5, $6, $7, $8, $9, $10::jsonb, $11, $12)
ON CONFLICT DO NOTHING
RETURNING id::text, short_id
`, id, shortID, input.UserID, nullableString(input.ProfileID), input.CapturedAt,
strings.TrimSpace(input.ReportType), strings.TrimSpace(input.Platform),
strings.TrimSpace(input.AppVersion), nullableString(crashSummary),
string(input.Manifest), sessionIDs, input.ExpectedBlobBytes).Scan(&insertedID, &insertedShortID)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return InsertReceivingResult{}, fmt.Errorf("insert diagnostic report: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return InsertReceivingResult{}, fmt.Errorf("commit diagnostic report insert: %w", err)
}
return InsertReceivingResult{ID: insertedID, ShortID: insertedShortID}, nil
}
return InsertReceivingResult{}, ErrShortIDExhausted
}
func (r *PostgresRepository) checkQuotas(ctx context.Context, tx pgx.Tx, input InsertReceivingInput, now time.Time) error {
if input.MaxReportsPerUserDay > 0 {
var count int
if err := tx.QueryRow(ctx, `
SELECT COUNT(*)
FROM client_diagnostic_reports
WHERE user_id = $1
AND state IN ('receiving', 'ready')
AND received_at >= date_trunc('day', $2::timestamptz)
`, input.UserID, now).Scan(&count); err != nil {
return fmt.Errorf("count diagnostic reports for quota: %w", err)
}
if count >= input.MaxReportsPerUserDay {
return &QuotaError{Kind: QuotaKindReportsPerDay, Limit: int64(input.MaxReportsPerUserDay)}
}
}
if input.MaxBytesPerUser > 0 {
// Count 'receiving' rows too: a new upload reserves its expected size in
// blob_bytes before the bundle streams in, so concurrent or multi-node
// uploads can't each pass a near-limit check and both mark ready. Ready
// rows overwrite the reservation with their actual size on completion.
var used int64
if err := tx.QueryRow(ctx, `
SELECT COALESCE(SUM(blob_bytes), 0)
FROM client_diagnostic_reports
WHERE user_id = $1
AND state IN ('receiving', 'ready')
`, input.UserID).Scan(&used); err != nil {
return fmt.Errorf("sum diagnostic bytes for quota: %w", err)
}
if input.ExpectedBlobBytes > input.MaxBytesPerUser || used+input.ExpectedBlobBytes > input.MaxBytesPerUser {
return &QuotaError{Kind: QuotaKindBytesPerUser, Limit: input.MaxBytesPerUser}
}
}
return nil
}
func (r *PostgresRepository) MarkReady(ctx context.Context, id string, blob BlobInfo) error {
tag, err := r.pool.Exec(ctx, `
UPDATE client_diagnostic_reports
SET state = 'ready',
blob_bucket = $2,
blob_key = $3,
blob_bytes = $4,
uncompressed_bytes = $5,
blob_sha256 = $6
WHERE id = $1::uuid
`, id, strings.TrimSpace(blob.Bucket), strings.TrimSpace(blob.Key), blob.Bytes, blob.UncompressedBytes, strings.TrimSpace(blob.SHA256))
if err != nil {
return fmt.Errorf("mark diagnostic report ready: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *PostgresRepository) MarkFailed(ctx context.Context, id string) error {
tag, err := r.pool.Exec(ctx, `
UPDATE client_diagnostic_reports
SET state = 'failed'
WHERE id = $1::uuid
`, id)
if err != nil {
return fmt.Errorf("mark diagnostic report failed: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *PostgresRepository) GetByID(ctx context.Context, id string) (*Report, error) {
row := r.pool.QueryRow(ctx, reportSelectSQL()+`
WHERE id = $1::uuid
`, id)
report, err := scanReport(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return report, nil
}
func (r *PostgresRepository) ListForAdmin(ctx context.Context, filters ListFilters) (ListResult, error) {
limit := filters.Limit
if limit <= 0 || limit > maxAdminListLimit {
limit = defaultAdminListLimit
}
conditions := []string{"1=1"}
args := make([]any, 0, 10)
argIdx := 1
if filters.UserID != nil {
conditions = append(conditions, fmt.Sprintf("user_id = $%d", argIdx))
args = append(args, *filters.UserID)
argIdx++
}
if strings.TrimSpace(filters.Platform) != "" {
conditions = append(conditions, fmt.Sprintf("platform = $%d", argIdx))
args = append(args, strings.TrimSpace(filters.Platform))
argIdx++
}
if strings.TrimSpace(filters.ReportType) != "" {
conditions = append(conditions, fmt.Sprintf("report_type = $%d", argIdx))
args = append(args, strings.TrimSpace(filters.ReportType))
argIdx++
}
if filters.From != nil {
conditions = append(conditions, fmt.Sprintf("received_at >= $%d", argIdx))
args = append(args, *filters.From)
argIdx++
}
if filters.To != nil {
conditions = append(conditions, fmt.Sprintf("received_at <= $%d", argIdx))
args = append(args, *filters.To)
argIdx++
}
if strings.TrimSpace(filters.ShortID) != "" {
shortID, err := ParseShortID(filters.ShortID)
if err != nil {
return ListResult{}, err
}
conditions = append(conditions, fmt.Sprintf("lower(short_id) = lower($%d)", argIdx))
args = append(args, shortID)
argIdx++
}
if strings.TrimSpace(filters.Cursor) != "" {
cursorReceivedAt, cursorID, err := decodeReportCursor(filters.Cursor)
if err != nil {
return ListResult{}, err
}
conditions = append(conditions, fmt.Sprintf("(received_at, id) < ($%d, $%d::uuid)", argIdx, argIdx+1))
args = append(args, cursorReceivedAt, cursorID)
argIdx += 2
}
query := fmt.Sprintf("%s\n\t\tWHERE %s\n\t\tORDER BY received_at DESC, id DESC\n\t\tLIMIT $%d",
reportListSelectSQL(), strings.Join(conditions, " AND "), argIdx)
args = append(args, limit+1)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return ListResult{}, fmt.Errorf("list diagnostic reports: %w", err)
}
defer rows.Close()
reports := make([]Report, 0, limit+1)
for rows.Next() {
report, err := scanReportSummary(rows)
if err != nil {
return ListResult{}, err
}
reports = append(reports, *report)
}
if err := rows.Err(); err != nil {
return ListResult{}, fmt.Errorf("iterate diagnostic reports: %w", err)
}
result := ListResult{}
if len(reports) > limit {
last := reports[limit-1]
result.NextCursor = encodeReportCursor(last.ReceivedAt, last.ID)
reports = reports[:limit]
}
result.Reports = reports
return result, nil
}
func (r *PostgresRepository) DeleteByID(ctx context.Context, id string) (*Report, error) {
row := r.pool.QueryRow(ctx, `
DELETE FROM client_diagnostic_reports
WHERE id = $1::uuid
RETURNING id::text, short_id, user_id, profile_id, state, captured_at, received_at,
report_type, platform, app_version, crash_summary, manifest, playback_session_ids,
blob_bucket, blob_key, blob_bytes, uncompressed_bytes, blob_sha256,
COALESCE(manifest->'report'->>'app_build', '')
`, id)
report, err := scanReport(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return report, nil
}
func (r *PostgresRepository) RetentionCandidates(ctx context.Context, olderThan time.Time, perUserByteCap int64) ([]Report, error) {
if olderThan.IsZero() && perUserByteCap <= 0 {
return nil, nil
}
query := reportCleanupSelectSQL() + `
WHERE received_at < $1
ORDER BY received_at ASC, id ASC
`
args := []any{olderThan}
if olderThan.IsZero() {
query = reportCleanupSelectSQL() + `
WHERE false
`
args = nil
}
if perUserByteCap > 0 {
byteCapQuery := `
WITH quota_candidates AS (
SELECT id
FROM (
SELECT id,
SUM(COALESCE(blob_bytes, 0)) OVER (
PARTITION BY user_id
ORDER BY received_at DESC, id DESC
) AS retained_bytes
FROM client_diagnostic_reports
WHERE state = 'ready'
) ranked
WHERE retained_bytes > $1
)
` + reportCleanupSelectSQL() + `
WHERE id IN (SELECT id FROM quota_candidates)
ORDER BY received_at ASC, id ASC
`
if olderThan.IsZero() {
return r.queryReportCleanup(ctx, byteCapQuery, perUserByteCap)
}
query = `
WITH quota_candidates AS (
SELECT id
FROM (
SELECT id,
SUM(COALESCE(blob_bytes, 0)) OVER (
PARTITION BY user_id
ORDER BY received_at DESC, id DESC
) AS retained_bytes
FROM client_diagnostic_reports
WHERE state = 'ready'
) ranked
WHERE retained_bytes > $2
)
` + reportCleanupSelectSQL() + `
WHERE received_at < $1 OR id IN (SELECT id FROM quota_candidates)
ORDER BY received_at ASC, id ASC
`
args = []any{olderThan, perUserByteCap}
}
return r.queryReportCleanup(ctx, query, args...)
}
func (r *PostgresRepository) StaleReceiving(ctx context.Context, grace time.Duration) ([]Report, error) {
if grace <= 0 {
grace = time.Hour
}
cutoff := time.Now().UTC().Add(-grace)
return r.queryReportCleanup(ctx, reportCleanupSelectSQL()+`
WHERE state IN ('receiving', 'failed')
AND received_at < $1
ORDER BY received_at ASC, id ASC
`, cutoff)
}
func (r *PostgresRepository) LiveBlobKeys(ctx context.Context, keys []string) (map[string]ReportState, error) {
live := make(map[string]ReportState, len(keys))
if len(keys) == 0 {
return live, nil
}
rows, err := r.pool.Query(ctx, `
SELECT candidate.key, report.state
FROM unnest($1::text[]) AS candidate(key)
JOIN client_diagnostic_reports report
ON report.blob_key = candidate.key
OR ($2 || report.user_id::text || '/' || report.id::text || '.tar.gz') = candidate.key
WHERE report.state IN ('ready', 'receiving')
`, keys, ObjectPrefix)
if err != nil {
return nil, fmt.Errorf("load live diagnostic blob keys: %w", err)
}
defer rows.Close()
for rows.Next() {
var key string
var state ReportState
if err := rows.Scan(&key, &state); err != nil {
return nil, fmt.Errorf("scan live diagnostic blob key: %w", err)
}
live[key] = state
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate live diagnostic blob keys: %w", err)
}
return live, nil
}
// queryReportCleanup runs a reportCleanupSelectSQL-shaped query; the returned
// reports carry no manifest and no app_build. Only the retention and
// stale-cleanup batches use it.
func (r *PostgresRepository) queryReportCleanup(ctx context.Context, query string, args ...any) ([]Report, error) {
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query diagnostic reports: %w", err)
}
defer rows.Close()
reports := []Report{}
for rows.Next() {
report, err := scanReportCleanup(rows)
if err != nil {
return nil, err
}
reports = append(reports, *report)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate diagnostic reports: %w", err)
}
return reports, nil
}
func validateInsertReceivingInput(input InsertReceivingInput) error {
if input.UserID <= 0 {
return fmt.Errorf("%w: user id is required", ErrInvalidReportInput)
}
if input.CapturedAt.IsZero() {
return fmt.Errorf("%w: captured_at is required", ErrInvalidReportInput)
}
if strings.TrimSpace(input.ReportType) == "" {
return fmt.Errorf("%w: report type is required", ErrInvalidReportInput)
}
if strings.TrimSpace(input.Platform) == "" {
return fmt.Errorf("%w: platform is required", ErrInvalidReportInput)
}
if strings.TrimSpace(input.AppVersion) == "" {
return fmt.Errorf("%w: app version is required", ErrInvalidReportInput)
}
if len(input.Manifest) == 0 || !json.Valid(input.Manifest) {
return fmt.Errorf("%w: manifest must be valid JSON", ErrInvalidReportInput)
}
if input.ExpectedBlobBytes < 0 {
return fmt.Errorf("%w: expected blob bytes cannot be negative", ErrInvalidReportInput)
}
return nil
}
// reportSelectSQL is the full projection including the manifest JSONB (up to
// MaxManifestBytes per row). Reserve it for single-report detail/download paths;
// the admin list uses reportListSelectSQL and retention/stale batches use
// reportCleanupSelectSQL so browsing a page or running a cleanup pass doesn't
// drag every report's manifest through the DB.
func reportSelectSQL() string {
return `SELECT id::text, short_id, user_id, profile_id, state, captured_at, received_at,
report_type, platform, app_version, crash_summary, manifest, playback_session_ids,
blob_bucket, blob_key, blob_bytes, uncompressed_bytes, blob_sha256,
COALESCE(manifest->'report'->>'app_build', '')
FROM client_diagnostic_reports`
}
// reportListSelectSQL is the admin-list projection: it omits the full manifest
// column but still extracts app_build from the manifest JSONB (a cheap text
// extraction) so list rows can show the build number without shipping the whole
// manifest. Reports scanned with scanReportSummary therefore have a nil
// Manifest. Cleanup batches must not pay for the JSONB extraction, so they use
// reportCleanupSelectSQL instead.
func reportListSelectSQL() string {
return `SELECT id::text, short_id, user_id, profile_id, state, captured_at, received_at,
report_type, platform, app_version, crash_summary, playback_session_ids,
blob_bucket, blob_key, blob_bytes, uncompressed_bytes, blob_sha256,
COALESCE(manifest->'report'->>'app_build', '')
FROM client_diagnostic_reports`
}
// reportCleanupSelectSQL is the retention/stale-cleanup projection. It omits
// both the manifest column and the app_build extraction because cleanup only
// needs id/state/blob fields to delete a row and its object; touching the JSONB
// per candidate would turn a scheduled pass over many large manifests into
// needless manifest I/O. Reports scanned with scanReportCleanup therefore have
// a nil Manifest and an empty AppBuild.
func reportCleanupSelectSQL() string {
return `SELECT id::text, short_id, user_id, profile_id, state, captured_at, received_at,
report_type, platform, app_version, crash_summary, playback_session_ids,
blob_bucket, blob_key, blob_bytes, uncompressed_bytes, blob_sha256
FROM client_diagnostic_reports`
}
type reportScanner interface {
Scan(dest ...any) error
}
// reportNulls holds the nullable columns shared by the full and summary
// projections so both scanners apply them the same way.
type reportNulls struct {
profileID sql.NullString
crashSummary sql.NullString
blobBucket sql.NullString
blobKey sql.NullString
blobSHA256 sql.NullString
blobBytes sql.NullInt64
uncompressedBytes sql.NullInt64
}
func (n *reportNulls) apply(report *Report) {
if n.profileID.Valid {
report.ProfileID = &n.profileID.String
}
if n.crashSummary.Valid {
report.CrashSummary = &n.crashSummary.String
}
if report.PlaybackSessionIDs == nil {
report.PlaybackSessionIDs = []string{}
}
if n.blobBucket.Valid {
report.BlobBucket = &n.blobBucket.String
}
if n.blobKey.Valid {
report.BlobKey = &n.blobKey.String
}
if n.blobBytes.Valid {
report.BlobBytes = &n.blobBytes.Int64
}
if n.uncompressedBytes.Valid {
report.UncompressedBytes = &n.uncompressedBytes.Int64
}
if n.blobSHA256.Valid {
report.BlobSHA256 = &n.blobSHA256.String
}
}
// scanReport scans the full reportSelectSQL projection, including the manifest.
func scanReport(row reportScanner) (*Report, error) {
var report Report
var nulls reportNulls
var manifest []byte
if err := row.Scan(
&report.ID,
&report.ShortID,
&report.UserID,
&nulls.profileID,
&report.State,
&report.CapturedAt,
&report.ReceivedAt,
&report.ReportType,
&report.Platform,
&report.AppVersion,
&nulls.crashSummary,
&manifest,
&report.PlaybackSessionIDs,
&nulls.blobBucket,
&nulls.blobKey,
&nulls.blobBytes,
&nulls.uncompressedBytes,
&nulls.blobSHA256,
&report.AppBuild,
); err != nil {
return nil, fmt.Errorf("scan diagnostic report: %w", err)
}
if len(manifest) > 0 {
report.Manifest = append(json.RawMessage(nil), manifest...)
}
nulls.apply(&report)
return &report, nil
}
// summaryScanTargets returns the scan destinations shared by the admin-list and
// cleanup projections, in column order. The two scanners differ only in whether
// app_build is appended, so keeping the common prefix in one place stops the
// long Scan lists from drifting apart.
func summaryScanTargets(report *Report, nulls *reportNulls) []any {
return []any{
&report.ID,
&report.ShortID,
&report.UserID,
&nulls.profileID,
&report.State,
&report.CapturedAt,
&report.ReceivedAt,
&report.ReportType,
&report.Platform,
&report.AppVersion,
&nulls.crashSummary,
&report.PlaybackSessionIDs,
&nulls.blobBucket,
&nulls.blobKey,
&nulls.blobBytes,
&nulls.uncompressedBytes,
&nulls.blobSHA256,
}
}
// scanReportSummary scans the reportListSelectSQL projection, which omits the
// manifest but includes app_build; the returned report's Manifest is left nil.
func scanReportSummary(row reportScanner) (*Report, error) {
var report Report
var nulls reportNulls
targets := append(summaryScanTargets(&report, &nulls), &report.AppBuild)
if err := row.Scan(targets...); err != nil {
return nil, fmt.Errorf("scan diagnostic report: %w", err)
}
nulls.apply(&report)
return &report, nil
}
// scanReportCleanup scans the reportCleanupSelectSQL projection, which omits
// both the manifest and app_build; the returned report's Manifest is nil and
// AppBuild is empty. Cleanup only reads id/state/blob fields.
func scanReportCleanup(row reportScanner) (*Report, error) {
var report Report
var nulls reportNulls
if err := row.Scan(summaryScanTargets(&report, &nulls)...); err != nil {
return nil, fmt.Errorf("scan diagnostic report: %w", err)
}
nulls.apply(&report)
return &report, nil
}
func nullableString(s *string) any {
if s == nil {
return nil
}
if strings.TrimSpace(*s) == "" {
return nil
}
return *s
}
func encodeReportCursor(receivedAt time.Time, id string) string {
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d|%s", receivedAt.UnixNano(), id)))
}
func decodeReportCursor(cursor string) (time.Time, string, error) {
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return time.Time{}, "", fmt.Errorf("%w: decode: %v", ErrInvalidCursor, err)
}
parts := strings.Split(string(raw), "|")
if len(parts) != 2 {
return time.Time{}, "", ErrInvalidCursor
}
nanos, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return time.Time{}, "", fmt.Errorf("%w: timestamp: %v", ErrInvalidCursor, err)
}
id := parts[1]
if _, err := uuid.Parse(id); err != nil {
return time.Time{}, "", fmt.Errorf("%w: id: %v", ErrInvalidCursor, err)
}
return time.Unix(0, nanos).UTC(), id, nil
}