v0.3.6: Active CDN replication, device roles, budgets, tombstones, engagement fix, DOS hardening

Active CDN replication:
- All devices proactively replicate recent posts (<72h, <2 replicas) to peers
- Target priority: desktops (300) > anchors (200) > phones (100) + cache_pressure
- ReplicationRequest/Response (0xE1/0xE2) wire messages
- 10-min cycle, 2-min initial delay, cap 20 posts per request
- Graceful with small networks (1 peer = 1 replica, 0 peers = silent skip)

Device roles & budgets:
- Intermittent (phone), Available (desktop), Persistent (anchor)
- Advertised in InitialExchange, stored per-peer
- Replication budget: phones 100MB/hr, desktops/anchors 200MB/hr
- Delivery budget: phones 1GB/hr, desktops 2GB/hr, anchors 1GB/hr
- Hourly auto-reset, enforcement on blob serving

Cache management:
- 1GB default cache limit, configurable in settings UI
- Eviction cycle activated (was implemented but never started)
- Share-link priority boost (+100 for 3+ downstream)
- Cache pressure score (0-255) for replication targeting

Engagement distribution fix:
- BlobHeader JSON rebuilt after BlobHeaderDiff ops
- Previously reactions/comments stored in tables but header stayed stale

Tombstone system:
- deleted_at column on reactions and comments
- Tombstones propagate through pull sync (additive merge respects timestamps)
- UI queries filter WHERE deleted_at IS NULL

Persistent notifications:
- seen_engagement and seen_messages tables replace in-memory Sets
- Only notify on genuinely unseen content, survives restarts

DOS hardening:
- BlobHeaderDiff fan-out: single batched task, max 10 concurrent via JoinSet
- Blob prefetch: cap 20 per cycle, newest first
- PostDownstreamRegister: cap 50 per sync
- Delivery budget enforcement on BlobRequest handler
- Pull preference: non-anchors first to preserve anchor delivery budget

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-20 21:00:28 -04:00
parent b7f2d369fa
commit a7e632de88
16 changed files with 1254 additions and 158 deletions

View file

