Fix #155: Replace O(n) visit ID collision loop with atomic counter#165
Fix #155: Replace O(n) visit ID collision loop with atomic counter#165parhumm merged 2 commits intodevelopmentfrom
Conversation
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
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughIntroduces 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
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 aSELECT 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: Missingdeclare(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
📒 Files selected for processing (5)
admin/index.phpsrc/Tracker/Processor.phpsrc/Tracker/Session.phpsrc/Tracker/Tracker.phpsrc/Tracker/VisitIdGenerator.php
src/Tracker/VisitIdGenerator.php
Outdated
| } | ||
|
|
||
| // Get the value that was set by LAST_INSERT_ID() | ||
| $visit_id = $wpdb->get_var("SELECT LAST_INSERT_ID()"); |
There was a problem hiding this comment.
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.
src/Tracker/VisitIdGenerator.php
Outdated
| self::OPTION_NAME | ||
| )); | ||
|
|
||
| if ($result === false) { |
There was a problem hiding this comment.
$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().
Human QA ChecklistFresh Install
Upgrade Path
Concurrent Traffic (Core Test)
Fallback Behavior
Session Continuity
Admin & Reporting
Database
|
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.
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
Describe your changes
...
Submission Review Guidelines:
CHANGELOG.md.Type of change
Summary by CodeRabbit