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
5 changes: 5 additions & 0 deletions data-machine.php
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ function datamachine_allow_json_upload( $mimes ) {
register_deactivation_hook( __FILE__, 'datamachine_deactivate_plugin' );

function datamachine_deactivate_plugin() {
// Unschedule recurring maintenance actions.
if ( function_exists( 'as_unschedule_all_actions' ) ) {
as_unschedule_all_actions( 'datamachine_cleanup_stale_claims', array(), 'datamachine-maintenance' );
as_unschedule_all_actions( 'datamachine_cleanup_failed_jobs', array(), 'datamachine-maintenance' );
}
}

/**
Expand Down
117 changes: 117 additions & 0 deletions inc/Cli/Commands/JobsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,123 @@ public function delete( array $args, array $assoc_args ): void {
}
}

/**
* Cleanup old jobs by status and age.
*
* Removes jobs matching a status that are older than a specified age.
* Useful for keeping the jobs table clean by purging stale failures,
* completed jobs, or other terminal statuses.
*
* ## OPTIONS
*
* [--older-than=<duration>]
* : Delete jobs older than this duration. Accepts days (e.g., 30d),
* weeks (e.g., 4w), or hours (e.g., 72h).
* ---
* default: 30d
* ---
*
* [--status=<status>]
* : Which job status to clean up. Uses prefix matching to catch
* compound statuses (e.g., "failed" matches "failed - timeout").
* ---
* default: failed
* ---
*
* [--dry-run]
* : Show what would be deleted without making changes.
*
* [--yes]
* : Skip confirmation prompt.
*
* ## EXAMPLES
*
* # Preview cleanup of failed jobs older than 30 days
* wp datamachine jobs cleanup --dry-run
*
* # Delete failed jobs older than 30 days
* wp datamachine jobs cleanup --yes
*
* # Delete failed jobs older than 2 weeks
* wp datamachine jobs cleanup --older-than=2w --yes
*
* # Delete completed jobs older than 90 days
* wp datamachine jobs cleanup --status=completed --older-than=90d --yes
*
* # Delete agent_skipped jobs older than 1 week
* wp datamachine jobs cleanup --status=agent_skipped --older-than=1w
*
* @subcommand cleanup
*/
public function cleanup( array $args, array $assoc_args ): void {
$duration_str = $assoc_args['older-than'] ?? '30d';
$status = $assoc_args['status'] ?? 'failed';
$dry_run = isset( $assoc_args['dry-run'] );
$skip_confirm = isset( $assoc_args['yes'] );

$days = $this->parseDurationToDays( $duration_str );
if ( null === $days ) {
WP_CLI::error( sprintf( 'Invalid duration format: "%s". Use format like 30d, 4w, or 72h.', $duration_str ) );
return;
}

$db_jobs = new Jobs();
$count = $db_jobs->count_old_jobs( $status, $days );

if ( 0 === $count ) {
WP_CLI::success( sprintf( 'No "%s" jobs older than %s found. Nothing to clean up.', $status, $duration_str ) );
return;
}

WP_CLI::log( sprintf( 'Found %d "%s" job(s) older than %s (%d days).', $count, $status, $duration_str, $days ) );

if ( $dry_run ) {
WP_CLI::success( sprintf( 'Dry run: %d job(s) would be deleted.', $count ) );
return;
}

if ( ! $skip_confirm ) {
WP_CLI::confirm( sprintf( 'Delete %d "%s" job(s) older than %s?', $count, $status, $duration_str ) );
}

$deleted = $db_jobs->delete_old_jobs( $status, $days );

if ( false === $deleted ) {
WP_CLI::error( 'Failed to delete jobs.' );
return;
}

WP_CLI::success( sprintf( 'Deleted %d "%s" job(s) older than %s.', $deleted, $status, $duration_str ) );
}

/**
* Parse a human-readable duration string to days.
*
* Supports formats: 30d (days), 4w (weeks), 72h (hours).
*
* @param string $duration Duration string.
* @return int|null Number of days, or null if invalid.
*/
private function parseDurationToDays( string $duration ): ?int {
if ( ! preg_match( '/^(\d+)(d|w|h)$/i', trim( $duration ), $matches ) ) {
return null;
}

$value = (int) $matches[1];
$unit = strtolower( $matches[2] );

if ( $value <= 0 ) {
return null;
}

return match ( $unit ) {
'd' => $value,
'w' => $value * 7,
'h' => max( 1, (int) ceil( $value / 24 ) ),
default => null,
};
}

/**
* Undo a completed job by reversing its recorded effects.
*
Expand Down
77 changes: 77 additions & 0 deletions inc/Core/ActionScheduler/JobsCleanup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php
/**
* Failed Jobs Cleanup
*
* Periodically removes stale failed jobs from the jobs table.
* Prevents indefinite accumulation of failed job records.
*
* @package DataMachine\Core\ActionScheduler
* @since 0.28.0
*/

namespace DataMachine\Core\ActionScheduler;

defined( 'ABSPATH' ) || exit;

/**
* Register the cleanup action handler.
*/
add_action(
'datamachine_cleanup_failed_jobs',
function () {
$db_jobs = new \DataMachine\Core\Database\Jobs\Jobs();

/**
* Filter the maximum age (in days) for failed jobs before cleanup.
*
* Jobs with a "failed" status (including compound statuses like
* "failed - timeout") older than this threshold will be deleted.
*
* @since 0.28.0
*
* @param int $max_age_days Maximum age in days. Default 30.
*/
$max_age_days = (int) apply_filters( 'datamachine_failed_jobs_max_age_days', 30 );

if ( $max_age_days < 1 ) {
$max_age_days = 30;
}

$deleted = $db_jobs->delete_old_jobs( 'failed', $max_age_days );

if ( false !== $deleted && $deleted > 0 ) {
do_action(
'datamachine_log',
'info',
'Scheduled cleanup: deleted old failed jobs',
array(
'jobs_deleted' => $deleted,
'max_age_days' => $max_age_days,
)
);
}
}
);

/**
* Schedule the cleanup job after Action Scheduler is initialized.
* Only runs in admin context to avoid database queries on frontend.
*/
add_action(
'action_scheduler_init',
function () {
if ( ! is_admin() ) {
return;
}

if ( ! as_next_scheduled_action( 'datamachine_cleanup_failed_jobs', array(), 'datamachine-maintenance' ) ) {
as_schedule_recurring_action(
time() + DAY_IN_SECONDS,
DAY_IN_SECONDS,
'datamachine_cleanup_failed_jobs',
array(),
'datamachine-maintenance'
);
}
}
);
26 changes: 26 additions & 0 deletions inc/Core/Database/Jobs/Jobs.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,32 @@ public function delete_jobs( array $criteria = array() ): int|false {
return $this->operations->delete_jobs( $criteria );
}

/**
* Delete old jobs by status and age.
*
* @since 0.28.0
*
* @param string $status_pattern Base status to match (e.g., 'failed').
* @param int $older_than_days Delete jobs older than this many days.
* @return int|false Number of deleted rows, or false on error.
*/
public function delete_old_jobs( string $status_pattern, int $older_than_days ): int|false {
return $this->operations->delete_old_jobs( $status_pattern, $older_than_days );
}

/**
* Count jobs matching a status pattern older than a given age.
*
* @since 0.28.0
*
* @param string $status_pattern Base status to match (e.g., 'failed').
* @param int $older_than_days Count jobs older than this many days.
* @return int Number of matching jobs.
*/
public function count_old_jobs( string $status_pattern, int $older_than_days ): int {
return $this->operations->count_old_jobs( $status_pattern, $older_than_days );
}

public function store_engine_data( int $job_id, array $data ): bool {
return $this->operations->store_engine_data( $job_id, $data );
}
Expand Down
77 changes: 77 additions & 0 deletions inc/Core/Database/Jobs/JobsOperations.php
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,83 @@ public function get_latest_jobs_by_flow_ids( array $flow_ids ): array {
return $jobs_by_flow;
}

/**
* Delete old jobs by status and age.
*
* Removes jobs matching the given status pattern that are older than
* the specified number of days. Uses LIKE matching to handle compound
* statuses (e.g., "failed - timeout").
*
* @since 0.28.0
*
* @param string $status_pattern Base status to match (e.g., 'failed'). Uses LIKE prefix matching.
* @param int $older_than_days Delete jobs older than this many days.
* @return int|false Number of deleted rows, or false on error.
*/
public function delete_old_jobs( string $status_pattern, int $older_than_days ): int|false {
if ( empty( $status_pattern ) || $older_than_days < 1 ) {
return false;
}

$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $older_than_days * DAY_IN_SECONDS ) );
$like_pattern = $this->wpdb->esc_like( $status_pattern ) . '%';

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $this->wpdb->query(
$this->wpdb->prepare(
'DELETE FROM %i WHERE status LIKE %s AND created_at < %s',
$this->table_name,
$like_pattern,
$cutoff_datetime
)
);

