Skip to content

Fix/db storage - #111

Draft
Amazing-Stardom wants to merge 22 commits into
masterfrom
fix/db-storage
Draft

Amazing-Stardom wants to merge 22 commits into
masterfrom
fix/db-storage

Conversation

@Amazing-Stardom

@Amazing-Stardom Amazing-Stardom commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Fix: DB Storage Growth from River Job Retention and preloaded_changes Metadata

TL;DR

PostgreSQL storage grew by 44% due to two root causes.

First, the River job queue had no retention limit, so completed and cancelled jobs accumulated in the river_job table indefinitely.

Second, large JSON blobs in the preloaded_changes field of the reviews.metadata column were never removed after reviews were completed.

Changes

River job retention limits

Set River job retention periods to prevent unbounded table growth:

  • Completed jobs: 30 days
  • Cancelled jobs: 30 days
  • Discarded jobs: 30 days

Preloaded changes archival cron job

Added a background cron job that runs daily at 21:30 UTC (3:00 AM IST) to move preloaded_changes JSON from PostgreSQL to blob storage.

The job works as follows:

  1. Query reviews older than the configured retention period that still have preloaded_changes in metadata.
  2. Enqueue one River job per review to upload the diff to blob storage.
  3. After all archival jobs are complete, run a purge job to remove preloaded_changes from the metadata column in bulk.

An admin settings tab at Settings/Storage allows operators to configure the schedule and retention period and trigger a manual run.

Fix: old review results failing to load

Very old CLI reviews stored the confidence field as a number (e.g. 0.95). The backend struct expected a string. Loading those reviews returned this error:

failed to decode review result: json: cannot unmarshal number into Go struct field ReviewComment.comments.Confidence of type string
image

The fix runs a second decode pass only when this specific error occurs. It converts any numeric confidence value to a string and retries. New reviews are not affected.

Validation

  1. Ran the manual archival trigger from admin settings and confirmed diffs moved to blob storage.
  2. Opened review 5645 (an old CLI review with numeric confidence) and confirmed it loads without error.
  3. go build ./internal/api ./internal/jobqueue passes with 0 errors.
  4. npx tsc --noEmit passes with 0 errors.

Checklist

  • This PR fulfills an agreed issue.
  • I kept the change narrow and scoped.
  • I ran the most specific relevant validation and described it above.
  • If I changed behavior, I called that out clearly in this PR.
  • If I touched UI, I attached a GIF or video walkthrough. This is required.
  • If this change touches security, disclosure flow, credentials, storage, network behavior, or licensing/entitlement enforcement, I reviewed SECURITY.md and updated related documentation if needed.
  • I have read and accept the Contributor License Agreement.

Notes For Reviewers

  • The purge job uses River's NextRetry pattern. It reschedules itself every 5 minutes until all archival jobs for a batch complete. Review this flow in PreloadedChangesArchivalPurgeWorker.
  • The confidence number-to-string fallback in decodeReviewResult is intentionally narrow. It only runs when the error message contains "cannot unmarshal number".
  • The cron expression is stored and executed in UTC. The UI displays schedule descriptions in UTC to avoid local timezone confusion.

Comment thread internal/api/diff_review.go
Comment thread internal/jobqueue/review_worker.go Outdated
Comment thread internal/jobqueue/review_worker.go
Comment thread scripts/migrate_diffs_to_blobstore.go Outdated
Comment thread scripts/migrate_diffs_to_blobstore.go Outdated
Comment thread scripts/migrate_diffs_to_blobstore.go Outdated
@LiveReview-Bot

Copy link
Copy Markdown

Offload Code Diffs to Blob Storage

Overview

This change introduces an automated archival system to move raw code diffs from PostgreSQL metadata to external blob storage after thirty days. It adds background workers, API endpoints, and a migration tool to prevent database bloat and reduce backup sizes.

Technical Highlights

  • Blob Storage (internal/blobstore): Implements utility functions for secure artifact storage with size limits and dynamic bucket configuration.
  • Job Queue (internal/jobqueue): Integrates River workers to handle asynchronous diff offloading, bulk deletion, and reduced job retention periods.
  • API and UI (internal/api, ui): Adds administrative settings tabs, cron scheduling options, and manual triggers for archival management.
  • Migration Script (scripts): Provides a worker-pool CLI utility to migrate existing JSONB payloads safely into blob storage.

Impact

  • Functionality: Reduces PostgreSQL table size by offloading historical code diffs to external blob storage.
  • Risk: Database queries for old reviews require fallback logic to fetch missing payloads from blob storage.

