Skip to content

Add atomic cache-based mutex lock to prevent concurrent import cron execution#1198

Closed
Claude wants to merge 3 commits intomasterfrom
claude/add-transient-mutex-lock
Closed

Add atomic cache-based mutex lock to prevent concurrent import cron execution#1198
Claude wants to merge 3 commits intomasterfrom
claude/add-transient-mutex-lock

Conversation

@Claude
Copy link
Copy Markdown

@Claude Claude AI commented Mar 23, 2026

WordPress pseudo-cron can fire the same scheduled event multiple times during overlapping page loads. Without synchronization, concurrent execution of the same import job causes duplicate posts (TOCTOU race in duplicate detection) and resource waste.

Changes

Added per-job mutex locking in run_cron() at includes/admin/feedzy-rss-feeds-import.php:1545-1608:

Atomic lock acquisition

  • Uses wp_cache_add() for atomic lock acquisition (returns false if lock already exists)
  • Prevents race condition where two processes could both acquire the lock
  • Lock stored in feedzy_import_locks cache group with key feedzy_import_lock_{job_id}

Dynamic lock TTL

  • TTL calculated from feedzy_max_execution_time filter (default 500s) + 60s buffer
  • Prevents lock expiration while import is still running
  • Filterable via feedzy_import_lock_ttl hook for customization
  • Falls back to 300s (5 minutes) if max execution time is unlimited or not set

Lock cleanup

  • Release in finally block via wp_cache_delete() to guarantee cleanup on both success and error paths
  • Auto-expires based on dynamic TTL as safety net

Error handling

  • Catches Throwable instead of Exception to handle PHP 8+ TypeError/Error
  • Makes cron execution more resilient

Logging

  • Skipped jobs logged at INFO level with job title, ID, and skip reason
  • Uses non-translatable strings for internal logging

Example

foreach ( $feedzy_imports as $job ) {
    $lock_key = 'feedzy_import_lock_' . $job->ID;
    
    // Calculate dynamic TTL
    $max_execution_time = (int) apply_filters( 'feedzy_max_execution_time', 500 );
    if ( $max_execution_time <= 0 ) {
        $max_execution_time = 300;
    }
    $lock_ttl = (int) apply_filters( 'feedzy_import_lock_ttl', $max_execution_time + 60, $job );
    
    // Atomic lock acquisition
    if ( ! wp_cache_add( $lock_key, time(), 'feedzy_import_locks', $lock_ttl ) ) {
        // Log skip and continue
        continue;
    }
    
    try {
        $this->run_job( $job, $max );
        // ... existing logic
    } catch ( Throwable $e ) {
        // Handle all errors
    } finally {
        wp_cache_delete( $lock_key, 'feedzy_import_locks' );
    }
}

Behavior

  • Same job cannot run concurrently (atomic lock prevents race conditions)
  • Different jobs run in parallel (job-specific locks)
  • Lock always released via finally block
  • Dynamic TTL prevents lock expiration during long-running imports
  • Catches all PHP errors including TypeError for PHP 8+ compatibility

[!WARNING]

Original prompt

This section details on the original issue you should resolve

<filter_complete></filter_complete>

<issue_title>Add transient-based mutex lock to prevent concurrent import cron execution</issue_title>
<issue_description>## Problem

WordPress pseudo-cron can fire the same scheduled event multiple times if page loads overlap. Without a lock, the same import job can run concurrently, causing duplicate posts (TOCTOU race condition in duplicate detection) and wasted server resources.

Solution

import.php — In run_cron(), add a per-job transient lock:

  • Before each job: check get_transient('feedzy_import_lock_' . $job->ID), skip if active
  • Set transient with 10-minute TTL before running the job
  • Use finally { delete_transient($lock_key); } to ensure cleanup on both success and error
  • Log skipped jobs for debugging

Files Affected

  • includes/admin/feedzy-rss-feeds-import.php

Acceptance Criteria

  • Same import job cannot run concurrently
  • Lock is always released after job completes (success or error)
  • Skipped jobs are logged with job title and ID
  • Different import jobs can still run in parallel
  • Lock expires after 10 minutes even if cleanup fails (safety net)

Priority: High — prevents duplicate imported posts
Regression risk: Low — worst case a job is skipped once, retries on next cron cycle</issue_description>

Comments on the Issue (you are @claude[agent] in this section)

Original prompt

This section details on the original issue you should resolve

<issue_title>Add transient-based mutex lock to prevent concurrent import cron execution</issue_title>
<issue_description>## Problem

WordPress pseudo-cron can fire the same scheduled event multiple times if page loads overlap. Without a lock, the same import job can run concurrently, causing duplicate posts (TOCTOU race condition in duplicate detection) and wasted server resources.

Solution

import.php — In run_cron(), add a per-job transient lock:

  • Before each job: check get_transient('feedzy_import_lock_' . $job->ID), skip if active
  • Set transient with 10-minute TTL before running the job
  • Use finally { delete_transient($lock_key); } to ensure cleanup on both success and error
  • Log skipped jobs for debugging

Files Affected

  • includes/admin/feedzy-rss-feeds-import.php

Acceptance Criteria

  • Same import job cannot run concurrently
  • Lock is always released after job completes (success or error)
  • Skipped jobs are logged with job title and ID
  • Different import jobs can still run in parallel
  • Lock expires after 10 minutes even if cleanup fails (safety net)

