Skip to content

Commit

Permalink
perf: lazy-load the thread context (#1774)
Browse files Browse the repository at this point in the history
* perf: lazy-load the thread context

fixes #898

* more tests

* test: more tests

* simplify implementation
  • Loading branch information
nolanlawson authored May 16, 2020
1 parent 9e09ba6 commit 836b0e3
Show file tree
Hide file tree
Showing 8 changed files with 511 additions and 11 deletions.
72 changes: 65 additions & 7 deletions src/routes/_actions/timeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { emit } from '../_utils/eventBus'
import { TIMELINE_BATCH_SIZE } from '../_static/timelines'
import { timelineItemToSummary } from '../_utils/timelineItemToSummary'
import uniqBy from 'lodash-es/uniqBy'
import { addStatusesOrNotifications } from './addStatusOrNotification'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { sortItemSummariesForThread } from '../_utils/sortItemSummariesForThread'

const byId = _ => _.id

Expand All @@ -26,13 +29,66 @@ async function storeFreshTimelineItemsInDatabase (instanceName, timelineName, it
}
}

async function updateStatus (instanceName, accessToken, statusId) {
const status = await getStatus(instanceName, accessToken, statusId)
await database.insertStatus(instanceName, status)
emit('statusUpdated', status)
return status
}

async function updateStatusAndThread (instanceName, accessToken, timelineName, statusId) {
const [status, context] = await Promise.all([
updateStatus(instanceName, accessToken, statusId),
getStatusContext(instanceName, accessToken, statusId)
])
await database.insertTimelineItems(
instanceName,
timelineName,
concat(context.ancestors, status, context.descendants)
)
addStatusesOrNotifications(instanceName, timelineName, concat(context.ancestors, context.descendants))
}

async function fetchFreshThreadFromNetwork (instanceName, accessToken, statusId) {
const [status, context] = await Promise.all([
getStatus(instanceName, accessToken, statusId),
getStatusContext(instanceName, accessToken, statusId)
])
return concat(context.ancestors, status, context.descendants)
}

async function fetchThreadFromNetwork (instanceName, accessToken, timelineName) {
const statusId = timelineName.split('/').slice(-1)[0]

// For threads, we do several optimizations to make it a bit faster to load.
// The vast majority of statuses have no replies and aren't in reply to anything,
// so we want that to be as fast as possible.
const status = await database.getStatus(instanceName, statusId)
if (!status) {
// If for whatever reason the status is not cached, fetch everything from the network
// and wait for the result. This happens in very unlikely cases (e.g. loading /statuses/<id>
// where <id> is not cached locally) but is worth covering.
return fetchFreshThreadFromNetwork(instanceName, accessToken, statusId)
}

if (!status.in_reply_to_id) {
// status is not a reply to another status (fast path)
// Update the status and thread asynchronously, but return just the status for now
// Any replies to the status will load asynchronously
/* no await */ updateStatusAndThread(instanceName, accessToken, timelineName, statusId)
return [status]
}
// status is a reply to some other status, meaning we don't want some
// jerky behavior where it suddenly scrolls into place. Update the status asynchronously
// but grab the thread now
scheduleIdleTask(() => updateStatus(instanceName, accessToken, statusId))
const context = await getStatusContext(instanceName, accessToken, statusId)
return concat(context.ancestors, status, context.descendants)
}

async function fetchTimelineItemsFromNetwork (instanceName, accessToken, timelineName, lastTimelineItemId) {
if (timelineName.startsWith('status/')) { // special case - this is a list of descendents and ancestors
const statusId = timelineName.split('/').slice(-1)[0]
const statusRequest = getStatus(instanceName, accessToken, statusId)
const contextRequest = getStatusContext(instanceName, accessToken, statusId)
const [status, context] = await Promise.all([statusRequest, contextRequest])
return concat(context.ancestors, status, context.descendants)
return fetchThreadFromNetwork(instanceName, accessToken, timelineName)
} else { // normal timeline
return getTimeline(instanceName, accessToken, timelineName, lastTimelineItemId, null, TIMELINE_BATCH_SIZE)
}
Expand All @@ -49,7 +105,7 @@ async function fetchTimelineItems (instanceName, accessToken, timelineName, last
try {
console.log('fetchTimelineItemsFromNetwork')
items = await fetchTimelineItemsFromNetwork(instanceName, accessToken, timelineName, lastTimelineItemId)
/* no await */ storeFreshTimelineItemsInDatabase(instanceName, timelineName, items)
await storeFreshTimelineItemsInDatabase(instanceName, timelineName, items)
} catch (e) {
console.error(e)
toast.say('Internet request failed. Showing offline content.')
Expand Down Expand Up @@ -160,9 +216,11 @@ export async function showMoreItemsForThread (instanceName, timelineName) {
timelineItemSummaries.push(itemSummaryToAdd)
}
}
const statusId = timelineName.split('/').slice(-1)[0]
const sortedTimelineItemSummaries = await sortItemSummariesForThread(timelineItemSummaries, statusId)
store.setForTimeline(instanceName, timelineName, {
timelineItemSummariesToAdd: [],
timelineItemSummaries: timelineItemSummaries
timelineItemSummaries: sortedTimelineItemSummaries
})
stop('showMoreItemsForThread')
}
2 changes: 1 addition & 1 deletion src/routes/_database/databaseApis.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ export * from './timelines/pagination'
export * from './timelines/getStatusOrNotification'
export * from './timelines/updateStatus'
export * from './timelines/deletion'
export * from './timelines/insertion'
export { insertTimelineItems, insertStatus } from './timelines/insertion'
export * from './meta'
export * from './relationships'
8 changes: 8 additions & 0 deletions src/routes/_database/timelines/insertion.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,11 @@ export async function insertTimelineItems (instanceName, timeline, timelineItems
return insertTimelineStatuses(instanceName, timeline, timelineItems)
}
}

