Restore

O Operator
Restore

Restore file to captured state

Every captured change carries its SHA-256-addressed content in BLOB_STORE. Restoring writes that content back to the original path — current file gets backed up to <path>.drift_restore_backup_<epoch> before overwrite.

What will change
Preview -- nothing has been written yet.
Target file/var/www/vhosts/3dshawn.com/site1/_purge.pl
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-12 00:02:19
Captured SHA35a296e95b1a8982c0aae27c5816d2e000e8f5af45dc2b15b0792b6528ec6050
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size8045 bytes
Current SHAd2622a1d705d
Same as captured?no -- restore will change it
What restore will change — live diff
Left column shows current on-disk content; right shows what restore will write. +5 additions, -46 deletions, 162 unchanged context lines.
11#!/usr/bin/perl
22#======================================================================
33# DriftSense -- auto-purge daemon
44#
55# Cron entry point (NOT a CGI). Reclaims disk in BLOB_STORE by deleting
66# blobs that are:
77#
88# * Older than the retention window (from config: retention_days,
99# default 90 days -- measured from BLOB_STORE.first_seen_at)
1010# * NOT referenced by any FILE_CHANGES row inside a Named Release's
1111# scope (portfolio-wide releases pin ALL blobs; scoped releases pin
1212# only their scope; explicit NAMED_RELEASE_PINS pin exact blobs)
1313# * NOT the current-latest capture for any active file monitor
1414# (we always keep the "most recent" state of every monitored file)
1515#
1616# Dry-run by default. Pass --live to actually delete. Every action
1717# (planned or performed) is written to PURGE_LOG for the audit trail.
1818#======================================================================
1919use strict;
2020use warnings;
2121use lib '/var/www/vhosts/3dshawn.com/site1';
2222use POSIX ();
2323use MODS::Config;
2424use MODS::DBConnect;
2525
2626$| = 1;
2727my $ts = POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime);
2828
2929my $live = grep { $_ eq '--live' } @ARGV;
3030my $cfg = MODS::Config->new;
3131my $db = MODS::DBConnect->new;
3232my $dbh = $db->db_connect or die "[$ts] cannot connect to DriftSense storage DB\n";
3333
3434my $retention_days = int($cfg->settings('retention_days') || 90);
3535if ($retention_days < 1) { $retention_days = 90; }
3636
3737# ---- Pin sets -------------------------------------------------------
3838
3939# Explicit pins (NAMED_RELEASE_PINS)
4040my %pinned_sha;
4141my @pin_rows = $db->db_readwrite_multiple($dbh, q~
4242 SELECT DISTINCT blob_sha FROM NAMED_RELEASE_PINS
4343 WHERE blob_sha IS NOT NULL
4444~, __FILE__, __LINE__);
4545$pinned_sha{$_->{blob_sha}} = 1 for @pin_rows;
4646
4747# Portfolio-wide named releases: pin ALL blobs referenced by any
4848# FILE_CHANGES up to the release's released_at moment.
4949my @portfolio_releases = $db->db_readwrite_multiple($dbh, q~
5050 SELECT named_release_id, UNIX_TIMESTAMP(released_at) AS rel_epoch
5151 FROM NAMED_RELEASES
5252 WHERE is_locked = 1
5353 AND scope_monitor_id IS NULL
5454 AND scope_server_id IS NULL
5555~, __FILE__, __LINE__);
5656foreach my $rel (@portfolio_releases) {
5757 my $ep = int($rel->{rel_epoch} || 0);
5858 next unless $ep;
5959 my @refs = $db->db_readwrite_multiple($dbh, qq~
6060 SELECT DISTINCT blob_sha FROM FILE_CHANGES
6161 WHERE blob_sha IS NOT NULL
6262 AND date_time <= FROM_UNIXTIME($ep)
6363 ~, __FILE__, __LINE__);
6464 $pinned_sha{$_->{blob_sha}} = 1 for @refs;
6565}
6666
6767# Scoped named releases: pin only blobs referenced by that scope
6868my @scoped_releases = $db->db_readwrite_multiple($dbh, q~
6969 SELECT named_release_id, scope_monitor_id, scope_server_id,
7070 UNIX_TIMESTAMP(released_at) AS rel_epoch
7171 FROM NAMED_RELEASES
7272 WHERE is_locked = 1
7373 AND (scope_monitor_id IS NOT NULL OR scope_server_id IS NOT NULL)
7474~, __FILE__, __LINE__);
7575foreach my $rel (@scoped_releases) {
7676 my $ep = int($rel->{rel_epoch} || 0);
7777 next unless $ep;
7878 my $where = "date_time <= FROM_UNIXTIME($ep) AND blob_sha IS NOT NULL";
7979 if ($rel->{scope_monitor_id}) {
8080 $where .= " AND file_monitor_list_id = " . int($rel->{scope_monitor_id});
8181 }
8282 if ($rel->{scope_server_id}) {
8383 $where .= " AND server_id = " . int($rel->{scope_server_id});
8484 }
8585 my @refs = $db->db_readwrite_multiple($dbh, qq~
8686 SELECT DISTINCT blob_sha FROM FILE_CHANGES WHERE $where
8787 ~, __FILE__, __LINE__);
8888 $pinned_sha{$_->{blob_sha}} = 1 for @refs;
8989}
9090
9191# Current-latest state per file per monitor: never purge these
9292my @latest_rows = $db->db_readwrite_multiple($dbh, q~
9393 SELECT DISTINCT fc.blob_sha
9494 FROM FILE_CHANGES fc
9595 JOIN (
9696 SELECT file_monitor_list_id, file_name, MAX(file_changes_id) AS mx
9797 FROM FILE_CHANGES
9898 WHERE blob_sha IS NOT NULL
9999 GROUP BY file_monitor_list_id, file_name
100100 ) l ON l.mx = fc.file_changes_id
101101 WHERE fc.blob_sha IS NOT NULL
102102~, __FILE__, __LINE__);
103103$pinned_sha{$_->{blob_sha}} = 1 for @latest_rows;
104
105# ---- Candidates: blobs older than the applicable retention window ---
106# Per-monitor: FILE_MONITOR_SETTINGS.retention_days overrides the global
107# default. Rule: a blob is eligible iff EVERY FILE_CHANGES row referring
108# to it is older than the retention_days of that row's monitor. If any
109# reference is within its monitor's retention, keep the blob.
110
111# Pre-compute per-monitor retention (map mid -> days)
112my %monitor_retention;
113foreach my $m ($db->db_readwrite_multiple($dbh, q~
114 SELECT file_monitor_list_id, retention_days FROM FILE_MONITOR_SETTINGS
115~, __FILE__, __LINE__)) {
116 $monitor_retention{$m->{file_monitor_list_id}} =
117 (defined $m->{retention_days} && $m->{retention_days} > 0)
118 ? int($m->{retention_days}) : $retention_days;
119}
120104
121# Fast path: pick blobs where the OLDEST reference's monitor retention
122# is already exceeded AND the NEWEST reference is also beyond the
123# per-monitor cutoff. Easier: iterate every blob, look at all its refs,
124# decide.
125my @candidates = $db->db_readwrite_multiple($dbh, q~
126 SELECT bs.blob_sha, bs.size_compressed, bs.size_uncompressed,
127 UNIX_TIMESTAMP(bs.first_seen_at) AS first_seen_epoch
128 FROM BLOB_STORE bs
105# ---- Candidates: blobs older than retention window, NOT pinned -----
106my @candidates = $db->db_readwrite_multiple($dbh, qq~
107 SELECT blob_sha, size_compressed, size_uncompressed
108 FROM BLOB_STORE
109 WHERE first_seen_at < DATE_SUB(NOW(), INTERVAL $retention_days DAY)
129110~, __FILE__, __LINE__);
130# Filter candidates by per-monitor retention: keep only blobs where ALL
131# referencing FILE_CHANGES.date_time are older than the reference's
132# monitor retention days.
133my @filtered;
134foreach my $b (@candidates) {
135 my $sha = $b->{blob_sha};
136 my $q_sha = $dbh->quote($sha);
137 my @refs = $db->db_readwrite_multiple($dbh, qq~
138 SELECT file_monitor_list_id AS mid, UNIX_TIMESTAMP(date_time) AS ts
139 FROM FILE_CHANGES WHERE blob_sha = $q_sha
140 ~, __FILE__, __LINE__);
141 next unless @refs; # unreferenced blobs already handled elsewhere
142 my $now = time;
143 my $eligible = 1;
144 for my $r (@refs) {
145 my $days_for = $monitor_retention{$r->{mid}} // $retention_days;
146 my $cutoff = $now - $days_for * 86400;
147 if (($r->{ts} // 0) > $cutoff) { $eligible = 0; last; }
148 }
149 push @filtered, $b if $eligible;
150}
151@candidates = @filtered;
152111
153112my $planned = 0;
154113my $deleted = 0;
155114my $bytes_freed = 0;
156115
157116foreach my $b (@candidates) {
158117 next if $pinned_sha{$b->{blob_sha}};
159118 my $sha = $b->{blob_sha};
160119 my $q_sha = $dbh->quote($sha);
161120 my $sz = int($b->{size_compressed} || 0);
162121 my $reason = "age > $retention_days d, no pin, not current-latest";
163122
164123 if ($live) {
165124 # Delete FILE_CHANGES rows referencing this blob first (cascading)
166125 # NO -- we KEEP the FILE_CHANGES metadata (name, timestamp) even
167126 # after purging content; just null out blob_sha so the diff
168127 # viewer shows "content purged" gracefully.
169128 $db->db_readwrite($dbh, qq~
170129 UPDATE FILE_CHANGES SET blob_sha = NULL
171130 WHERE blob_sha = $q_sha
172131 ~, __FILE__, __LINE__);
173132 $db->db_readwrite($dbh, qq~
174133 DELETE FROM BLOB_STORE WHERE blob_sha = $q_sha
175134 ~, __FILE__, __LINE__);
176135 $deleted++;
177136 $bytes_freed += $sz;
178137 $db->db_readwrite($dbh, qq~
179138 INSERT INTO PURGE_LOG (blob_sha, freed_bytes, reason, dry_run)
180139 VALUES ($q_sha, $sz, @{[ $dbh->quote($reason) ]}, 0)
181140 ~, __FILE__, __LINE__);
182141 } else {
183142 $db->db_readwrite($dbh, qq~
184143 INSERT INTO PURGE_LOG (blob_sha, freed_bytes, reason, dry_run)
185144 VALUES ($q_sha, $sz, @{[ $dbh->quote($reason) ]}, 1)
186145 ~, __FILE__, __LINE__);
187146 $planned++;
188147 }
189148}
190149
191150$db->db_disconnect($dbh);
192151
193152my $mode = $live ? 'LIVE' : 'dry-run';
194153if ($live) {
195154 print "[$ts] purge $mode: deleted $deleted blobs, freed " . _fmt_bytes($bytes_freed) . "\n"
196155 if $deleted;
197156} else {
198157 print "[$ts] purge $mode: $planned blobs eligible (retention=$retention_days d) -- pass --live to execute\n"
199158 if $planned;
200159}
201160exit 0;
202161
203162sub _fmt_bytes {
204163 my $n = shift;
205164 return "$n B" if $n < 1024;
206165 return sprintf('%.1f KB', $n/1024) if $n < 1024*1024;
207166 return sprintf('%.1f MB', $n/1024/1024);
208167}
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.
Cancel Logged to RESTORE_LOG regardless of outcome.