do_action(
'datamachine_log',
'info',
'Deleted old jobs',
array(
'status_pattern' => $status_pattern,
'older_than_days' => $older_than_days,
'cutoff_datetime' => $cutoff_datetime,
'jobs_deleted' => false !== $result ? $result : 0,
'success' => false !== $result,
)
);

return $result;
}

/**
* Count jobs matching a status pattern older than a given age.
*
* @since 0.28.0
*
* @param string $status_pattern Base status to match (e.g., 'failed').
* @param int $older_than_days Count jobs older than this many days.
* @return int Number of matching jobs.
*/
public function count_old_jobs( string $status_pattern, int $older_than_days ): int {
if ( empty( $status_pattern ) || $older_than_days < 1 ) {
return 0;
}

$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $older_than_days * DAY_IN_SECONDS ) );
$like_pattern = $this->wpdb->esc_like( $status_pattern ) . '%';

// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$count = $this->wpdb->get_var(
$this->wpdb->prepare(
'SELECT COUNT(*) FROM %i WHERE status LIKE %s AND created_at < %s',
$this->table_name,
$like_pattern,
$cutoff_datetime
)
);

return (int) $count;
}

/**
* Delete jobs by status criteria or all jobs.
*/
Expand Down
1 change: 1 addition & 0 deletions inc/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@
require_once __DIR__ . '/Core/Steps/AI/Directives/FlowMemoryFilesDirective.php';
require_once __DIR__ . '/Core/FilesRepository/FileCleanup.php';
require_once __DIR__ . '/Core/ActionScheduler/ClaimsCleanup.php';
require_once __DIR__ . '/Core/ActionScheduler/JobsCleanup.php';
require_once __DIR__ . '/Core/ActionScheduler/QueueTuning.php';