export async function insertStatus (instanceName, status) {
cacheStatus(status, instanceName)
const db = await getDatabase(instanceName)
await dbPromise(db, [STATUSES_STORE, ACCOUNTS_STORE], 'readwrite', ([statusesStore, accountsStore]) => {
storeStatus(statusesStore, accountsStore, status)
})
}
22 changes: 22 additions & 0 deletions src/routes/_utils/maps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// utilities for working with Maps

export function mapBy (items, func) {
const map = new Map()
for (const item of items) {
map.set(func(item), item)
}
return map
}

export function multimapBy (items, func) {
const map = new Map()
for (const item of items) {
const key = func(item)
if (map.has(key)) {
map.get(key).push(item)
} else {
map.set(key, [item])
}
}
return map
}
73 changes: 73 additions & 0 deletions src/routes/_utils/sortItemSummariesForThread.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// This is designed to exactly mimic Mastodon's ordering for threads. As described by Gargron:
// "statuses are ordered in the postgresql query and then any of OP's self-replies bubble to the top"
// Source: https://github.com/tootsuite/mastodon/blob/ef15246/app/models/concerns/status_threading_concern.rb
import { concat } from './arrays'
import { compareTimelineItemSummaries } from './statusIdSorting'
import { mapBy, multimapBy } from './maps'

export function sortItemSummariesForThread (summaries, statusId) {
const ancestors = []
const descendants = []
const summariesById = mapBy(summaries, _ => _.id)
const summariesByReplyId = multimapBy(summaries, _ => _.replyId)

const status = summariesById.get(statusId)
if (!status) {
// bail out, for some reason we can't find the status (should never happen)
return summaries
}

// find ancestors
let currentStatus = status
do {
currentStatus = summariesById.get(currentStatus.replyId)
if (currentStatus) {
ancestors.unshift(currentStatus)
}
} while (currentStatus)

// find descendants
// This mirrors the depth-first ordering used in the Postgres query in the Mastodon implementation
const stack = [status]
while (stack.length) {
const current = stack.shift()
const newChildren = (summariesByReplyId.get(current.id) || []).sort(compareTimelineItemSummaries)
Array.prototype.unshift.apply(stack, newChildren)
if (current.id !== status.id) { // the status is not a descendant of itself
descendants.push(current)
}
}

// Normally descendants are sorted in depth-first order, via normal ID sorting
// but replies that come from the account they're actually replying to get promoted
// This only counts if it's an unbroken self-reply, e.g. in the case of
// A -> A -> A -> B -> A -> A
// B has broken the chain, so only the first three As are considered unbroken self-replies
const isUnbrokenSelfReply = (descendant) => {
let current = descendant
while (true) {
if (current.accountId !== status.accountId) {
return false
}
const parent = summariesById.get(current.replyId)
if (!parent) {
break
}
current = parent
}
return current.id === statusId
}

const promotedDescendants = []
const otherDescendants = []
for (const descendant of descendants) {
(isUnbrokenSelfReply(descendant) ? promotedDescendants : otherDescendants).push(descendant)
}

return concat(
ancestors,
[status],
promotedDescendants,
otherDescendants
)
}
1 change: 1 addition & 0 deletions src/routes/_utils/timelineItemToSummary.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export function timelineItemToSummary (item) {
return {
id: item.id,
accountId: item.account.id,
replyId: (item.in_reply_to_id) || undefined,
reblogId: (item.reblog && item.reblog.id) || undefined,
type: item.type || undefined
Expand Down
6 changes: 3 additions & 3 deletions tests/spec/122-replies-in-thread.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,13 @@ test('no duplicates in threads', async t => {
const id5 = await getNthStatusId(6)()
await postReplyAs('admin', 'hey i am replying to 1 again', id1)
await t
.expect(getNthStatusContent(6).innerText).contains('this is my thread 5')
.click(getNthStatus(6))
.expect(getUrl()).contains(id5)
.click(getNthStatus(1))
.expect(getUrl()).contains(id1)

await t
.click(getNthStatus(6))
.expect(getNthStatusContent(5).innerText).contains('this is my thread 5')
.click(getNthStatus(5))
.expect(getUrl()).contains(id5)
await replyToNthStatus(t, 5, 'this is my thread 6', 6)
await t
Expand Down
Loading

0 comments on commit 836b0e3

Please sign in to comment.