Skip to content

Fix #155: Replace O(n) visit ID collision loop with atomic counter#165

Merged
parhumm merged 2 commits intodevelopmentfrom
fix/155-visit-id-performance
Mar 10, 2026
Merged

Fix #155: Replace O(n) visit ID collision loop with atomic counter#165
parhumm merged 2 commits intodevelopmentfrom
fix/155-visit-id-performance

Conversation

@ParhamTehrani
Copy link
Copy Markdown
Contributor

@ParhamTehrani ParhamTehrani commented Mar 10, 2026

Performance fix for visit ID generation that caused 503 errors under high traffic.
Close #155
Before: Query to information_schema.TABLES + unbounded do...while loop
After: Atomic increment via LAST_INSERT_ID() - always exactly 2 queries

  • Add VisitIdGenerator class with thread-safe atomic counter
  • Update Tracker.php, Session.php, Processor.php to use new generator
  • Add counter initialization on plugin activation/upgrade

Describe your changes

...

Submission Review Guidelines:

  • I have performed a self-review of my code
  • If it is a core feature, I have added thorough tests.
  • Will this be part of a product update? If yes, please write one phrase about this update.
  • I have reviewed my code for security best practices.
  • Following the above guidelines will result in quick merges and clear and detailed feedback when appropriate.
  • My code follows the style guidelines of this project
  • I have updated the change-log in CHANGELOG.md.

Type of change

  • Fix - Fixes an existing bug
  • Add - Adds functionality
  • Update - Update existing functionality
  • Dev - Development related task
  • Tweak - A minor adjustment to the codebase
  • Performance - Address performance issues
  • Enhancement - Improvement to existing functionality

Summary by CodeRabbit

  • Refactor
    • Replaced the previous database-driven visit ID allocation with an atomic, single-step generator to improve performance and eliminate collision checks.
  • Bug Fixes
    • Added a one-time counter initialization during environment setup to ensure reliable visit ID sequencing and reduce initialization races.

Performance fix for visit ID generation that caused 503 errors under high traffic.

Before: Query to information_schema.TABLES + unbounded do...while loop
After: Atomic increment via LAST_INSERT_ID() - always exactly 2 queries

- Add VisitIdGenerator class with thread-safe atomic counter
- Update Tracker.php, Session.php, Processor.php to use new generator
- Add counter initialization on plugin activation/upgrade
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Introduces a new atomic visit-ID system: a VisitIdGenerator class that uses an options-stored counter and LAST_INSERT_ID() increments. Tracker/Session/Processor delegate ID generation to it, and admin init sets up the counter after tables are created.

Changes

Cohort / File(s) Summary
New Visit ID Generator
src/Tracker/VisitIdGenerator.php
Adds a new class implementing atomic visit ID generation (LAST_INSERT_ID pattern), counter initialization, fallback generation, and utility/reset APIs.
Tracker Refactor (ID generation)
src/Tracker/Processor.php, src/Tracker/Session.php, src/Tracker/Tracker.php
Replaces AUTO_INCREMENT / MAX(visit_id) queries and collision loops with VisitIdGenerator::generateNextVisitId() and a time() fallback if non-positive. Simplifies visit_id assignment paths.
Initialization Setup
admin/index.php
Calls VisitIdGenerator::initializeCounter() during environment initialization after tables are created to establish the counter option.

Sequence Diagram

sequenceDiagram
    participant Tracker as Tracker (Processor/Session/Tracker)
    participant VIG as VisitIdGenerator
    participant DB as Options DB / slim_stats

    Tracker->>VIG: generateNextVisitId()
    VIG->>VIG: ensureCounterExists()
    VIG->>DB: read option `slimstat_visit_id_counter`
    alt option missing
        VIG->>DB: compute initial from slim_stats / add_option(...)
    end
    VIG->>DB: INSERT/UPDATE ... LAST_INSERT_ID(option_value + 1)
    DB-->>VIG: LAST_INSERT_ID() value
    alt ID > 0
        VIG-->>Tracker: return next visit_id
    else
        VIG->>VIG: fallbackGenerateVisitId()
        VIG-->>Tracker: return fallback ID
    end
    Tracker->>Tracker: assign visit_id to stat
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Poem

🐰 A tiny counter hums in place,
No more loops in frantic chase.
LAST_INSERT_ID gives numbers neat,
Hopping IDs — fast and fleet.
I nibble code and dance with glee.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix #155: Replace O(n) visit ID collision loop with atomic counter' directly and clearly summarizes the main change: replacing a performance-problematic O(n) collision detection loop with an atomic counter mechanism.
Docstring Coverage ✅ Passed Docstring coverage is 90.91% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/155-visit-id-performance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/Tracker/VisitIdGenerator.php (2)

64-77: Performance: ensureCounterExists() adds an extra query on every visit ID generation.