@ -358,10 +358,6 @@ function toast(msg) {
}
// --- Notifications (Tauri plugin) ---
let _notifiedMessages = new Set();
let _notifiedReacts = new Set();
let _notifiedComments = new Set();
let _notifiedPosts = new Set();
let _notifReady = false;
async function maybeNotify(title, body, tag) {
try {
@ -635,40 +631,24 @@ async function loadFeed(force) {
const oldFp = _feedFingerprint;
_feedFingerprint = fp;
// Notify on new posts and engagement (skip first load)
// Notify on new posts and engagement (DB-backed seen tracking)
if (_notifReady && oldFp) {
try {
const notifPosts = await invoke('get_setting', { key: 'notif_posts' }).catch(() => null) || 'off';
const notifReacts = await invoke('get_setting', { key: 'notif_reacts' }).catch(() => null) || 'on';
for (const p of posts) {
// New post notifications
if (!p.isMe && notifPosts !== 'off' && !_notifiedPosts.has(p.id)) {
_notifiedPosts.add(p.id);
if (_notifiedPosts.size > posts.length) { // skip initial bulk
maybeNotify(`New post from ${p.authorName || p.author.slice(0,8)}`, (p.content || '').slice(0, 80), `post-${p.id}`);
}
if (!p.isMe) continue;
if (notifReacts === 'off') continue;
// Get DB-persisted seen counts
const seen = await invoke('get_seen_engagement', { postId: p.id }).catch(() => ({ seenReactCount: 0, seenCommentCount: 0 }));
const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0);
const totalComments = p.commentCount || 0;
if (totalReacts > seen.seenReactCount) {
const newReacts = totalReacts - seen.seenReactCount;
maybeNotify('New reactions on your post', `${newReacts} new reaction${newReacts > 1 ? 's' : ''}`, `react-${p.id}`);
}
// Reaction notifications on our posts
if (p.isMe && notifReacts !== 'off' && p.reactionCounts) {
for (const r of p.reactionCounts) {
const key = `${p.id}-${r.emoji}-${r.count}`;
if (!_notifiedReacts.has(key)) {
_notifiedReacts.add(key);
if (_notifiedReacts.size > 1) {
maybeNotify(`${r.emoji} on your post`, `${r.count} ${r.emoji} reactions`, `react-${key}`);
}
}
}
}
// Comment notifications on our posts
if (p.isMe && notifReacts !== 'off' && p.commentCount > 0) {
const key = `${p.id}-comments-${p.commentCount}`;
if (!_notifiedComments.has(key)) {
_notifiedComments.add(key);
if (_notifiedComments.size > 1) {
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
}
}
if (totalComments > seen.seenCommentCount) {
const newComments = totalComments - seen.seenCommentCount;
maybeNotify('New comment on your post', (p.content || '').slice(0, 40), `comment-${p.id}`);
}
}
} catch (_) {}
@ -730,6 +710,14 @@ async function loadMyPosts(force) {
}
}
}
// Mark all visible own posts' engagement as seen (DB-backed)
for (const p of mine) {
const totalReacts = (p.reactionCounts || []).reduce((sum, r) => sum + r.count, 0);
const totalComments = p.commentCount || 0;
if (totalReacts > 0 || totalComments > 0) {
invoke('mark_post_seen', { postId: p.id, reactCount: totalReacts, commentCount: totalComments }).catch(() => {});
}
}
} catch (e) {
myPostsList.innerHTML = `<p class="status-err">Error: ${e}</p>`;
}
@ -802,19 +790,18 @@ async function loadMessages(force) {
if (!force && fp === _messagesFingerprint) return;
_messagesFingerprint = fp;
// Notify on new incoming messages
// Notify on new incoming messages (DB-backed seen tracking)
try {
const notifMsg = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
if (notifMsg !== 'off') {
if (_notifReady && notifMsg !== 'off') {
for (const [partnerId, thread] of sortedThreads) {
const lastReadMs = await invoke('get_last_read_message', { partnerIdHex: partnerId }).catch(() => 0);
for (const p of thread.posts) {
if (p.isMe || _notifiedMessages.has(p.id)) continue;
_notifiedMessages.add(p.id);
if (_notifiedMessages.size > 1) { // skip first load
const name = thread.partnerName || partnerId.slice(0, 8);
const body = notifMsg === 'preview' ? (p.decryptedContent || '').slice(0, 100) : 'New message';
maybeNotify(`Message from ${name}`, body, `msg-${p.id}`);
}
if (p.isMe) continue;
if (p.timestampMs <= lastReadMs) continue;
const name = thread.partnerName || partnerId.slice(0, 8);
const body = notifMsg === 'preview' ? (p.decryptedContent || '').slice(0, 100) : 'New message';
maybeNotify(`Message from ${name}`, body, `msg-${p.id}`);
}
}
}
@ -897,6 +884,9 @@ async function loadMessages(force) {
const input = $('#popover-reply-input');
if (input) setTimeout(() => input.focus(), 100);
// Mark conversation as read (DB-backed)
invoke('mark_conversation_read', { partnerId }).catch(() => {});
// Mark incoming encrypted messages as "seen"
for (const p of thread.posts) {
if (!p.isMe && threadPostIds.includes(p.id)) {
@ -2364,7 +2354,7 @@ async function doSyncAll() {
if (currentTab === 'myposts') loadMyPosts(true);
if (currentTab === 'people') { loadFollows(); }
if (currentTab === 'messages') loadMessages(true);
if (currentTab === 'settings') { loadRedundancy(); loadPublicVisible(); if (diagnosticsInterval) loadAllDiagnostics(); }
if (currentTab === 'settings') { loadRedundancy(); loadPublicVisible(); loadCacheStats(); if (diagnosticsInterval) loadAllDiagnostics(); }
loadStats();
} catch (e) {
toast('Sync error: ' + e);
@ -2422,6 +2412,53 @@ async function loadPublicVisible() {
}
}
// --- Cache storage settings ---
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
}
async function loadCacheStats() {
try {
const stats = await invoke('get_cache_stats');
const display = $('#cache-stats-display');
const maxLabel = stats.maxBytes === 0 ? 'Unlimited' : formatBytes(stats.maxBytes);
const pct = stats.maxBytes > 0 ? ` (${(stats.usedBytes / stats.maxBytes * 100).toFixed(1)}%)` : '';
display.textContent = `${formatBytes(stats.usedBytes)} used of ${maxLabel}${pct}${stats.blobCount} blobs`;
} catch (e) {
console.error('loadCacheStats:', e);
}
}
async function loadCacheSizeSetting() {
try {
const val = await invoke('get_setting', { key: 'cache_size_bytes' });
if (val) {
const sel = $('#cache-size-select');
// Match the option value
for (const opt of sel.options) {
if (opt.value === val) { sel.value = val; break; }
}
}
await loadCacheStats();
} catch (e) {
console.error('loadCacheSizeSetting:', e);
}
}
$('#cache-size-select').addEventListener('change', async () => {
const value = $('#cache-size-select').value;
try {
await invoke('set_setting', { key: 'cache_size_bytes', value });
toast('Cache size updated — takes effect on next eviction cycle');
await loadCacheStats();
} catch (e) {
toast('Error saving cache size: ' + e);
}
});
// --- Circle profiles ---
async function loadCircleProfiles() {
const container = $('#circle-profiles-list');
@ -2659,7 +2696,7 @@ document.querySelectorAll('.tab').forEach(tab => {
if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading();
loadMessages(true); loadDmRecipientOptions();
}
if (target === 'settings') { loadRedundancy(); loadPublicVisible(); }
if (target === 'settings') { loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
});
});
});