From e5460feea5b5e273d76813662d1b2e0a51e7dc85 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 27 Aug 2026 14:51:07 -0400 Subject: [PATCH] fix: expire unapplied workspace cleanup plans --- inc/Storage/CleanupRunRepository.php | 49 +++++++++++ inc/Tasks/WorkspaceRetentionCleanupTask.php | 62 +++++++++++++ tests/cleanup-plan-expiry.php | 98 +++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 tests/cleanup-plan-expiry.php diff --git a/inc/Storage/CleanupRunRepository.php b/inc/Storage/CleanupRunRepository.php index af3d62b8..5059b568 100644 --- a/inc/Storage/CleanupRunRepository.php +++ b/inc/Storage/CleanupRunRepository.php @@ -185,6 +185,55 @@ public function list_runs( array $filters = array() ): array { return array_map( fn( $row ) => $this->decode_run( (array) $row ), is_array( $rows ) ? $rows : array() ); } + /** + * Delete cleanup runs in a terminalless state that were never applied. + * + * A plan is persisted so an operator can review it and apply it later, so + * an unapplied plan is expected rather than broken. Nothing removed it + * afterwards, so plans accumulated for the life of the install. Expiry is + * bounded per pass and removes each run's items alongside it. + * + * @param string $status Run status to expire. + * @param string $cutoff Delete runs created strictly before this GMT datetime. + * @param int $limit Maximum runs to delete in one pass. + * @return array{runs:int,items:int} + */ + public function expire_runs( string $status, string $cutoff, int $limit = 500 ): array { + global $wpdb; + + $limit = max( 1, min( 5000, $limit ) ); + // phpcs:disable WordPress.DB.PreparedSQL -- Table names derive from $wpdb prefix; predicates are prepared. + $run_ids = $wpdb->get_col( + $wpdb->prepare( + 'SELECT run_id FROM ' . CleanupSchema::runs_table() . ' WHERE status = %s AND created_at < %s ORDER BY created_at ASC LIMIT %d', + $status, + $cutoff, + $limit + ) + ); + $run_ids = array_values( array_filter( array_map( 'strval', is_array( $run_ids ) ? $run_ids : array() ) ) ); + if ( array() === $run_ids ) { + return array( 'runs' => 0, 'items' => 0 ); + } + + $placeholders = implode( ', ', array_fill( 0, count( $run_ids ), '%s' ) ); + $items = (int) SqliteBusyRetry::run( + 'cleanup_run_expire_items', + fn() => $wpdb->query( + $wpdb->prepare( 'DELETE FROM ' . CleanupSchema::items_table() . ' WHERE run_id IN (' . $placeholders . ')', ...$run_ids ) + ) + ); + $runs = (int) SqliteBusyRetry::run( + 'cleanup_run_expire_runs', + fn() => $wpdb->query( + $wpdb->prepare( 'DELETE FROM ' . CleanupSchema::runs_table() . ' WHERE run_id IN (' . $placeholders . ')', ...$run_ids ) + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL + + return array( 'runs' => max( 0, $runs ), 'items' => max( 0, $items ) ); + } + /** * Fetch items for a run. * diff --git a/inc/Tasks/WorkspaceRetentionCleanupTask.php b/inc/Tasks/WorkspaceRetentionCleanupTask.php index 1704ff83..f6a68dd2 100644 --- a/inc/Tasks/WorkspaceRetentionCleanupTask.php +++ b/inc/Tasks/WorkspaceRetentionCleanupTask.php @@ -10,6 +10,7 @@ use DataMachine\Core\PluginSettings; use DataMachine\Engine\AI\System\Tasks\SystemTask; use DataMachine\Engine\Tasks\TaskScheduler; +use DataMachineCode\Storage\CleanupRunRepository; use DataMachineCode\Support\SystemTaskDrainability; use DataMachineCode\Workspace\Workspace; @@ -120,6 +121,8 @@ public function executeTask( int $jobId, array $params ): void { return; } + $result['expired_plans'] = $this->expire_unapplied_plans( empty( $opts['dry_run'] ) ); + $report = (array) ( $result['report'] ?? array() ); do_action( 'datamachine_log', @@ -142,6 +145,65 @@ public function executeTask( int $jobId, array $params ): void { $this->completeJob($jobId, $result); } + /** + * Expire cleanup plans that were persisted for review and never applied. + * + * Every plan or dry run persists a run so an operator can apply it later, + * and nothing removed those rows afterwards, so a busy install accumulates + * plans indefinitely. Applied and completed runs are untouched: only the + * unapplied planning state expires. + * + * @param bool $apply Whether to delete rather than report the candidates. + * @return array + */ + private function expire_unapplied_plans( bool $apply ): array { + /** + * Filter how long an unapplied cleanup plan is retained. + * + * @param int $days Retention window in days. + */ + $days = (int) apply_filters( 'datamachine_code_cleanup_plan_max_age_days', 7 ); + $days = max( 1, $days ); + + /** + * Filter how many unapplied plans one retention pass may expire. + * + * @param int $limit Maximum runs per pass. + */ + $limit = (int) apply_filters( 'datamachine_code_cleanup_plan_expiry_limit', 2000 ); + $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); + + if ( ! $apply ) { + return array( 'applied' => false, 'max_age_days' => $days, 'cutoff' => $cutoff ); + } + + $repository = new CleanupRunRepository(); + $expired = $repository->expire_runs( 'planned', $cutoff, $limit ); + + if ( $expired['runs'] > 0 ) { + do_action( + 'datamachine_log', + 'info', + sprintf( 'Workspace retention cleanup: expired %d unapplied cleanup plan(s).', $expired['runs'] ), + array( + 'task' => $this->getTaskType(), + 'runs' => $expired['runs'], + 'items' => $expired['items'], + 'max_age_days' => $days, + 'cutoff' => $cutoff, + ) + ); + } + + return array( + 'applied' => true, + 'max_age_days' => $days, + 'cutoff' => $cutoff, + 'runs' => $expired['runs'], + 'items' => $expired['items'], + ); + } + /** * Build reviewed plans and schedule cleanup chunks as child Data Machine jobs. * diff --git a/tests/cleanup-plan-expiry.php b/tests/cleanup-plan-expiry.php new file mode 100644 index 00000000..0aed4c48 --- /dev/null +++ b/tests/cleanup-plan-expiry.php @@ -0,0 +1,98 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): mixed { return $this->data; } + } + } + + /** Minimal wpdb capturing the statements the expiry pass issues. */ + final class ExpiryFakeWpdb { + public string $prefix = 'wp_'; + /** @var array}> */ + public array $queries = array(); + /** @var array */ + public array $selectable = array(); + public int $deleted_items = 0; + public int $deleted_runs = 0; + + public function prepare(string $sql, ...$values): array { + return array('sql' => $sql, 'values' => $values); + } + + /** @param array{sql:string,values:array} $prepared */ + public function get_col($prepared): array { + $this->queries[] = $prepared; + $limit = (int) end($prepared['values']); + return array_slice($this->selectable, 0, max(0, $limit)); + } + + /** @param array{sql:string,values:array} $prepared */ + public function query($prepared): int { + $this->queries[] = $prepared; + if (str_contains($prepared['sql'], 'cleanup_items')) { + $this->deleted_items = count($prepared['values']) * 2; + return $this->deleted_items; + } + $this->deleted_runs = count($prepared['values']); + return $this->deleted_runs; + } + } + + global $wpdb; + $wpdb = new ExpiryFakeWpdb(); + + require_once dirname(__DIR__) . '/inc/Storage/CleanupRunRepositoryInterface.php'; + require_once dirname(__DIR__) . '/inc/Storage/CleanupSchema.php'; + require_once dirname(__DIR__) . '/inc/Storage/CleanupRunRepository.php'; + + $repository = new DataMachineCode\Storage\CleanupRunRepository(); + $checks = array(); + + // Nothing eligible must not issue a delete. + $wpdb->selectable = array(); + $empty = $repository->expire_runs('planned', '2026-08-20 00:00:00', 100); + $checks['an empty candidate set deletes nothing'] = array('runs' => 0, 'items' => 0) === $empty + && 0 === $wpdb->deleted_runs; + + // Eligible plans delete their items and then the runs themselves. + $wpdb->queries = array(); + $wpdb->selectable = array('cleanup-run-a', 'cleanup-run-b', 'cleanup-run-c'); + $expired = $repository->expire_runs('planned', '2026-08-20 00:00:00', 100); + $statements = array_map(static fn(array $q): string => $q['sql'], $wpdb->queries); + + $checks['expiry reports the runs and items it removed'] = 3 === $expired['runs'] && 6 === $expired['items']; + $checks['candidates are selected by status, age, and bound'] = str_contains($statements[0] ?? '', 'WHERE status = %s AND created_at < %s') + && str_contains($statements[0] ?? '', 'ORDER BY created_at ASC LIMIT %d') + && array('planned', '2026-08-20 00:00:00', 100) === ($wpdb->queries[0]['values'] ?? array()); + $checks['items are removed before their runs'] = str_contains($statements[1] ?? '', 'cleanup_items') + && str_contains($statements[2] ?? '', 'cleanup_runs'); + $checks['deletes are scoped to the selected run ids'] = array('cleanup-run-a', 'cleanup-run-b', 'cleanup-run-c') === ($wpdb->queries[2]['values'] ?? array()); + + // The per-pass bound is clamped rather than trusted. + $wpdb->queries = array(); + $wpdb->selectable = array('cleanup-run-a'); + $repository->expire_runs('planned', '2026-08-20 00:00:00', 100000); + $checks['an oversized bound is clamped'] = 5000 === (int) end($wpdb->queries[0]['values']); + + $wpdb->queries = array(); + $repository->expire_runs('planned', '2026-08-20 00:00:00', 0); + $checks['a zero bound is raised to one'] = 1 === (int) end($wpdb->queries[0]['values']); + + $passed = ! in_array(false, $checks, true); + foreach ($checks as $description => $result) { + fwrite($passed ? STDOUT : STDERR, sprintf("%s: %s\n", $result ? 'PASS' : 'FAIL', $description)); + } + exit($passed ? 0 : 1); +}