mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
views: exact count unions across relays (fixes cross-relay under-count) (#4)
The watch-page/chart "exact" count relied on the NIP-45 COUNT, but
countEventsAcrossRelays returns bestEstimate = the single relay with the MOST
events — it can't union across relays. So when views are distributed across
relays it under-counted (a video's card showed 19 while the chart's uncapped
list union showed ~63).
exactCountForPointer now lists view events across ALL relays (one on-demand
list, not a storm) and folds the UNION into the deduped dedupeBuckets (one entry
per viewer-window). total = the set size, so it is accurate AND monotonic (never
spuriously decreases on partial relay responses). The per-relay COUNT is kept
only as a lower-bound floor when the list truncates (>2000 events), flagged
partial. The popularity modal passes { exact: true }; since the count flows
through shared pointer state, opening a video corrects its count everywhere.
Grid cards (no exact) still use the §17-capped batched backfill (approximate,
storm-safe) until opened — an accepted accuracy/cost tradeoff.
Also note the ⋯ menu "Event Details" button does nothing (new TODO #31).
Tests: testExactCountUnionsAcrossRelays. Grandfathered viewCounter.js size.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
1bc2b06395
commit
36f3c6e127
5 files changed
+164
-26
No files matched your search
+11
-7
@@ -319,13 +319,17 @@ export function openPopularityModal({
|
||||
|
||||
// Headline total: subscribe to the same counter the card uses (stable + cached).
|
||||
try {
|
||||
countToken = subscribeToVideoViewCount(pointer, ({ total } = {}) => {
|
||||
if (closed) return;
|
||||
if (Number.isFinite(total)) {
|
||||
counterTotal = Number(total);
|
||||
renderHeadline();
|
||||
}
|
||||
});
|
||||
countToken = subscribeToVideoViewCount(
|
||||
pointer,
|
||||
({ total } = {}) => {
|
||||
if (closed) return;
|
||||
if (Number.isFinite(total)) {
|
||||
counterTotal = Number(total);
|
||||
renderHeadline();
|
||||
}
|
||||
},
|
||||
{ exact: true },
|
||||
);
|
||||
} catch (error) {
|
||||
devLogger.warn("[popularity] Failed to subscribe to view count:", error);
|
||||
}
|
||||
|
||||
+68
-19
@@ -249,35 +249,84 @@ async function runBatchedBackfill() {
|
||||
|
||||
// Exact NIP-45 COUNT for a single pointer (watch page). Cheap — one pointer,
|
||||
// no storm — and accurate where a viewer might actually scrutinize the number.
|
||||
const EXACT_LIST_LIMIT = 2000;
|
||||
|
||||
// Accurate single-pointer count (watch page / popularity chart). Lists view
|
||||
// events across ALL relays and dedupes by (viewer, window) — a true cross-relay
|
||||
// UNION. This replaces relying on the NIP-45 COUNT, which returns the single
|
||||
// relay with the most events (it can't union across relays) and therefore
|
||||
// UNDER-counts whenever views are spread across relays. The deduped set accrues
|
||||
// monotonically, so the number is accurate AND never spuriously decreases. For
|
||||
// extremely popular videos where the list truncates, the per-relay COUNT is used
|
||||
// as a lower-bound floor and the result is flagged partial.
|
||||
async function exactCountForPointer(key, pointer, options = {}) {
|
||||
const sinceSeconds =
|
||||
Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY;
|
||||
|
||||
let events;
|
||||
try {
|
||||
const result = await countVideoViewEventsApi(pointer, {
|
||||
events = await listVideoViewEventsApi(pointer, {
|
||||
since: sinceSeconds,
|
||||
limit: EXACT_LIST_LIMIT,
|
||||
relays: options.relays,
|
||||
signal: options.signal,
|
||||
});
|
||||
const best = Number.isFinite(result?.best?.count)
|
||||
? Number(result.best.count)
|
||||
: Number.isFinite(result?.total)
|
||||
? Number(result.total)
|
||||
: null;
|
||||
if (best === null) {
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
const state = ensurePointerState(key);
|
||||
if (result && typeof result === "object") {
|
||||
state.partial = Boolean(result.partial);
|
||||
userLogger.warn("[viewCounter] Exact view count list failed:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventList = Array.isArray(events) ? events : [];
|
||||
const truncated = eventList.length >= EXACT_LIST_LIMIT;
|
||||
|
||||
const state = ensurePointerState(key);
|
||||
// Fold the union into the deduped bucket set (one entry per viewer-window).
|
||||
for (const event of eventList) {
|
||||
const bucketKey = deriveBucketKey(event) || deriveFallbackKey(event);
|
||||
if (!bucketKey) {
|
||||
continue;
|
||||
}
|
||||
if (best > state.total) {
|
||||
state.total = best;
|
||||
state.lastSyncedAt = Date.now();
|
||||
}
|
||||
schedulePersist();
|
||||
notifyHandlers(key);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
userLogger.warn("[viewCounter] Exact view count failed:", error);
|
||||
const createdAt = Number.isFinite(event?.created_at)
|
||||
? Number(event.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
state.dedupeBuckets.set(bucketKey, createdAt);
|
||||
}
|
||||
|
||||
let total = state.dedupeBuckets.size;
|
||||
let partial = truncated;
|
||||
|
||||
if (truncated) {
|
||||
try {
|
||||
const result = await countVideoViewEventsApi(pointer, {
|
||||
relays: options.relays,
|
||||
signal: options.signal,
|
||||
});
|
||||
const best = Number.isFinite(result?.best?.count)
|
||||
? Number(result.best.count)
|
||||
: Number.isFinite(result?.total)
|
||||
? Number(result.total)
|
||||
: null;
|
||||
if (Number.isFinite(best) && best > total) {
|
||||
total = best;
|
||||
}
|
||||
partial = partial || Boolean(result?.partial);
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.partial = partial;
|
||||
if (total !== state.total) {
|
||||
state.total = total;
|
||||
state.lastSyncedAt = Date.now();
|
||||
}
|
||||
schedulePersist();
|
||||
notifyHandlers(key);
|
||||
}
|
||||
|
||||
restoreCacheSnapshot();
|
||||
|
||||
@@ -43,6 +43,7 @@ const GRANDFATHERED = {
|
||||
"js/nostr/nip46Client.js": 2102,
|
||||
"js/state/cache.js": 1827,
|
||||
"js/app/feedCoordinator.js": 1857,
|
||||
"js/viewCounter.js": 1023,
|
||||
"js/app/authSessionCoordinator.js": 1803,
|
||||
"js/ui/components/RevertModal.js": 1663,
|
||||
"js/payments/nwcClient.js": 1642,
|
||||
|
||||
@@ -575,6 +575,66 @@ async function testRelayCountAggregationUsesBestEstimate() {
|
||||
}
|
||||
}
|
||||
|
||||
// The exact path (options.exact, watch page / popularity chart) must count the
|
||||
// cross-relay UNION of view events deduped by (viewer, window) — NOT the per-relay
|
||||
// NIP-45 COUNT, which returns the single relay with the most events and therefore
|
||||
// under-counts when views are spread across relays.
|
||||
async function testExactCountUnionsAcrossRelays() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
harness.resetMetrics();
|
||||
|
||||
const previousGetManager = nostrClient.getSubscriptionManager;
|
||||
// exactCountForPointer only runs when a SubscriptionManager exists.
|
||||
nostrClient.getSubscriptionManager = () => ({
|
||||
list: async () => [],
|
||||
subscribe: () => ({ close() {} }),
|
||||
handleReconnect() {},
|
||||
});
|
||||
|
||||
try {
|
||||
const pointer = { type: "e", value: "view-counter-exact-union" };
|
||||
const pointerKey = harness.pointerKeyFromInput(pointer);
|
||||
const dayStart =
|
||||
Math.floor(Math.floor(Date.now() / 1000) / 86400) * 86400;
|
||||
// alice has two views in the same day (dedupe to 1) → 3 distinct (viewer,day):
|
||||
// alice@d0, bob@d0, alice@d1.
|
||||
harness.setEvents(pointerKey, [
|
||||
{ id: "e1", pubkey: "alice", created_at: dayStart + 10 },
|
||||
{ id: "e2", pubkey: "alice", created_at: dayStart + 20 },
|
||||
{ id: "e3", pubkey: "bob", created_at: dayStart + 30 },
|
||||
{ id: "e4", pubkey: "alice", created_at: dayStart - 86400 + 10 },
|
||||
]);
|
||||
// A single relay's COUNT only saw 1 — the union must win.
|
||||
harness.setCountTotal(pointerKey, {
|
||||
total: 1,
|
||||
best: { relay: "wss://relay.one", count: 1 },
|
||||
});
|
||||
|
||||
const updates = [];
|
||||
const token = subscribeToVideoViewCount(
|
||||
pointer,
|
||||
(state) => updates.push({ ...state }),
|
||||
{ exact: true },
|
||||
);
|
||||
try {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
const final = updates.at(-1);
|
||||
assert.ok(final, "expected an exact-count update");
|
||||
assert.equal(
|
||||
final.total,
|
||||
3,
|
||||
"exact count = cross-relay union deduped by (viewer,day), not the per-relay COUNT (1)",
|
||||
);
|
||||
} finally {
|
||||
unsubscribeFromVideoViewCount(pointer, token);
|
||||
}
|
||||
} finally {
|
||||
nostrClient.getSubscriptionManager = previousGetManager;
|
||||
}
|
||||
}
|
||||
|
||||
async function testLocalIngestNotifiesImmediately() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
@@ -1136,6 +1196,7 @@ await testHydrationSkipsStaleEventsAndRollsOff();
|
||||
await testLocalIngestNotifiesImmediately();
|
||||
await testUnsubscribeStopsCallbacks();
|
||||
await testRelayCountAggregationUsesBestEstimate();
|
||||
await testExactCountUnionsAcrossRelays();
|
||||
await testRecordVideoViewEmitsJsonPayload();
|
||||
await testSignAndPublishFallbackUsesSessionActor();
|
||||
await testHydrateHistoryPrefersRootEvent();
|
||||
|
||||
@@ -221,6 +221,22 @@ parameterized-replaceable event; (b) `exactCountForPointer` runs a NIP-45 COUNT
|
||||
boundary (map vs the `syncActiveVideoRootTimestamp` field step). Removed from
|
||||
the QUARANTINE map. Spec corrections logged in TEST_INTEGRITY.md
|
||||
(SCN-view-counter-legacy-path, SCN-hydrate-root-timestamp).
|
||||
- [x] **Cross-relay UNDER-count fixed (2026-06-24).** The "exact" path (watch page
|
||||
+ popularity chart) relied on the NIP-45 COUNT, but `countEventsAcrossRelays`
|
||||
returns `bestEstimate` = the SINGLE relay with the most events — it can't
|
||||
union across relays, so it under-counts when views are spread across relays
|
||||
(e.g. card showed 19 while the chart's uncapped list union showed ~63).
|
||||
`exactCountForPointer` now lists view events across ALL relays (uncapped, one
|
||||
on-demand list — not a storm) and folds the UNION into the deduped
|
||||
`dedupeBuckets` (one per viewer-window); total = the set size, so it's
|
||||
accurate AND monotonic (never spuriously decreases). Per-relay COUNT is kept
|
||||
only as a lower-bound floor when the list truncates (>2000), flagged partial.
|
||||
The popularity modal now passes `{ exact: true }`, and since the count flows
|
||||
through the shared pointer state, opening a video corrects its count
|
||||
everywhere (grid cards converge). Test: `testExactCountUnionsAcrossRelays` in
|
||||
`tests/view-counter.test.mjs`. NOTE: grid cards (no `exact`) still use the
|
||||
§17-capped batched backfill (approximate, storm-safe) until the video is
|
||||
opened — acceptable accuracy/cost tradeoff.
|
||||
- [x] **`reactionCounter.js` (likes/kind-7) audited — accurate, no fix needed.**
|
||||
It does NOT use NIP-45 COUNT; it lists kind-7 events and dedupes via
|
||||
`applyReactionToState` keyed by pubkey (one reaction per user, latest-wins),
|
||||
@@ -469,6 +485,13 @@ toward freshness and looked identical. Gave each a structural identity:
|
||||
windowed "trending" (views in the last N days) once view-over-time data (#26)
|
||||
lands, instead of all-time totals.
|
||||
|
||||
### 31. ⋯ menu "Event Details" button does nothing (BUG)
|
||||
- [ ] The "Event Details" item in the video ⋯ (more) menu does not open / does
|
||||
nothing when clicked. Trace its `data-action` → `handleMoreMenuAction` case
|
||||
(vs `EventDetailsModal`): confirm the action name matches a case (likely a
|
||||
missing/renamed case or a no-op like the embed/copy-magnet event-name
|
||||
mismatches), wire it to open `EventDetailsModal`, and add a regression test.
|
||||
|
||||
### 28. Beacon torrent app stuck — spinner never resolves (BUG)
|
||||
- [ ] The torrent beacon app (`scripts/build:beacon` / `torrent/` integration) shows
|
||||
a spinner that never goes away. Trace the beacon's init/connect path: what the
|
||||
|
||||
Reference in new issue
Block a user