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).
|
// Headline total: subscribe to the same counter the card uses (stable + cached).
|
||||||
try {
|
try {
|
||||||
countToken = subscribeToVideoViewCount(pointer, ({ total } = {}) => {
|
countToken = subscribeToVideoViewCount(
|
||||||
if (closed) return;
|
pointer,
|
||||||
if (Number.isFinite(total)) {
|
({ total } = {}) => {
|
||||||
counterTotal = Number(total);
|
if (closed) return;
|
||||||
renderHeadline();
|
if (Number.isFinite(total)) {
|
||||||
}
|
counterTotal = Number(total);
|
||||||
});
|
renderHeadline();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ exact: true },
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
devLogger.warn("[popularity] Failed to subscribe to view count:", 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,
|
// 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.
|
// 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 = {}) {
|
async function exactCountForPointer(key, pointer, options = {}) {
|
||||||
|
const sinceSeconds =
|
||||||
|
Math.floor(Date.now() / 1000) - VIEW_COUNT_BACKFILL_MAX_DAYS * SECONDS_PER_DAY;
|
||||||
|
|
||||||
|
let events;
|
||||||
try {
|
try {
|
||||||
const result = await countVideoViewEventsApi(pointer, {
|
events = await listVideoViewEventsApi(pointer, {
|
||||||
|
since: sinceSeconds,
|
||||||
|
limit: EXACT_LIST_LIMIT,
|
||||||
relays: options.relays,
|
relays: options.relays,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
const best = Number.isFinite(result?.best?.count)
|
} catch (error) {
|
||||||
? Number(result.best.count)
|
if (error?.name === "AbortError") {
|
||||||
: Number.isFinite(result?.total)
|
|
||||||
? Number(result.total)
|
|
||||||
: null;
|
|
||||||
if (best === null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const state = ensurePointerState(key);
|
userLogger.warn("[viewCounter] Exact view count list failed:", error);
|
||||||
if (result && typeof result === "object") {
|
return;
|
||||||
state.partial = Boolean(result.partial);
|
}
|
||||||
|
|
||||||
|
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) {
|
const createdAt = Number.isFinite(event?.created_at)
|
||||||
state.total = best;
|
? Number(event.created_at)
|
||||||
state.lastSyncedAt = Date.now();
|
: Math.floor(Date.now() / 1000);
|
||||||
}
|
state.dedupeBuckets.set(bucketKey, createdAt);
|
||||||
schedulePersist();
|
}
|
||||||
notifyHandlers(key);
|
|
||||||
} catch (error) {
|
let total = state.dedupeBuckets.size;
|
||||||
if (error?.name !== "AbortError") {
|
let partial = truncated;
|
||||||
userLogger.warn("[viewCounter] Exact view count failed:", error);
|
|
||||||
|
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();
|
restoreCacheSnapshot();
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const GRANDFATHERED = {
|
|||||||
"js/nostr/nip46Client.js": 2102,
|
"js/nostr/nip46Client.js": 2102,
|
||||||
"js/state/cache.js": 1827,
|
"js/state/cache.js": 1827,
|
||||||
"js/app/feedCoordinator.js": 1857,
|
"js/app/feedCoordinator.js": 1857,
|
||||||
|
"js/viewCounter.js": 1023,
|
||||||
"js/app/authSessionCoordinator.js": 1803,
|
"js/app/authSessionCoordinator.js": 1803,
|
||||||
"js/ui/components/RevertModal.js": 1663,
|
"js/ui/components/RevertModal.js": 1663,
|
||||||
"js/payments/nwcClient.js": 1642,
|
"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() {
|
async function testLocalIngestNotifiesImmediately() {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
harness.reset();
|
harness.reset();
|
||||||
@@ -1136,6 +1196,7 @@ await testHydrationSkipsStaleEventsAndRollsOff();
|
|||||||
await testLocalIngestNotifiesImmediately();
|
await testLocalIngestNotifiesImmediately();
|
||||||
await testUnsubscribeStopsCallbacks();
|
await testUnsubscribeStopsCallbacks();
|
||||||
await testRelayCountAggregationUsesBestEstimate();
|
await testRelayCountAggregationUsesBestEstimate();
|
||||||
|
await testExactCountUnionsAcrossRelays();
|
||||||
await testRecordVideoViewEmitsJsonPayload();
|
await testRecordVideoViewEmitsJsonPayload();
|
||||||
await testSignAndPublishFallbackUsesSessionActor();
|
await testSignAndPublishFallbackUsesSessionActor();
|
||||||
await testHydrateHistoryPrefersRootEvent();
|
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
|
boundary (map vs the `syncActiveVideoRootTimestamp` field step). Removed from
|
||||||
the QUARANTINE map. Spec corrections logged in TEST_INTEGRITY.md
|
the QUARANTINE map. Spec corrections logged in TEST_INTEGRITY.md
|
||||||
(SCN-view-counter-legacy-path, SCN-hydrate-root-timestamp).
|
(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.**
|
- [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
|
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),
|
`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)
|
windowed "trending" (views in the last N days) once view-over-time data (#26)
|
||||||
lands, instead of all-time totals.
|
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)
|
### 28. Beacon torrent app stuck — spinner never resolves (BUG)
|
||||||
- [ ] The torrent beacon app (`scripts/build:beacon` / `torrent/` integration) shows
|
- [ ] 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
|
a spinner that never goes away. Trace the beacon's init/connect path: what the
|
||||||
|
|||||||
Reference in new issue
Block a user