Architecture B: Bloom Gossip + Implicit Replication

- Tag bloom filter as primary peer discovery (2048 bits x 3 hashes)
- Filter table gossip for transitive peer discovery
- Scoped queries for on-demand tag lookup
- Liveness tracking + peer eviction (5 failures, 7-day TTL)
- Topic subscriptions filter content sync at both ends
- Two-theme CSS system (default minimal + kodama2)
- Status bar on all pages (topics, peers, filters)
- Bracketless tags, grouped moderation page, cleaner forms
- Match main site heading level (h1 -> h2)
This commit is contained in:
blankie 2026-06-06 01:19:40 +00:00
parent 96d26f3970
commit 987317b4ac
4 changed files with 875 additions and 164 deletions

View file

@ -1,8 +1,11 @@
import sqlite3
import os
import threading
import time
from datetime import datetime, timedelta
from tinyweb_forum.bloom import BloomFilter
FORUM_DB = "forum.db"
@ -88,6 +91,20 @@ class ForumDB:
" PRIMARY KEY (content_id, content_type)"
")"
)
db.execute(
"CREATE TABLE IF NOT EXISTS peer_filters ("
" peer_hash TEXT PRIMARY KEY,"
" bloom_bytes BLOB,"
" bloom_size INTEGER DEFAULT 2048,"
" bloom_hashes INTEGER DEFAULT 3,"
" tag_count INTEGER DEFAULT 0,"
" last_seen REAL NOT NULL"
")"
)
try:
db.execute("ALTER TABLE synced_instances ADD COLUMN consecutive_failures INTEGER DEFAULT 0")
except Exception:
pass
db.commit()
db.close()
@ -509,22 +526,190 @@ class ForumDB:
db = self.get_db()
try:
cutoff = (datetime.utcnow() - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
# Delete posts in old threads
db.execute(
"DELETE FROM posts WHERE thread_id IN "
"(SELECT id FROM threads WHERE updated_at < ?)",
(cutoff,),
)
# Delete orphaned posts (thread already deleted)
db.execute(
"DELETE FROM posts WHERE thread_id NOT IN (SELECT id FROM threads)"
)
# Delete old threads
db.execute("DELETE FROM threads WHERE updated_at < ?", (cutoff,))
# Clean up orphaned upvotes
db.execute(
"DELETE FROM upvotes WHERE thread_id NOT IN (SELECT id FROM threads)"
)
db.commit()
finally:
self.return_db(db)
def get_tag_cloud(self, limit=50):
db = self.get_db()
try:
rows = db.execute("SELECT tags FROM threads").fetchall()
counts = {}
for r in rows:
if r["tags"]:
for t in r["tags"].split(","):
tag = t.strip().lower()
if tag:
counts[tag] = counts.get(tag, 0) + 1
return sorted(counts.items(), key=lambda x: -x[1])[:limit]
finally:
self.return_db(db)
def get_threads_by_topics(self, topics, since="", limit=200):
db = self.get_db()
try:
params = []
where = []
if topics:
clauses = []
for t in topics:
clauses.append("t.tags LIKE ?")
params.append(f"%{t}%")
where.append("(" + " OR ".join(clauses) + ")")
if since:
where.append("t.updated_at > ?")
params.append(since)
where_clause = (" WHERE " + " AND ".join(where)) if where else ""
return db.execute(
"SELECT t.*, (SELECT count(*) FROM posts p WHERE p.thread_id = t.id) AS reply_count "
f"FROM threads t{where_clause} ORDER BY t.updated_at DESC LIMIT ?",
params + [limit],
).fetchall()
finally:
self.return_db(db)
def get_posts_by_thread_ids(self, thread_ids):
if not thread_ids:
return []
db = self.get_db()
try:
placeholders = ",".join("?" for _ in thread_ids)
return db.execute(
f"SELECT * FROM posts WHERE thread_id IN ({placeholders}) ORDER BY created_at ASC",
thread_ids,
).fetchall()
finally:
self.return_db(db)
def get_new_upvotes_since(self, since, thread_ids=None):
db = self.get_db()
try:
query = (
"SELECT thread_id FROM upvotes u "
"WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = u.thread_id AND t.updated_at > ?)"
)
params = [since]
if thread_ids:
placeholders = ",".join("?" for _ in thread_ids)
query += f" AND u.thread_id IN ({placeholders})"
params.extend(thread_ids)
return [r["thread_id"] for r in db.execute(query, params).fetchall()]
finally:
self.return_db(db)
# --- Filter Table (Architecture B: Bloom Gossip) ---
def store_peer_filter(self, peer_hash, bloom_bytes, bloom_size, bloom_hashes, tag_count):
db = self.get_db()
try:
db.execute(
"INSERT OR REPLACE INTO peer_filters "
"(peer_hash, bloom_bytes, bloom_size, bloom_hashes, tag_count, last_seen) "
"VALUES (?, ?, ?, ?, ?, ?)",
(peer_hash, bloom_bytes, bloom_size, bloom_hashes, tag_count, time.time()),
)
db.commit()
finally:
self.return_db(db)
def get_peer_filter(self, peer_hash):
db = self.get_db()
try:
row = db.execute(
"SELECT * FROM peer_filters WHERE peer_hash = ?", (peer_hash,)
).fetchone()
if row:
return dict(row)
return None
finally:
self.return_db(db)
def get_all_filters(self):
"""Return all stored peer filters (for filter table gossip)."""
db = self.get_db()
try:
return [dict(r) for r in db.execute(
"SELECT * FROM peer_filters ORDER BY last_seen DESC"
).fetchall()]
finally:
self.return_db(db)
def get_filtered_peers_by_tag(self, tag):
"""Return peer hashes whose bloom filter might contain the given tag."""
tag = tag.strip().lower()
db = self.get_db()
try:
matches = []
for r in db.execute("SELECT * FROM peer_filters").fetchall():
bf = BloomFilter.from_bytes(r["bloom_bytes"], r["bloom_size"], r["bloom_hashes"])
if bf.might_contain(tag):
matches.append(r["peer_hash"])
return matches
finally:
self.return_db(db)
def prune_peer_filters(self, max_age_days=7):
db = self.get_db()
try:
cutoff = time.time() - max_age_days * 86400
db.execute("DELETE FROM peer_filters WHERE last_seen < ?", (cutoff,))
db.commit()
finally:
self.return_db(db)
def get_peer_filter_count(self):
db = self.get_db()
try:
return db.execute("SELECT count(*) FROM peer_filters").fetchone()[0]
finally:
self.return_db(db)
# --- Peer Liveness ---
def record_sync_result(self, peer_hash, success):
db = self.get_db()
try:
existing = db.execute(
"SELECT consecutive_failures FROM synced_instances WHERE instance_hash = ?",
(peer_hash,),
).fetchone()
if existing is not None:
new_failures = 0 if success else (existing["consecutive_failures"] + 1)
db.execute(
"UPDATE synced_instances SET consecutive_failures = ? WHERE instance_hash = ?",
(new_failures, peer_hash),
)
if success:
db.execute(
"UPDATE synced_instances SET status = 'active' WHERE instance_hash = ?",
(peer_hash,),
)
db.commit()
finally:
self.return_db(db)
def get_dead_peers(self, max_failures=5):
"""Return list of peer hashes with too many consecutive failures."""
db = self.get_db()
try:
return [
r["instance_hash"] for r in db.execute(
"SELECT instance_hash FROM synced_instances "
"WHERE consecutive_failures >= ?",
(max_failures,),
).fetchall()
]
finally:
self.return_db(db)