v2.0.10 — snappier boot + non-blocking relay push on save
DB - Six indices on the cards table (position+created_at, bucket_id, parent_id, updated_at, caldav_uid, relay_uid). The /api/cards ORDER BY position was a full-table scan + sort on every board reload; now it's an index walk. - PRAGMA synchronous=NORMAL (safe under WAL) + temp_store=MEMORY. Save path - pushCardToRelayRead and deleteCardFromRelayRead now run AFTER the response is flushed via fastcgi_finish_request (falls back to ignore_user_abort + flush on non-FPM setups). The user's save no longer blocks on the relay round-trip. - cURL timeouts dropped from 10s/30s to 5s/10s, plus a 2s connect-timeout. Worst-case on a dead relay goes from ~160s down to ~15s — and now that worst case is in the background anyway. Client - loadCards phase-2 batches bumped from 25 to 500 cards per fetch. For 300 cards that's 1 HTTP trip instead of 12 — spinner wall time drops from ~3s to ~0.25s. - renderBoard() is now requestAnimationFrame-coalesced. The boot sequence calls renderBoard from cache paint, meta paint, body paint, and the cards.reloaded plugin hook; they used to each trigger an innerHTML swap + layout pass. Now they collapse into one actual render per frame. Callers that really need a sync render (rare) can use the new renderBoardSync(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
87ebcd156b
commit
2d58cd993e
7 files changed
+124
-28
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '2.0.9');
|
define('APP_VERSION', '2.0.10');
|
||||||
|
|
||||||
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
|
||||||
// Off by default; enable via env for local troubleshooting only.
|
// Off by default; enable via env for local troubleshooting only.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "2.0.9",
|
"version": "2.0.10",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+54
-21
@@ -312,13 +312,19 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
'body' => hyprcrdRelayEncrypt($body, $relayKey),
|
'body' => hyprcrdRelayEncrypt($body, $relayKey),
|
||||||
'hero_filename' => $heroFilename,
|
'hero_filename' => $heroFilename,
|
||||||
]);
|
]);
|
||||||
|
// Aggressive timeouts: read-later is opt-in + best-effort. If the
|
||||||
|
// relay is slow or unreachable we must NOT block the user's save —
|
||||||
|
// this function still runs inline from the PATCH handler (we'll
|
||||||
|
// also flush the response before invoking it), so the shorter
|
||||||
|
// timeout caps the worst-case backlog even if flushing fails.
|
||||||
$ch = curl_init(rtrim($relayUrl, '/') . '/api/read');
|
$ch = curl_init(rtrim($relayUrl, '/') . '/api/read');
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
]);
|
]);
|
||||||
hyprcrdApplySafeCurlOpts($ch);
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
$resp = curl_exec($ch);
|
$resp = curl_exec($ch);
|
||||||
@@ -348,7 +354,8 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
CURLOPT_POSTFIELDS => $attPayload,
|
CURLOPT_POSTFIELDS => $attPayload,
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 30,
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
]);
|
]);
|
||||||
hyprcrdApplySafeCurlOpts($ach);
|
hyprcrdApplySafeCurlOpts($ach);
|
||||||
curl_exec($ach);
|
curl_exec($ach);
|
||||||
@@ -367,7 +374,8 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
CURLOPT_CUSTOMREQUEST => 'DELETE',
|
||||||
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
|
||||||
CURLOPT_TIMEOUT => 10,
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
]);
|
]);
|
||||||
hyprcrdApplySafeCurlOpts($ch);
|
hyprcrdApplySafeCurlOpts($ch);
|
||||||
curl_exec($ch);
|
curl_exec($ch);
|
||||||
@@ -397,13 +405,21 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
'read_push' => $readPush,
|
'read_push' => $readPush,
|
||||||
]);
|
]);
|
||||||
$saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch();
|
$saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch();
|
||||||
if ($readPush && $saved) {
|
// When the card opts into read-later, generate the relay_uid up
|
||||||
pushCardToRelayRead($saved, $db);
|
// front so the response the client receives already carries it.
|
||||||
// relay_uid may have been generated inside the push helper;
|
// Actually pushing to the relay happens AFTER the response is
|
||||||
// re-read so the client sees it on the response.
|
// flushed — the relay hop is best-effort and shouldn't stretch
|
||||||
$saved = $db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . (int)$saved['id'])->fetch();
|
// the user-facing save latency.
|
||||||
|
if ($readPush && $saved && empty($saved['relay_uid'])) {
|
||||||
|
$uid = bin2hex(random_bytes(16));
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $saved['id']]);
|
||||||
|
$saved['relay_uid'] = $uid;
|
||||||
}
|
}
|
||||||
echo json_encode($saved);
|
echo json_encode($saved);
|
||||||
|
if ($readPush && $saved) {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
pushCardToRelayRead($saved, $db);
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,11 +452,15 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($cardRow && !empty($cardRow['caldav_uid'])) {
|
if ($cardRow && !empty($cardRow['caldav_uid'])) {
|
||||||
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$cardRow['caldav_uid'], $m[1]]);
|
$db->prepare('INSERT OR REPLACE INTO caldav_tombstones (caldav_uid, card_id) VALUES (?, ?)')->execute([$cardRow['caldav_uid'], $m[1]]);
|
||||||
}
|
}
|
||||||
if ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid'])) {
|
$wipeFromRelay = ($cardRow && (int)($cardRow['read_push'] ?? 0) === 1 && !empty($cardRow['relay_uid']))
|
||||||
deleteCardFromRelayRead((string)$cardRow['relay_uid'], $db);
|
? (string)$cardRow['relay_uid'] : null;
|
||||||
}
|
|
||||||
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
|
$db->prepare('DELETE FROM cards WHERE id = :id')->execute(['id' => $m[1]]);
|
||||||
echo json_encode(['ok' => true]); exit;
|
echo json_encode(['ok' => true]);
|
||||||
|
if ($wipeFromRelay) {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
deleteCardFromRelayRead($wipeFromRelay, $db);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
|
if (preg_match('#^/api/cards/(\d+)/history$#', $route, $m) && $method === 'GET') {
|
||||||
@@ -491,21 +511,34 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
|
if ($fields) { $fields[] = 'updated_at = datetime("now")'; $db->prepare('UPDATE cards SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); }
|
||||||
$card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id');
|
$card = $db->prepare('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = :id');
|
||||||
$card->execute(['id' => $m[1]]); $savedCard = $card->fetch();
|
$card->execute(['id' => $m[1]]); $savedCard = $card->fetch();
|
||||||
// Relay sync:
|
// Decide the relay-sync action BEFORE echoing the response, then
|
||||||
// - if card currently wants to be in the read list, push (idempotent
|
// flush + run the actual cURL hop afterwards. The user doesn't
|
||||||
// upsert keeps the remote copy in sync on every edit).
|
// wait for the relay round-trip; a dead/slow relay no longer
|
||||||
// - if the user just toggled it off, clean up remotely.
|
// stretches the card save into "eine Ewigkeit".
|
||||||
|
$pushAction = null; // 'push' | 'delete' | null
|
||||||
if ($savedCard) {
|
if ($savedCard) {
|
||||||
$nowRead = (int)($savedCard['read_push'] ?? 0);
|
$nowRead = (int)($savedCard['read_push'] ?? 0);
|
||||||
if ($nowRead === 1) {
|
if ($nowRead === 1) {
|
||||||
pushCardToRelayRead($savedCard, $db);
|
// Mint relay_uid up front so the response carries it.
|
||||||
// Re-read to surface any newly-generated relay_uid on the wire.
|
if (empty($savedCard['relay_uid'])) {
|
||||||
$card->execute(['id' => $m[1]]); $savedCard = $card->fetch();
|
$uid = bin2hex(random_bytes(16));
|
||||||
|
$db->prepare('UPDATE cards SET relay_uid = ? WHERE id = ?')->execute([$uid, $savedCard['id']]);
|
||||||
|
$savedCard['relay_uid'] = $uid;
|
||||||
|
}
|
||||||
|
$pushAction = 'push';
|
||||||
} elseif ($prevRead === 1 && $prevRelayUid) {
|
} elseif ($prevRead === 1 && $prevRelayUid) {
|
||||||
deleteCardFromRelayRead($prevRelayUid, $db);
|
$pushAction = 'delete';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
echo json_encode($savedCard); exit;
|
echo json_encode($savedCard);
|
||||||
|
if ($pushAction === 'push') {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
pushCardToRelayRead($savedCard, $db);
|
||||||
|
} elseif ($pushAction === 'delete') {
|
||||||
|
hyprcrdFinishRequest();
|
||||||
|
deleteCardFromRelayRead((string)$prevRelayUid, $db);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
|
|||||||
+8
-5
@@ -108,11 +108,14 @@ async function loadCards() {
|
|||||||
updateSortUI();
|
updateSortUI();
|
||||||
renderBoard();
|
renderBoard();
|
||||||
|
|
||||||
// Phase 2: Load full cards in batches → replace spinners with content
|
// Phase 2: Load full card bodies. One fetch up to the ID cap — with
|
||||||
const batchSize = 25;
|
// the /api/cards(?ids=) endpoint now indexed on cards.position +
|
||||||
for (let i = 0; i < meta.length; i += batchSize) {
|
// created_at a single big batch is faster than many small ones, and
|
||||||
const batch = meta.slice(i, i + batchSize);
|
// shorter perceived spinner time matters more than memory. Fall back
|
||||||
const ids = batch.map(c => c.id).join(',');
|
// to smaller batches only if the URL would blow past ~2000 chars.
|
||||||
|
const IDS_MAX_PER_FETCH = 500;
|
||||||
|
for (let i = 0; i < meta.length; i += IDS_MAX_PER_FETCH) {
|
||||||
|
const ids = meta.slice(i, i + IDS_MAX_PER_FETCH).map(c => c.id).join(',');
|
||||||
const full = await api('api/cards?ids=' + ids);
|
const full = await api('api/cards?ids=' + ids);
|
||||||
if (!full) continue;
|
if (!full) continue;
|
||||||
for (const card of full) {
|
for (const card of full) {
|
||||||
|
|||||||
@@ -493,8 +493,30 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
//
|
||||||
|
// Public renderBoard() is coalesced via requestAnimationFrame: multiple
|
||||||
|
// callers in the same tick (cache paint → meta paint → body paint →
|
||||||
|
// plugin-hook re-render → ...) collapse into a single actual render on
|
||||||
|
// the next frame. Dropped the "three innerHTML swaps per reload" cost
|
||||||
|
// that showed up as the "Gekringel" during boot. Callers that need the
|
||||||
|
// DOM in a specific state before doing something else can call
|
||||||
|
// renderBoardSync() explicitly — but in practice every existing caller
|
||||||
|
// just wants the board to reflect the latest state.
|
||||||
|
|
||||||
|
let rafScheduled = 0;
|
||||||
export function renderBoard() {
|
export function renderBoard() {
|
||||||
|
if (rafScheduled) return;
|
||||||
|
rafScheduled = requestAnimationFrame(() => {
|
||||||
|
rafScheduled = 0;
|
||||||
|
renderBoardSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function renderBoardSync() {
|
||||||
|
if (rafScheduled) { cancelAnimationFrame(rafScheduled); rafScheduled = 0; }
|
||||||
|
renderBoardImpl();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoardImpl() {
|
||||||
// Diff state.cards against the last seen ids so we can flag arrivals
|
// Diff state.cards against the last seen ids so we can flag arrivals
|
||||||
// as "unseen". The very first renderBoard runs with state.cards = []
|
// as "unseen". The very first renderBoard runs with state.cards = []
|
||||||
// (initial empty state, before the API has returned), so we wait
|
// (initial empty state, before the API has returned), so we wait
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ class Database
|
|||||||
|
|
||||||
self::$pdo->exec('PRAGMA journal_mode = WAL');
|
self::$pdo->exec('PRAGMA journal_mode = WAL');
|
||||||
self::$pdo->exec('PRAGMA foreign_keys = ON');
|
self::$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
// Safe speedups under WAL. synchronous=NORMAL trades a tiny
|
||||||
|
// durability window on power-loss (WAL checkpoint hasn't
|
||||||
|
// flushed) for noticeably quicker write-heavy flows — fine for
|
||||||
|
// a single-user local-first app. temp_store=MEMORY keeps
|
||||||
|
// transient sort / group scratch in RAM instead of touching
|
||||||
|
// disk for every ORDER BY.
|
||||||
|
self::$pdo->exec('PRAGMA synchronous = NORMAL');
|
||||||
|
self::$pdo->exec('PRAGMA temp_store = MEMORY');
|
||||||
|
|
||||||
self::migrate();
|
self::migrate();
|
||||||
}
|
}
|
||||||
@@ -188,5 +196,17 @@ class Database
|
|||||||
)
|
)
|
||||||
');
|
');
|
||||||
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_ep ON login_attempts(ip, endpoint, attempted_at)');
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_ep ON login_attempts(ip, endpoint, attempted_at)');
|
||||||
|
|
||||||
|
// Hot-path indices for the board. /api/cards is called on every
|
||||||
|
// reload and runs `SELECT … FROM cards LEFT JOIN buckets ORDER BY
|
||||||
|
// position, created_at`. Without an index on position this is a
|
||||||
|
// full-table sort on every call — fine at 10 cards, noticeable at
|
||||||
|
// 300+.
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(position, created_at)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_bucket_id ON cards(bucket_id)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_parent_id ON cards(parent_id)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_updated_at ON cards(updated_at DESC)');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_caldav_uid ON cards(caldav_uid) WHERE caldav_uid IS NOT NULL');
|
||||||
|
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_cards_relay_uid ON cards(relay_uid) WHERE relay_uid IS NOT NULL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,24 @@ function hyprcrdRelayDecrypt(string $envelope, string $secret): string {
|
|||||||
return $envelope;
|
return $envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalise the HTTP response (headers + body already echoed) and let
|
||||||
|
* PHP continue working in the background without blocking the client.
|
||||||
|
* Under php-fpm this uses fastcgi_finish_request. Under mod_php / the
|
||||||
|
* built-in dev server we fall back to ignore_user_abort + flush — the
|
||||||
|
* client may still see the connection hold open, but at least it
|
||||||
|
* doesn't error on disconnect and the response is already buffered.
|
||||||
|
*/
|
||||||
|
function hyprcrdFinishRequest(): void {
|
||||||
|
if (function_exists('fastcgi_finish_request')) {
|
||||||
|
fastcgi_finish_request();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
if (ob_get_level() > 0) { @ob_end_flush(); }
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
|
||||||
function hyprcrdUrlHostIsPublic(string $url): bool {
|
function hyprcrdUrlHostIsPublic(string $url): bool {
|
||||||
$host = parse_url($url, PHP_URL_HOST);
|
$host = parse_url($url, PHP_URL_HOST);
|
||||||
if (!$host) return false;
|
if (!$host) return false;
|
||||||
|
|||||||
Reference in new issue
Block a user