This method is called on every generateNextVisitId() invocation, adding a SELECT COUNT(*) query before the atomic update. This means the "exactly 2 queries" claim in the PHPDoc is actually 3 queries in practice (or 4+ if initialization is needed).

Consider caching the existence check with a static flag, or restructuring to let the UPDATE handle missing rows gracefully (e.g., the UPDATE will affect 0 rows if the option doesn't exist, which you could detect and then initialize).

Also, use Yoda condition with strict comparison per coding guidelines:

♻️ Proposed optimization
+    private static bool $counterVerified = false;
+
     public static function ensureCounterExists(): void
     {
+        if (self::$counterVerified) {
+            return;
+        }
+
         global $wpdb;
 
         // Check if option exists
         $exists = $wpdb->get_var($wpdb->prepare(
             "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name = %s",
             self::OPTION_NAME
         ));
 
-        if ($exists == 0) {
+        if (0 === (int) $exists) {
             self::initializeCounter();
         }
+
+        self::$counterVerified = true;
     }

As per coding guidelines: "Use Yoda Conditions: if ( 'value' === $var )"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Tracker/VisitIdGenerator.php` around lines 64 - 77, ensureCounterExists()
currently issues a SELECT COUNT(*) on every call (invoked by
generateNextVisitId()), adding an extra query; remove this redundant check by
either (A) caching the existence with a static/class-level boolean in
ensureCounterExists() so the SELECT runs only once per request, or (B) eliminate
the SELECT entirely and let the atomic UPDATE in generateNextVisitId() run
first, detect when affected rows === 0 and then call initializeCounter(); update
the affected-rows check to use a Yoda strict comparison (0 === $affected) per
coding guidelines and reference the functions ensureCounterExists,
generateNextVisitId, and initializeCounter when making the change.

1-17: Missing declare(strict_types=1) and GPL license header.

Per coding guidelines, new PHP files should include declare(strict_types=1) at the top and a GPL license header. Additionally, the constant should use explicit visibility for PHP 7.1+ compatibility.

📝 Proposed fix
 <?php
+/**
+ * Atomic Visit ID Generator for SlimStat
+ *
+ * `@package` SlimStat
+ * `@license` GPL-2.0-or-later
+ */
+
+declare(strict_types=1);
 
 namespace SlimStat\Tracker;
 
 /**
  * Atomic Visit ID Generator for SlimStat
  *
  * Uses MySQL's LAST_INSERT_ID() for atomic increment operations,
  * providing thread-safe visit ID generation without race conditions.
  *
  * Performance: Always exactly 2 queries (O(1)) instead of O(n) collision loop.
  *
  * `@since` 5.4.1
  */
 class VisitIdGenerator
 {
-    const OPTION_NAME = 'slimstat_visit_id_counter';
+    public const OPTION_NAME = 'slimstat_visit_id_counter';

As per coding guidelines: "Use declare(strict_types=1); at the top of files where appropriate" and "Add GPL license headers to all new PHP files".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Tracker/VisitIdGenerator.php` around lines 1 - 17, The file is missing a
GPL license header, the declare(strict_types=1) directive, and explicit
visibility on the class constant; update the VisitIdGenerator file to add the
appropriate GPL license header at the top, add declare(strict_types=1);
immediately after the opening <?php, and change const OPTION_NAME to an
explicitly visible constant (e.g., public const OPTION_NAME) inside the
VisitIdGenerator class so it is compliant with PHP 7.1+ and project coding
guidelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Tracker/VisitIdGenerator.php`:
- Around line 124-134: The fallbackGenerateVisitId() can produce duplicates
under concurrency; modify VisitIdGenerator::fallbackGenerateVisitId to combine
microtime(true) and mt_rand with additional entropy such as the process id
(getmypid()) and a short cryptographic nonce (e.g.
random_bytes/openssl_random_pseudo_bytes or random_int) to reduce collisions,
then fold/normalize the result to fit a 32-bit signed int range (retain the
existing max(..., (int) time()) behavior). Also emit a log entry when this
fallback path is used (call the project logger or error_log) so operators can
detect DB/atomic-counter failures; make sure the logging call is reachable from
fallbackGenerateVisitId and does not itself depend on the failed resource.

---

Nitpick comments:
In `@src/Tracker/VisitIdGenerator.php`:
- Around line 64-77: ensureCounterExists() currently issues a SELECT COUNT(*) on
every call (invoked by generateNextVisitId()), adding an extra query; remove
this redundant check by either (A) caching the existence with a
static/class-level boolean in ensureCounterExists() so the SELECT runs only once
per request, or (B) eliminate the SELECT entirely and let the atomic UPDATE in
generateNextVisitId() run first, detect when affected rows === 0 and then call
initializeCounter(); update the affected-rows check to use a Yoda strict
comparison (0 === $affected) per coding guidelines and reference the functions
ensureCounterExists, generateNextVisitId, and initializeCounter when making the
change.
- Around line 1-17: The file is missing a GPL license header, the
declare(strict_types=1) directive, and explicit visibility on the class
constant; update the VisitIdGenerator file to add the appropriate GPL license
header at the top, add declare(strict_types=1); immediately after the opening
<?php, and change const OPTION_NAME to an explicitly visible constant (e.g.,
public const OPTION_NAME) inside the VisitIdGenerator class so it is compliant
with PHP 7.1+ and project coding guidelines.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 28345ce0-7844-4165-b2b6-6e87da811d01

📥 Commits

Reviewing files that changed from the base of the PR and between eceb20c and f04495f.

📒 Files selected for processing (5)
  • admin/index.php
  • src/Tracker/Processor.php
  • src/Tracker/Session.php
  • src/Tracker/Tracker.php
  • src/Tracker/VisitIdGenerator.php

}

// Get the value that was set by LAST_INSERT_ID()
$visit_id = $wpdb->get_var("SELECT LAST_INSERT_ID()");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LAST_INSERT_ID() is connection-scoped, so this two-step sequence is only safe if both statements hit the same underlying MySQL handle. Slimstat already supports swapping the wpdb implementation via slimstat_custom_wpdb, and HyperDB/LudicrousDB-style routers commonly send writes and reads to different connections. In that setup the UPDATE can hit the writer while SELECT LAST_INSERT_ID() runs on a reader and returns 0 or an unrelated insert id, which would assign the wrong visit_id. This needs to be collapsed into a single statement/connection-safe primitive or guarded so split-db installs do not silently generate bad IDs.

self::OPTION_NAME
));

if ($result === false) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

$wpdb->query() returns 0 when the UPDATE matches no rows, not just false on SQL errors. If the counter option is missing here because add_option() failed, the row was deleted concurrently, or a stale cache/db split left ensureCounterExists() out of sync, this branch still falls through to SELECT LAST_INSERT_ID(). MySQL then reuses the connection's previous insert id instead of taking the fallback path, so we can assign a stale/wrong visit_id. Treat anything other than exactly one affected row as failure before reading LAST_INSERT_ID().

@parhumm
Copy link
Copy Markdown
Contributor

parhumm commented Mar 10, 2026

Human QA Checklist

Fresh Install

  • Activate plugin on a clean WordPress site
  • Verify slimstat_visit_id_counter option is created in wp_options
  • Visit the frontend — confirm a pageview is recorded with a valid visit_id
  • Visit again in the same session — confirm visit_id stays the same (session continuity)
  • Open a second browser/incognito — confirm a different visit_id is assigned

Upgrade Path

  • Install the previous version (5.4.x), generate some traffic (5+ pageviews)
  • Note the current max visit_id in wp_slim_stats
  • Upgrade to this PR branch
  • Verify slimstat_visit_id_counter is initialized to >= the old max visit_id
  • New visits get IDs higher than the old max (no ID reuse or gaps backward)

Concurrent Traffic (Core Test)

  • Use a load testing tool (e.g. ab -n 100 -c 10) or open 10+ tabs simultaneously
  • All requests complete without 503 errors
  • Every recorded row has a unique visit_id (no duplicates):
    SELECT visit_id, COUNT(*) c FROM wp_slim_stats GROUP BY visit_id HAVING c > 1;
  • Counter in wp_options incremented correctly (value = max visit_id in table)

Fallback Behavior

  • Temporarily rename the slimstat_visit_id_counter option row in DB
  • Visit the site — ensureCounterExists() should re-create it automatically
  • Pageview is still tracked with a valid visit_id

Session Continuity

  • Start a visit, navigate 3-4 pages — all pageviews share the same visit_id
  • Wait for session timeout (default 30 min or lower it for testing)
  • Visit again — a new visit_id is assigned

Admin & Reporting

  • Reports page loads correctly, showing visits with proper visit IDs
  • "Visits" vs "Pageviews" counts are consistent with expected behavior
  • No PHP warnings/errors in debug.log related to visit ID generation

Database

  • Verify no queries to information_schema.TABLES in the tracking path (enable query logging or use Query Monitor)
  • Confirm only 2 queries per visit ID generation: UPDATE ... LAST_INSERT_ID() + SELECT LAST_INSERT_ID()

Copy link
Copy Markdown
Contributor

@parhumm parhumm left a comment

Choose a reason for hiding this comment

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

Re-reviewed after commit a16bd1b. No additional blocking findings in the current diff from static review. Remaining gap is manual runtime/load verification against a WordPress install.

@parhumm parhumm merged commit 6680c5a into development Mar 10, 2026
1 check was pending
parhumm added a commit that referenced this pull request Mar 10, 2026
The optimized classmap was missing the new VisitIdGenerator class
from PR #165, causing a fatal "Class not found" error on every
AJAX tracking request. Regenerated with composer dump-autoload -o -a.
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.

2 participants