Comment thread internal/api/preloaded_changes_archival.go Outdated
Comment thread internal/api/preloaded_changes_archival_settings.go Outdated
Comment thread internal/api/preloaded_changes_archival_settings.go Outdated
Comment thread internal/api/preloaded_changes_archival_settings.go
Comment thread internal/api/preloaded_changes_archival_settings.go
Comment thread internal/blobstore/blobstore.go
Comment thread internal/jobqueue/jobqueue.go
Comment thread internal/jobqueue/jobqueue.go
Comment thread internal/jobqueue/jobqueue.go Outdated
Comment thread internal/jobqueue/preloaded_changes_archival_worker.go Outdated
Comment thread internal/jobqueue/preloaded_changes_archival_worker.go Outdated
Comment thread internal/jobqueue/preloaded_changes_archival_worker.go Outdated
Comment thread scripts/migrate_diffs_to_blobstore.go Outdated
Comment thread ui/src/pages/Settings/PreloadedChangesArchivalSettingsTab.tsx
Comment thread ui/src/pages/Settings/PreloadedChangesArchivalSettingsTab.tsx
@LiveReview-Bot

Copy link
Copy Markdown

Offload Code Diffs to Blob Storage

Overview

This architectural update moves old code diffs from PostgreSQL JSONB metadata to external blob storage. It adds background workers, API endpoints, and configuration settings to automate the archival process.

Technical Highlights

  • internal/blobstore/blobstore.go: Centralizes bucket operations and enforces a 100MB limit on artifact sizes to prevent memory issues.
  • internal/jobqueue/jobqueue.go: Registers archival and purge workers while reducing River job retention periods from 365 to 30 days.
  • internal/api/preloaded_changes_archival.go: Implements automated background cron management for grouping and offloading stale code diffs by organization.
  • scripts/migrate_diffs_to_blobstore.go: Provides a concurrent producer-consumer script to migrate existing database diffs into blob storage safely.

Impact

  • Functionality: Reduces database bloat by moving historical diff payloads to blob storage with transparent API read fallbacks.
  • Risk: Failed blob uploads rely on a local write-through metadata fallback, requiring careful monitoring during high-load archival runs.

Comment thread internal/api/preloaded_changes_archival.go Outdated
Comment thread internal/api/preloaded_changes_archival.go Outdated
Comment thread internal/api/preloaded_changes_archival_settings.go Outdated
Comment thread internal/api/preloaded_changes_archival_settings.go
Comment thread internal/api/preloaded_changes_archival_settings.go
Comment thread internal/api/preloaded_changes_archival_settings.go Outdated
Comment thread internal/blobstore/blobstore.go
}
defer r.Close()

size := r.Size()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The r.Size() method may not provide accurate results or might not be available for all blob storage backends or implementations.

Suggestions:

  1. Add a comment explaining the reliance on r.Size() and the fallback io.LimitReader for robustness.
  2. Consider if io.LimitReader alone is sufficient, or if r.Size() is critical for early exit.

effectiveCronExpr := "30 21 * * *"
var archivalRetentionDays int = 30

if db != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The database query is using context.Background(). Please ensure that this is acceptable for long-running startup operations, as it might not propagate cancellation signals.

Suggestions:

  1. Consider passing a context with a timeout if the DB query could hang indefinitely during startup.


