Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions inc/Storage/CleanupRunRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
62 changes: 62 additions & 0 deletions inc/Tasks/WorkspaceRetentionCleanupTask.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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',
Expand All @@ -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<string,mixed>
*/
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.
*
Expand Down
98 changes: 98 additions & 0 deletions tests/cleanup-plan-expiry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php
/** Unapplied cleanup plans expire on a bounded, age-gated pass. */

declare(strict_types=1);

namespace {
if (! defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/fixtures/');
}

if (! class_exists('WP_Error')) {
class WP_Error {
public function __construct(private string $code = '', private string $message = '', private mixed $data = null) {}
public function get_error_code(): string { return $this->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<int,array{sql:string,values:array<int,mixed>}> */
public array $queries = array();
/** @var array<int,string> */
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<int,mixed>} $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<int,mixed>} $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);
}
Loading