diff --git a/data-machine.php b/data-machine.php index 76801a0a..77c64190 100644 --- a/data-machine.php +++ b/data-machine.php @@ -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' ); + } } /** diff --git a/inc/Cli/Commands/JobsCommand.php b/inc/Cli/Commands/JobsCommand.php index c820a961..01bd0ef2 100644 --- a/inc/Cli/Commands/JobsCommand.php +++ b/inc/Cli/Commands/JobsCommand.php @@ -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=] + * : Delete jobs older than this duration. Accepts days (e.g., 30d), + * weeks (e.g., 4w), or hours (e.g., 72h). + * --- + * default: 30d + * --- + * + * [--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. * diff --git a/inc/Core/ActionScheduler/JobsCleanup.php b/inc/Core/ActionScheduler/JobsCleanup.php new file mode 100644 index 00000000..b4bab9fc --- /dev/null +++ b/inc/Core/ActionScheduler/JobsCleanup.php @@ -0,0 +1,77 @@ +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' + ); + } + } +); diff --git a/inc/Core/Database/Jobs/Jobs.php b/inc/Core/Database/Jobs/Jobs.php index 664c4f7c..3f458074 100644 --- a/inc/Core/Database/Jobs/Jobs.php +++ b/inc/Core/Database/Jobs/Jobs.php @@ -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 ); } diff --git a/inc/Core/Database/Jobs/JobsOperations.php b/inc/Core/Database/Jobs/JobsOperations.php index 347702e6..d94b4c9c 100644 --- a/inc/Core/Database/Jobs/JobsOperations.php +++ b/inc/Core/Database/Jobs/JobsOperations.php @@ -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. */ diff --git a/inc/bootstrap.php b/inc/bootstrap.php index 0816a1d7..f935bbdc 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -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';