Every click ran three UPDATEs against the same short_links row plus one against
the owner's link_wallets row, all inside one transaction. Two consequences:
- A popular link serialised its entire traffic through a single row lock.
- Locking two rows per transaction meant concurrent clicks on different links of
the same owner could take them in opposite orders, so MySQL deadlocked.
Production logged 152 deadlocks, 146 of them on one row, surfacing as HTTP 500s to
real visitors. With a link now being handed to a very large audience, this is the
next thing that falls over, and it is independent of page weight.
Counters are now buffered and flushed instead of written inline:
- link_clicks stays the source of truth. It is insert-only, so it has no
contention no matter how popular a link gets.
- Increments accumulate in Redis hashes and drain via link:flush-click-counters,
scheduled every minute. The database sees one UPDATE per link per flush instead
of four per click.
- Draining reads and deletes each hash atomically, so a click landing mid-flush is
counted in that batch or the next, never dropped.
- --recount rebuilds totals from link_clicks after a Redis loss.
If the buffer is unavailable the recorder writes through to the database, keeping
today's behaviour (and today's contention) rather than losing counts. Nothing in
the click path may break the redirect the visitor actually came for, so the whole
recorder is wrapped and failures are logged.
Storage sits behind ClickCounterStore so the buffered path is covered by tests
rather than silently falling back to write-through when no Redis is present.
Also adds the (short_link_id, ip_hash, clicked_at) index. The unique-click check
filters on ip_hash but only (short_link_id, clicked_at) was indexed, so every
click scanned all of that link's rows in the dedupe window — cost growing with the
link's own popularity, the worst possible shape for a link going viral.
Tests: 11 new. The load-bearing one asserts the request path issues no UPDATE
against short_links or link_wallets at all.
Pre-existing suite failures go from 13 to 9; none of the remainder are related.
Co-Authored-By: Claude <noreply@anthropic.com>
Resolve countries from CDN headers first, then MaxMind GeoLite2 via visitor IP, with an artisan command to download and refresh the database.
Co-authored-by: Cursor <cursoragent@cursor.com>
Event pages like sales-webinar live on events.ladill.com, but ladl.link
only proxied to ladill.com/q/* — fix by retrying events.ladill.com when
the platform hub has no matching code.
Co-authored-by: Cursor <cursoragent@cursor.com>
Proxy transfer paths directly to transfer.ladill.com and stop following ladl.link redirects server-side so recipient links resolve.
Co-authored-by: Cursor <cursoragent@cursor.com>
Show wallet balance prominently in the mobile profile bottom sheet with refresh
on open; align session lifetime with the platform default.
Co-authored-by: Cursor <cursoragent@cursor.com>
Link Afia now resolves provider, model, and API keys from ladilldb.platform_settings like the monolith, with .env fallbacks for local dev.
Co-authored-by: Cursor <cursoragent@cursor.com>
Wire the chat route and Link-scoped system prompt, include the Afia panel in the main layout, and point the UI at link.afia.chat instead of the copied QR Plus route.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add Bitly-style branded domain support via Ladill Domains SSL API,
account analytics dashboard, settings page with default domain picker,
and fix SSO/dashboard issues from QR Plus template leftovers.
Co-authored-by: Cursor <cursoragent@cursor.com>
Management UI at link.ladill.com with GHS 0.05 per link wallet billing,
click analytics, and legacy fallback to ladill.com/q for QR ecosystem codes.
Co-authored-by: Cursor <cursoragent@cursor.com>