if db != nil {
var data []byte
err := db.QueryRowContext(context.Background(), "SELECT data FROM system_settings WHERE name = 'preloaded_changes_archival_settings'").Scan(&data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Potential sql.ErrNoRows is not explicitly handled. The check err == nil might be misleading if no row actually exists in the database.

Suggestions:

  1. Explicitly check for sql.ErrNoRows and handle it to clarify intent, though current logic defaults correctly.

RetentionDays int `json:"retention_days"`
}
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
log.Printf("[jobqueue] failed to unmarshal archival settings from DB: %v", unmarshalErr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The log message for the unmarshal error lacks context. It would be beneficial to include the name of the setting that failed to unmarshal.

Suggestions:

  1. Add name (e.g., preloaded_changes_archival_settings) to the log message for better debuggability.


parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
archivalSchedule, parseErr := parser.Parse(effectiveCronExpr)
if parseErr != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The log message for the cron parse error uses Printf instead of Errorf. This might cause the error to be missed or not properly highlighted in the logs.

Suggestions:

  1. Use log.Errorf or a structured logger with error level for cron parse failures.

log.Printf("[jobqueue] preloaded_changes archival cron=%q next_run=%s retention_days=%d", effectiveCronExpr, nextRun.Format("2006-01-02 15:04:05 MST"), archivalRetentionDays)
}

// Verification 1: Ensure River database schema matches the River Go library version

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

This is an excellent addition. River schema validation effectively prevents silent data corruption that could arise from missed database migrations.

CompletedJobRetentionPeriod: 365 * 24 * time.Hour,
CancelledJobRetentionPeriod: 365 * 24 * time.Hour,
DiscardedJobRetentionPeriod: 365 * 24 * time.Hour,
CompletedJobRetentionPeriod: 30 * 24 * time.Hour,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

Reducing the job retention period from 365 days to 30 days will significantly decrease the database size and improve overall performance.

func() (river.JobArgs, *river.InsertOpts) {
return PreloadedChangesArchivalSweepJobArgs{
RetentionDays: archivalRetentionDays,
BatchSize: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Setting BatchSize: 0 is unclear regarding its intent. It could imply the default batch size or no batching at all. Please clarify the intended behavior or use a meaningful default value.

Suggestions:

  1. Add a comment explaining BatchSize: 0 behavior, or set a sensible default if it's not handled by the worker.

},
&river.PeriodicJobOpts{
ID: "preloaded_changes_archival_sweep",
RunOnStart: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

Setting RunOnStart: true for this periodic job ensures that it will execute immediately after the application starts up.

InsertOpts: &river.InsertOpts{
Queue: "preloaded_changes_archival",
UniqueOpts: river.UniqueOpts{
ByArgs: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

Using UniqueOpts{ByArgs: true} is a good practice for preventing the creation of duplicate archival jobs.

}
res, err := jq.client.InsertMany(ctx, params)
if err != nil {
log.Printf("[ERROR] Failed to queue preloaded_changes archival jobs: %v", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The log message uses Printf instead of Errorf. This might cause the log entry to be overlooked or not treated as an error.

Suggestions:

  1. Use log.Errorf or a structured logger with error level for failed job queuing.

// UpdateArchivalSchedule dynamically updates the River periodic schedule for preloaded_changes archival.
func (jq *JobQueue) UpdateArchivalSchedule(cronExpr string) error {
if jq == nil || jq.client == nil {
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Returning nil when jq or client is nil might silently ignore schedule updates. It's important to ensure these cases are handled appropriately.

Suggestions:

  1. Return an error here, similar to EnqueuePreloadedChangesArchivalSweep, to indicate a misconfigured job queue.

return fmt.Errorf("invalid cron expression %q: %w", cronExpr, err)
}
// Remove any existing schedule for this job before adding the new one dynamically
jq.client.PeriodicJobs().RemoveByID("preloaded_changes_archival_sweep")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

Calling RemoveByID before Add is a crucial step for correctly updating dynamic schedules.

schedule,
func() (river.JobArgs, *river.InsertOpts) {
return PreloadedChangesArchivalSweepJobArgs{
RetentionDays: 30, // Using default here; the sweep worker fetches the live value dynamically

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The RetentionDays is hardcoded to 30 here, but the comment suggests that the worker fetches the live value. Please confirm that the worker logic correctly handles this and that there is no discrepancy.

Suggestions:

  1. Ensure preloadedChangesArchivalSweepWorker indeed fetches the current retention days from settings, making this hardcoded value harmless.

},
&river.PeriodicJobOpts{
ID: "preloaded_changes_archival_sweep",
RunOnStart: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

Setting RunOnStart: true for this dynamically added periodic job ensures that it takes effect immediately upon startup.

AND metadata ? 'preloaded_changes'
ORDER BY created_at ASC;
`
rows, err := w.db.QueryContext(ctx, query, retentionDays)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The sweep query fetches all eligible reviews at once. This could lead to a memory issue if there are millions of records to process.

Suggestions:

  1. Implement batching for archivalJobs creation, e.g., fetch and enqueue in chunks.
  2. Consider using COPY (SELECT ...) TO STDOUT for very large datasets if w.db.QueryContext becomes a bottleneck.

log.Error().Err(uploadErr).Int64("review_id", reviewID).Msg("[preloaded_changes_archival_worker] blob upload failed")

// If it's a fatal write permission error, cancel the job permanently so it doesn't infinitely retry
if strings.Contains(strings.ToLower(uploadErr.Error()), "permission denied") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

This is good. Calling JobCancel for fatal permission errors effectively prevents infinite retry loops.

return fmt.Errorf("failed to query pending jobs for batch %s: %w", batchRunID, err)
}
if pendingCount > 0 {
log.Warn().Str("batch_run_id", batchRunID).Int("pending", pendingCount).Msg("[preloaded_changes_purge_worker] 6 hours passed with jobs still pending — discarding purge job (no data deleted)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

This is a critical safety measure. Calling JobCancel prevents data purging if jobs remain pending after a 6-hour timeout.

log.Info().Str("batch_run_id", batchRunID).Int("pending", pendingCount).Str("snooze", snoozeDuration.String()).Str("elapsed", elapsed.Round(time.Second).String()).Msg("[preloaded_changes_purge_worker] jobs still pending, snoozing with exponential backoff...")
// JobSnooze safely reschedules the job without counting it as a failure,
// and automatically extends MaxAttempts so it never permanently dies from snoozing.
return river.JobSnooze(snoozeDuration)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

This is good. Using JobSnooze implements intelligent backoff and avoids unnecessarily marking jobs as failures.

}

// 2. O(1) Fetch & Update (Postgres does all the work internally)
purgeQuery := `

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

This is good. The bulk UPDATE statement uses a subquery for efficiency, and the metadata - 'key' syntax is used for JSONB removal.

type PreloadedChangesArchivalConfig struct {
// MaxWorkers is the concurrency of the "preloaded_changes_archival" queue.
MaxWorkers int // default: 10
// BatchSize is the number of reviews packaged into a single archival job.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The description for BatchSize is unclear. While individual archival jobs process one review, the sweep job does not utilize this BatchSize for fetching records. Please clarify the intended use or remove the ambiguity.

Suggestions:

  1. Clarify BatchSize purpose: if it's for the sweep, implement its usage there. If it's for individual archival jobs, clarify how it applies (e.g., if a single archival job could process multiple reviews, which is not currently the case).
  2. Consider renaming BatchSize if it's not used for batching reviews, or remove if unused.

MaxWorkers: 10,
BatchSize: 10,
}
if v := os.Getenv("LIVEREVIEW_PRELOADED_CHANGES_ARCHIVAL_MAX_WORKERS"); v != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

This is good. Environment variables can be used to override MaxWorkers and BatchSize, providing flexibility in configuration.

if n, err := strconv.Atoi(v); err == nil && n > 0 {
config.MaxWorkers = n
}
} else if v := os.Getenv("LIVEREVIEW_DIFF_ARCHIVAL_MAX_WORKERS"); v != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

This is good. The implementation includes backward compatibility for older environment variable names, ensuring a smoother transition.

modelDiffsPayload, err := json.Marshal(modelDiffs)
if err != nil {
w.handleFailure(ctx, args, logger, eventSink, fmt.Sprintf("failed to marshal diffs: %v", err), "failed_to_marshal_diffs")
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

An early return in this function skips the logger cleanup and leaves the review in a running state. Please use handleFailure properly or ensure that cleanup is performed.

Suggestions:

  1. Ensure eventSink and logger are properly closed or failed before returning nil on marshal error

<div className="grid grid-cols-6 gap-1 w-fit">
{HOURS_12.map((h12) => {
const h24 = h12 === 0 ? offset : h12 + offset; // 12AM=0, 1AM=1...11AM=11, 12PM=12, 1PM=13...11PM=23
const isSelected = hours.includes(h24 === 24 ? 12 : h24); // handle edge: 12+12=24 shouldn't happen since offset is 0 or 12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

With offsets of 0 or 12, it's impossible to achieve h24=24. This appears to be a dead check, as h24 === 24 will never be true under these conditions.

Suggestions:

  1. Remove the redundant h24 === 24 check

return enrichedFiles;
}, [enrichedFiles, sortMode, canSortByRisk]);

const facets = useMemo(() => buildFilterFacets(files, filters), [files, filters]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The buildFilterFacets function has been updated to use enrichedFiles instead of the previous files.

Suggestions:

  1. Confirm enrichedFiles always contains necessary data for facet building. If files and enrichedFiles are not always identical in structure/content, this could lead to subtle filtering bugs or missing facets.

<table className="w-full table-fixed border-collapse font-mono text-xs">
<tbody>
{lines.map((line, idx) => {
const lineComments = comments.filter(({ comment }) => commentBelongsToLine(comment, line) && commentMatchesFilters(comment, filters));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The commentBelongsToLine function now accepts lines as an argument.

Suggestions:

  1. Ensure commentBelongsToLine correctly handles all edge cases with the added lines context, especially for comments spanning multiple lines or at hunk boundaries.


useEffect(() => clearTimers, [clearTimers]);

const isActive = vote === 'up' || vote === 'down';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The postFeedback useCallback has been removed.

Suggestions:

  1. Ensure feedback submission logic is correctly reimplemented elsewhere or confirm it's no longer needed. Missing this could lead to silent failures in feedback collection.

}
}, [feedbackId]);

const calculatePos = (wrapperEl: HTMLElement, popupEl?: HTMLElement | null) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

A new calculatePos function has been introduced for handling popup positioning.

Suggestions:

  1. Consider adding comments to calculatePos to explain the positioning logic, especially viewport edge handling.

setPopupVisible(false);
setPopupMode(null);
setStatsExpanded(false);
}, 280);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

The setStatsExpanded(false) call has been removed. Please ensure that this state is managed elsewhere if it is still required.

Suggestions:

  1. Confirm setStatsExpanded is no longer required or is handled by a different mechanism.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants