Skip to content

Commit 351672b

Browse files
committed
prep work on card data caching
1 parent 8c4a191 commit 351672b

File tree

5 files changed

+135
-16
lines changed

5 files changed

+135
-16
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Assessment of `nextCard()` Performance
2+
3+
The `nextCard()` function in `SessionController.ts` is experiencing performance issues, with execution times ranging from acceptable to several seconds. The root cause of this inconsistency appears to be in the data retrieval logic, specifically within the `ELONavigator` class, which is responsible for fetching new and review cards for a study session.
4+
5+
## The Problem: N+1 Queries
6+
7+
The primary performance bottleneck is a classic "N+1 query" problem in the `ELONavigator.getPendingReviews()` method. This method makes two separate, sequential database calls:
8+
9+
1. **`this.user.getPendingReviews(this.course.getCourseID())`**: Fetches a list of all pending review cards for the user.
10+
2. **`this.course.getCardEloData(reviews.map((r) => r.cardId))`**: For each card returned in the first query, this method makes an additional query to retrieve the card's ELO data.
11+
12+
This means that if a user has 20 pending reviews, the `getPendingReviews()` method will make 21 database queries (1 to get the reviews, and 20 to get the ELO data for each review). This is highly inefficient and is the most likely cause of the long and unpredictable delays.
13+
14+
A similar, though likely less severe, issue exists in the `getNewCards()` method, which also makes multiple database calls.
15+
16+
## The Solution: Server-Side Data Aggregation
17+
18+
The most effective way to resolve this issue is to move the data aggregation logic to the server-side (i.e., into a CouchDB view or a server-side function). Instead of making multiple round trips to the database, we can create a single query that joins the review and ELO data and returns it in a single response.
19+
20+
This will require the following changes:
21+
22+
1. **Create a new CouchDB view:** This view will be responsible for joining the user's pending reviews with the corresponding card ELO data.
23+
2. **Modify `ELONavigator.getPendingReviews()`:** This method will be updated to query the new CouchDB view instead of making two separate database calls.
24+
3. **(Optional but Recommended)** **Refactor `ELONavigator.getNewCards()`:** This method should also be refactored to reduce the number of database calls.
25+
26+
## Recommendation
27+
28+
I recommend that we proceed with the server-side data aggregation approach. This will significantly improve the performance and reliability of the `nextCard()` function and provide a better user experience.
29+
30+
I will now create a plan to implement these changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Assessment Amendment: `nextCard()` Performance
2+
3+
This document amends the initial assessment in `a.1.assessment.md`.
4+
5+
## Refined Problem Analysis
6+
7+
My initial analysis correctly identified that multiple database round-trips were the source of the performance issue. However, I incorrectly pinpointed *when* these trips occurred.
8+
9+
The user correctly pointed out that the latency does not happen during the initial `prepareSession()` call, but rather *after* each `nextCard()` call. Here's the corrected flow:
10+
11+
1. `prepareSession()` populates the internal queues (`reviewQ`, `newQ`) with `StudySessionItem` objects. These objects are lightweight and only contain IDs and metadata, not the full card content. This initial population is relatively fast.
12+
2. The UI calls `nextCard()`, which dequeues a single `StudySessionItem`. This is a fast, in-memory operation.
13+
3. The UI receives the `StudySessionItem` and now has a `cardID`. To render the card, the UI must then make **two separate, sequential network requests** to CouchDB:
14+
1. Fetch the main card document.
15+
2. Fetch the card's associated data document, which is pointed to by the main card document.
16+
4. These two on-demand fetches for every single card are the direct cause of the interactive lag the user experiences.
17+
18+
## Revised Recommendation
19+
20+
The user's proposal to maintain a client-side buffer of fully "hydrated" cards is the correct approach. This strategy involves pre-fetching the next few cards in the background so they are immediately available in memory when `nextCard()` is called. This moves the latency from an interactive "blocking" operation to a non-blocking background task.
21+
22+
I agree with this approach and will create a new plan based on it.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Revised Plan for `nextCard()` Performance
2+
3+
This plan outlines the steps to implement a client-side pre-fetching strategy to eliminate interactive lag when displaying study cards.
4+
5+
## Core Strategy
6+
7+
We will modify the `SessionController` to maintain a small, rolling buffer of fully hydrated card objects. Instead of the UI being responsible for fetching card data on-demand, the `SessionController` will proactively fetch the next few cards in the background.
8+
9+
## Implementation Steps
10+
11+
1. **Introduce a Hydrated Card Queue:**
12+
* Create a new queue within `SessionController`, let's call it `hydratedQ`. This queue will store fully resolved card objects, including their view and data components.
13+
* The `nextCard()` function will now draw directly from this `hydratedQ`.
14+
15+
2. **Implement a Pre-fetching Mechanism:**
16+
* Create a new private method in `SessionController`, e.g., `_fillHydratedQueue()`.
17+
* This method will run in the background and ensure that `hydratedQ` always contains a target number of cards (e.g., 5).
18+
* It will peek at the upcoming card IDs in the `reviewQ` and `newQ`, fetch their full data from CouchDB, and push the hydrated objects into `hydratedQ`.
19+
20+
3. **Modify `nextCard()` Logic:**
21+
* The `nextCard()` function will be updated to:
22+
1. Dequeue a hydrated card from `hydratedQ` and return it to the UI.
23+
2. After dequeuing, it will trigger the `_fillHydratedQueue()` method asynchronously to ensure the buffer is refilled in the background.
24+
25+
4. **Initial Hydration:**
26+
* During `prepareSession()`, after the initial queues are populated, we will make an initial call to `_fillHydratedQueue()` to populate the buffer for the start of the session.
27+
28+
## Task Breakdown
29+
30+
I will create a `a.4.todo.md` file with a detailed task breakdown for implementing this plan.

agent/nextCard-perf/a.4.todo.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# `nextCard()` Performance TODO
2+
3+
This file breaks down the tasks required to implement the client-side pre-fetching strategy.
4+
5+
## Phase 1: Core `SessionController` Modifications
6+
7+
- [x] **Task 1.1:** In `SessionController.ts`, define a new interface `HydratedCard` that represents a fully resolved card object (including its view and data).
8+
- [x] **Task 1.2:** In `SessionController.ts`, add a new private queue property: `private hydratedQ: ItemQueue<HydratedCard> = new ItemQueue<HydratedCard>();`
9+
- [x] **Task 1.3:** In `SessionController.ts`, create a new private async method `_fillHydratedQueue()`. This method will be responsible for the pre-fetching logic.
10+
11+
## Phase 2: Pre-fetching Logic
12+
13+
- [ ] **Task 2.1:** Inside `_fillHydratedQueue()`, implement the logic to determine how many cards to fetch (target buffer size is 5).
14+
- [ ] **Task 2.2:** Inside `_fillHydratedQueue()`, peek at the next items in `reviewQ` and `newQ` to get their card IDs.
15+
- [ ] **Task 2.3:** For each card ID, implement the necessary calls to fetch the full card document and its associated data document from CouchDB.
16+
- [ ] **Task 2.4:** Once fetched, create `HydratedCard` objects and enqueue them into `hydratedQ`.
17+
18+
## Phase 3: Update `nextCard()` and `prepareSession()`
19+
20+
- [ ] **Task 3.1:** Modify the `nextCard()` function to dequeue from `hydratedQ` instead of the other queues.
21+
- [ ] **Task 3.2:** After dequeuing in `nextCard()`, add a non-blocking call to `_fillHydratedQueue()` to trigger the background pre-fetch.
22+
- [ ] **Task 3.3:** In `prepareSession()`, after the `reviewQ` and `newQ` are populated, add an initial `await this._fillHydratedQueue()` call to ensure the buffer is ready for the first card.
23+
24+
## Phase 4: UI and Verification
25+
26+
- [ ] **Task 4.1:** Adjust the UI component that calls `nextCard()` to expect the new `HydratedCard` object, removing its own data-fetching logic.
27+
- [ ] **Task 4.2:** Manually test the study session flow to confirm that cards load quickly and that there are no errors.
28+
- [ ] **Task 4.3:** Use browser developer tools to verify that network requests for card data are happening *before* a card is displayed, not when it is requested.

packages/db/src/study/SessionController.ts

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
import { CardRecord } from '@db/core';
1111
import { Loggable } from '@db/util';
1212
import { ScheduledCard } from '@db/core/types/user';
13+
import { ViewComponent } from '@vue-skuilder/common-ui';
14+
import { ViewData } from '@vue-skuilder/common';
1315

1416
function randomInt(min: number, max: number): number {
1517
return Math.floor(Math.random() * (max - min + 1)) + min;
@@ -25,24 +27,30 @@ export interface StudySessionRecord {
2527
records: CardRecord[];
2628
}
2729

28-
class ItemQueue<T extends StudySessionItem> {
30+
export interface HydratedCard {
31+
item: StudySessionItem;
32+
view: ViewComponent;
33+
data: ViewData[];
34+
}
35+
36+
class ItemQueue<T> {
2937
private q: T[] = [];
3038
private seenCardIds: string[] = [];
3139
private _dequeueCount: number = 0;
3240
public get dequeueCount(): number {
3341
return this._dequeueCount;
3442
}
3543

36-
public add(item: T) {
37-
if (this.seenCardIds.find((d) => d === item.cardID)) {
44+
public add(item: T, cardId: string) {
45+
if (this.seenCardIds.find((d) => d === cardId)) {
3846
return; // do not re-add a card to the same queue
3947
}
4048

41-
this.seenCardIds.push(item.cardID);
49+
this.seenCardIds.push(cardId);
4250
this.q.push(item);
4351
}
44-
public addAll(items: T[]) {
45-
items.forEach((i) => this.add(i));
52+
public addAll(items: T[], cardIdExtractor: (item: T) => string) {
53+
items.forEach((i) => this.add(i, cardIdExtractor(i)));
4654
}
4755
public get length() {
4856
return this.q.length;
@@ -63,7 +71,7 @@ class ItemQueue<T extends StudySessionItem> {
6371
public get toString(): string {
6472
return (
6573
`${typeof this.q[0]}:\n` +
66-
this.q.map((i) => `\t${i.courseID}+${i.cardID}: ${i.status}`).join('\n')
74+
this.q.map((i) => `\t${(i as any).courseID}+${(i as any).cardID}: ${(i as any).status}`).join('\n')
6775
);
6876
}
6977
}
@@ -79,6 +87,7 @@ export class SessionController extends Loggable {
7987
private reviewQ: ItemQueue<StudySessionReviewItem> = new ItemQueue<StudySessionReviewItem>();
8088
private newQ: ItemQueue<StudySessionNewItem> = new ItemQueue<StudySessionNewItem>();
8189
private failedQ: ItemQueue<StudySessionFailedItem> = new ItemQueue<StudySessionFailedItem>();
90+
private hydratedQ: ItemQueue<HydratedCard> = new ItemQueue<HydratedCard>();
8291
private _currentCard: StudySessionItem | null = null;
8392
/**
8493
* Indicates whether the session has been initialized - eg, the
@@ -222,11 +231,7 @@ export class SessionController extends Loggable {
222231
}
223232

224233
let report = 'Review session created with:\n';
225-
for (let i = 0; i < dueCards.length; i++) {
226-
const card = dueCards[i];
227-
this.reviewQ.add(card);
228-
report += `\t${card.courseID}-${card.cardID}\n`;
229-
}
234+
this.reviewQ.addAll(dueCards, (c) => c.cardID);
230235
this.log(report);
231236
}
232237

@@ -246,7 +251,7 @@ export class SessionController extends Loggable {
246251
if (newContent[i].length > 0) {
247252
const item = newContent[i].splice(0, 1)[0];
248253
this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
249-
this.newQ.add(item);
254+
this.newQ.add(item, item.cardID);
250255
n--;
251256
}
252257
}
@@ -385,17 +390,21 @@ export class SessionController extends Loggable {
385390
cardID: this._currentCard.cardID,
386391
courseID: this._currentCard.courseID,
387392
contentSourceID: this._currentCard.contentSourceID,
388-
contentSourceType: this._currentCard.contentSourceType,
393+
contentSourceType: this._current_card.contentSourceType,
389394
status: 'failed-new',
390395
};
391396
}
392397

393-
this.failedQ.add(failedItem);
398+
this.failedQ.add(failedItem, failedItem.cardID);
394399
} else if (action === 'dismiss-error') {
395400
// some error logging?
396401
} else if (action === 'dismiss-failed') {
397402
// handled by Study.vue
398403
}
399404
}
400405
}
401-
}
406+
407+
private async _fillHydratedQueue() {
408+
// TODO: Implement pre-fetching logic here
409+
}
410+
}

0 commit comments

Comments
 (0)