Practical Integration

Building a Bulk Media Analysis Tool

⏱ 17 min

The previous lesson’s single-attachment function works fine for one image at a time. Pointed at a media library with thousands of entries, it needs to become something that survives a page timeout, doesn’t hammer a provider’s API faster than its rate limit allows, and can be safely interrupted and resumed rather than started over from attachment one every time something goes wrong. None of that is AI-specific, it’s the same batch-processing problem WordPress developers solve for any slow, per-item job run across a large data set. This lesson builds that processor, and is deliberately conservative about naming specific scheduling tools by exact behavior it hasn’t verified for this course.

What you'll learn in this lesson
Chunked processing with an offset
Working through a library in small batches instead of one giant loop.
Being rate-aware
Spacing requests out and backing off on failure rather than firing calls as fast as PHP allows.
Resumability
Storing progress so a batch can pick back up after an interruption instead of restarting.
Where scheduling fits
General patterns, WP-Cron and Action Scheduler, for running batches outside a single request.
Prerequisites

Lesson 5’s my_plugin_suggest_tags_for_attachment() function, this lesson wraps it in a batch runner rather than reimplementing the analysis step itself.

Step 1: Processing in small, bounded chunks

A single PHP request has a time limit, batch the work rather than looping over everything at once:

// File: my-plugin/class-bulk-media-analysis.php
function my_plugin_run_analysis_batch( int $batch_size = 5 ): int {
	$offset = (int) get_option( 'my_plugin_analysis_offset', 0 );

	$images = get_posts(
		array(
			'post_type'      => 'attachment',
			'post_mime_type' => 'image',
			'post_status'    => 'inherit',
			'posts_per_page' => $batch_size,
			'offset'         => $offset,
			'orderby'        => 'ID',
			'order'          => 'ASC',
			'meta_query'     => array(
				array( 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS' ),
			),
		)
	);

	if ( empty( $images ) ) {
		delete_option( 'my_plugin_analysis_offset' );
		return 0;
	}

	foreach ( $images as $attachment ) {
		my_plugin_suggest_tags_for_attachment( $attachment->ID );
	}

	update_option( 'my_plugin_analysis_offset', $offset + count( $images ) );

	return count( $images );
}

A small $batch_size, five to ten attachments, keeps each run well within a typical request’s execution time even accounting for network latency on every AI call. The stored offset means the next call picks up exactly where the last one left off, whether that next call happens a second later or a day later.

Step 2: Spacing calls out and backing off on failure

Firing every request in a batch back to back is the fastest way to hit a provider’s rate limit. A small delay between calls, and a real backoff when a call fails, keeps a bulk job from turning one transient error into a cascade of them:

// File: my-plugin/class-bulk-media-analysis.php
function my_plugin_suggest_tags_with_retry( int $attachment_id, int $max_attempts = 3 ): void {
	$attempt = 0;

	while ( $attempt < $max_attempts ) {
		try {
			my_plugin_suggest_tags_for_attachment( $attachment_id );
			return;
		} catch ( \Throwable $e ) {
			++$attempt;
			sleep( $attempt * 2 ); // 2s, then 4s, then 6s before giving up.
		}
	}

	update_option(
		'my_plugin_analysis_failures',
		array_merge(
			get_option( 'my_plugin_analysis_failures', array() ),
			array( $attachment_id )
		)
	);
}

Failures get logged to their own option rather than silently dropped, so a later pass can specifically retry just the attachments that failed instead of re-running the whole library.

sleep() inside a batch has real limits

Calling sleep() inside a web request ties up a PHP worker for that entire duration, fine for a handful of seconds in a small admin-triggered batch, not something to scale up to hundreds of items in one request. For genuinely large libraries, keep batch sizes small and rely on running the batch function repeatedly (Step 3) rather than adding longer sleeps inside a single run.

Step 3: Running the batch outside a single page load

A batch this size still needs to run more than once to get through a large library. Two well-established WordPress patterns fit here, and which one to reach for depends on scale:

Ways to run a repeating batch job
ApproachReasonable for
WP-Cron, scheduling my_plugin_run_analysis_batch() on a recurring eventSmaller libraries, sites without heavy traffic-dependent cron concerns.
Action Scheduler (bundled with WooCommerce and available as a standalone library), scheduling one action per batch and letting each completed batch queue the nextLarger libraries, or sites already running Action Scheduler for other jobs, since it gives you a real admin UI for inspecting pending, running, and failed batches.

A minimal WP-Cron wiring:

// File: my-plugin/class-bulk-media-analysis.php
add_action( 'my_plugin_run_analysis_batch_event', function () {
	my_plugin_run_analysis_batch( 5 );
} );

if ( ! wp_next_scheduled( 'my_plugin_run_analysis_batch_event' ) ) {
	wp_schedule_event( time(), 'hourly', 'my_plugin_run_analysis_batch_event' );
}
This lesson keeps the scheduling detail general on purpose

Both WP-Cron and Action Scheduler are real, well-documented WordPress patterns for running work outside a single request, but the exact scheduling behavior, interval tuning, and failure-handling details you’d want for a specific production job aren’t something this course verified in depth for a media-analysis use case specifically. Treat the wiring above as a starting structure, and consult each tool’s own documentation for the details your actual scale and hosting environment need, WP-Cron’s reliance on site traffic to fire scheduled events being the most common surprise for low-traffic sites.

Step 4: Checking overall progress

// File: my-plugin/class-bulk-media-analysis.php
function my_plugin_get_analysis_progress(): array {
	$total_untagged = ( new WP_Query( array(
		'post_type'      => 'attachment',
		'post_mime_type' => 'image',
		'posts_per_page' => 1,
		'meta_query'     => array(
			array( 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS' ),
		),
	) ) )->found_posts;

	return array(
		'remaining' => $total_untagged,
		'offset'    => (int) get_option( 'my_plugin_analysis_offset', 0 ),
		'failures'  => count( get_option( 'my_plugin_analysis_failures', array() ) ),
	);
}

Surfacing this on the review screen from the previous lesson gives whoever’s running the job a clear “X remaining, Y failed” view instead of an opaque background process they have to guess about.

Test it: run a batch manually

wp-env run cli wp eval '
$processed = my_plugin_run_analysis_batch( 3 );
echo "Processed: $processed\n";
print_r( my_plugin_get_analysis_progress() );
'

Run it a second time and confirm the offset advanced rather than reprocessing the same three attachments.

A deleted attachment mid-run shifts your offset

If attachments are deleted from the library between batch runs, an offset-based query can skip over items or process the same one twice. For a library that’s actively changing during a long-running bulk job, querying by a stored “last processed attachment ID” with a > comparison instead of a raw numeric offset is more robust, worth the extra complexity on a large, actively-managed library.

Recap

A bulk media analysis tool is a batch processor first and an AI feature second: small chunks bounded by a stored offset, spacing and retry logic to stay rate-aware, and either WP-Cron or Action Scheduler to run repeatedly outside a single request. The per-image analysis itself is unchanged from Lesson 5, this lesson is entirely about running that function safely at library scale rather than changing what it does.

Resources & further reading

← Auto-Tagging and Organizing the Media Library With AI Multimodal Prompts: Combining Text and Image Input →