Priority: High — prevents duplicate imported posts
Regression risk: Low — worst case a job is skipped once, retries on next cron cycle</issue_description>

Comments on the Issue (you are @claude[agent] in this section)

@Claude Claude AI changed the title [WIP] Add transient-based mutex lock to prevent concurrent import execution Add transient-based mutex lock to prevent concurrent import cron execution Mar 23, 2026
@Claude Claude AI requested a review from ineagu March 23, 2026 15:10
@ineagu ineagu requested a review from Copilot March 23, 2026 15:41
@pirate-bot
Copy link
Copy Markdown
Contributor

pirate-bot commented Mar 23, 2026

🌍 i18n String Review Report

📊 Summary

Category Count
➕ Added 1
➖ Removed 0
🔄 Changed 0
Total 1
➕ Added Strings (1) - Click to expand
String Location Words Suggested Match
Skipping job "%1$s" (ID: %2$d) - already run... includes/admin/feedzy-rss... 8 No close match
Total 8

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a per-import-job mutex in the import cron runner to reduce duplicate imports caused by overlapping WP-Cron executions.

Changes:

  • Introduces a per-job transient lock key (feedzy_import_lock_{job_id}) to skip already-running jobs.
  • Adds INFO logging when a job is skipped due to an existing lock.
  • Ensures lock cleanup via a finally block after each job run.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1562 to +1563
// Set lock with 10-minute TTL
set_transient( $lock_key, time(), 10 * MINUTE_IN_SECONDS );
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock TTL is hard-coded to 10 minutes. This can expire while an import is still running (run_job() increases max execution time via the feedzy_max_execution_time filter), which would allow a second cron execution to acquire the lock and run concurrently. Suggest basing the TTL on the configured max execution time (plus a buffer) and/or making it filterable so longer-running imports don’t lose the lock mid-run.

Suggested change
// Set lock with 10-minute TTL
set_transient( $lock_key, time(), 10 * MINUTE_IN_SECONDS );
// Determine lock TTL based on max execution time (plus buffer) and allow filtering.
$max_execution_time = (int) apply_filters( 'feedzy_max_execution_time', ini_get( 'max_execution_time' ) );
if ( $max_execution_time <= 0 ) {
$max_execution_time = 300; // Fallback to 5 minutes if unlimited or not set.
}
$lock_ttl = (int) apply_filters( 'feedzy_import_lock_ttl', $max_execution_time + 60, $job );
set_transient( $lock_key, time(), $lock_ttl );

Copilot uses AI. Check for mistakes.
Comment on lines +1598 to +1603
'error' => $e->getMessage(),
)
);
} finally {
// Always release the lock, even on error
delete_transient( $lock_key );
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block still only catches Exception; PHP Errors/TypeErrors (Throwable) during imports would bypass the catch/logging and can abort cron processing. Consider switching the catch to Throwable (and logging it) to make cron execution more resilient and failures visible.

Copilot uses AI. Check for mistakes.
Comment on lines +1549 to +1563
// Check if job is already running
if ( get_transient( $lock_key ) ) {
Feedzy_Rss_Feeds_Log::info(
// translators: %1$s is the import job title, %2$d is the job ID.
sprintf( __( 'Skipping job "%1$s" (ID: %2$d) - already running', 'feedzy-rss-feeds' ), $job->post_title, $job->ID ),
array(
'job_id' => $job->ID,
'reason' => 'concurrent_execution_prevented',
)
);
continue;
}

// Set lock with 10-minute TTL
set_transient( $lock_key, time(), 10 * MINUTE_IN_SECONDS );
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock acquisition uses a non-atomic check-then-set (get_transient() followed by set_transient()). Two overlapping requests can both see no transient and then both set it, so the same job can still run concurrently (the exact race this lock is meant to prevent). Consider switching to an atomic primitive for acquisition (e.g., add_option() with a unique option name, or a cache add/"add"-semantics call) and only proceed when acquisition succeeds.

Copilot uses AI. Check for mistakes.
@ineagu
Copy link
Copy Markdown
Contributor

ineagu commented Mar 23, 2026

@claude[agent] can you fix the errors and avoid creating strings?

…able, remove i18n string

Co-authored-by: ineagu <1849868+ineagu@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Codeinwp/feedzy-rss-feeds/sessions/0a2662a5-9f58-44a8-ad51-bd8821fe85d6
@Claude
Copy link
Copy Markdown
Author

Claude AI commented Mar 23, 2026

@claude[agent] can you fix the errors and avoid creating strings?

Fixed in dc5c7d1:

  1. Removed i18n string - Changed from translatable string to simple concatenation for internal logging
  2. Atomic lock acquisition - Replaced non-atomic get/set pattern with wp_cache_add() which returns false if lock exists
  3. Dynamic TTL - Lock duration now based on feedzy_max_execution_time filter (500s default) + 60s buffer, filterable via feedzy_import_lock_ttl
  4. Catch Throwable - Changed from Exception to Throwable for PHP 8+ TypeError/Error handling

@Claude Claude AI changed the title Add transient-based mutex lock to prevent concurrent import cron execution Add atomic cache-based mutex lock to prevent concurrent import cron execution Mar 23, 2026
@ineagu ineagu closed this Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add transient-based mutex lock to prevent concurrent import cron execution

4 participants