[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf: optimize database queries with batching and caching

+62 -30
+18
api/so/sprk/actor/getProfile.ts
··· 10 10 import { createPipeline, noRules } from "../../../../pipeline.ts"; 11 11 import { Views } from "../../../../views/index.ts"; 12 12 import { resHeaders } from "../../../util.ts"; 13 + import { getLogger } from "@logtape/logtape"; 14 + 15 + const logger = getLogger(["appview", "getProfile"]); 13 16 14 17 export default function (server: Server, ctx: AppContext) { 15 18 const getProfile = createPipeline(skeleton, hydration, noRules, presentation); 16 19 server.so.sprk.actor.getProfile({ 17 20 auth: ctx.authVerifier.optionalStandardOrRole, 18 21 handler: async ({ auth, params, req }) => { 22 + const start = performance.now(); 23 + 19 24 const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth); 20 25 const labelers = ctx.reqLabelers(req); 26 + 27 + const t1 = performance.now(); 21 28 const hydrateCtx = await ctx.hydrator.createContext({ 22 29 labelers, 23 30 viewer, 24 31 includeTakedowns, 25 32 }); 33 + const t2 = performance.now(); 26 34 27 35 const result = await getProfile({ ...params, hydrateCtx }, ctx); 36 + const t3 = performance.now(); 28 37 29 38 const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer); 39 + const t4 = performance.now(); 40 + 41 + logger.info("getProfile timing", { 42 + viewer: !!viewer, 43 + createContext: Math.round(t2 - t1), 44 + pipeline: Math.round(t3 - t2), 45 + repoRev: Math.round(t4 - t3), 46 + total: Math.round(t4 - start), 47 + }); 30 48 31 49 return { 32 50 encoding: "application/json",
+2 -1
data-plane/db/models.ts
··· 192 192 subject: { type: String, required: true, index: true }, 193 193 }) 194 194 .index({ authorDid: 1, subject: 1 }, { unique: true }) 195 - .index({ subject: 1, createdAt: -1 }); 195 + .index({ subject: 1, createdAt: -1 }) 196 + .index({ subject: 1, authorDid: 1 }); 196 197 197 198 // profiles 198 199
+21 -16
data-plane/routes/follows.ts
··· 109 109 * Uses aggregation to avoid fetching all followers of popular accounts. 110 110 */ 111 111 112 - const results: FollowsFollowing[] = []; 113 - 114 112 // Get all people the viewer follows (bounded by viewer's follow count) 115 113 const viewerFollows = await this.db.models.Follow.find({ 116 114 authorDid: viewerDid, ··· 118 116 119 117 const viewerFollowsDids = viewerFollows.map((f) => f.subject); 120 118 121 - if (viewerFollowsDids.length === 0) { 119 + if (viewerFollowsDids.length === 0 || subjectDids.length === 0) { 122 120 // Viewer follows no one, so no known followers possible 123 121 return { 124 122 results: subjectDids.map((targetDid) => ··· 127 125 }; 128 126 } 129 127 130 - for (const subjectDid of subjectDids) { 131 - // Find which of the viewer's follows also follow the subject 132 - // This query is bounded by the viewer's follow count, not the subject's follower count 133 - const mutualConnections = await this.db.models.Follow.find({ 134 - authorDid: { $in: viewerFollowsDids }, 135 - subject: subjectDid, 136 - }).select("authorDid"); 128 + // Batch query: get all follows where authorDid is in viewer's follows AND subject is in subjectDids 129 + // This replaces N sequential queries with 1 query 130 + const mutualConnections = await this.db.models.Follow.find({ 131 + authorDid: { $in: viewerFollowsDids }, 132 + subject: { $in: subjectDids }, 133 + }).select("authorDid subject"); 137 134 138 - results.push( 139 - new FollowsFollowing({ 140 - targetDid: subjectDid, 141 - dids: mutualConnections.map((connection) => connection.authorDid), 142 - }), 143 - ); 135 + // Group results by subject 136 + const connectionsBySubject = new Map<string, string[]>(); 137 + for (const connection of mutualConnections) { 138 + const existing = connectionsBySubject.get(connection.subject) ?? []; 139 + existing.push(connection.authorDid); 140 + connectionsBySubject.set(connection.subject, existing); 144 141 } 142 + 143 + // Build results in the same order as subjectDids 144 + const results = subjectDids.map((subjectDid) => 145 + new FollowsFollowing({ 146 + targetDid: subjectDid, 147 + dids: connectionsBySubject.get(subjectDid) ?? [], 148 + }) 149 + ); 145 150 146 151 return { results }; 147 152 }
+14 -9
data-plane/routes/interactions.ts
··· 158 158 return { results: new Map() }; 159 159 } 160 160 161 - // Get all DIDs the viewer follows 161 + // Get all DIDs the viewer follows (use lean() for faster queries) 162 162 const viewerFollows = await this.db.models.Follow.find({ 163 163 authorDid: viewerDid, 164 - }).select("subject"); 164 + }) 165 + .select("subject") 166 + .lean(); 165 167 const followedDids = viewerFollows.map((f) => f.subject); 166 168 167 169 if (followedDids.length === 0) { ··· 169 171 } 170 172 171 173 // Query likes, reposts, and replies by followed users on the subject URIs 174 + // All queries are batched and parallelized for optimal performance 172 175 const [likes, reposts, replies] = await Promise.all([ 173 176 this.db.models.Like.find({ 174 177 subject: { $in: subjectUris }, 175 178 authorDid: { $in: followedDids }, 176 179 }) 177 180 .select("uri cid subject authorDid indexedAt") 178 - .sort({ indexedAt: -1 }), 181 + .sort({ indexedAt: -1 }) 182 + .lean(), 179 183 this.db.models.Repost.find({ 180 184 subject: { $in: subjectUris }, 181 185 authorDid: { $in: followedDids }, 182 186 }) 183 187 .select("uri cid subject authorDid indexedAt") 184 - .sort({ indexedAt: -1 }), 188 + .sort({ indexedAt: -1 }) 189 + .lean(), 185 190 this.db.models.Reply.find({ 186 191 "reply.parent.uri": { $in: subjectUris }, 187 192 authorDid: { $in: followedDids }, 188 193 }) 189 194 .select("uri cid reply.parent.uri authorDid indexedAt text") 190 - .sort({ indexedAt: -1 }), 195 + .sort({ indexedAt: -1 }) 196 + .lean(), 191 197 ]); 192 198 193 - // Build result map keyed by subject URI 199 + // Build result map keyed by subject URI - pre-initialize for all subject URIs 194 200 const results = new Map<string, KnownInteraction[]>(); 195 - 196 - // Initialize empty arrays for each subject URI 197 201 for (const uri of subjectUris) { 198 202 results.set(uri, []); 199 203 } 200 204 205 + // Process all interactions in a single pass for better performance 201 206 // Add likes 202 207 for (const like of likes) { 203 208 const interactions = results.get(like.subject); ··· 228 233 229 234 // Add replies 230 235 for (const reply of replies) { 231 - const parentUri = reply.reply?.parent.uri; 236 + const parentUri = reply.reply?.parent?.uri; 232 237 if (!parentUri) continue; 233 238 const interactions = results.get(parentUri); 234 239 if (interactions) {
+1 -1
data-plane/routes/sync.ts
··· 1 1 import { Database } from "../db/index.ts"; 2 2 3 3 async function getLatestRev(actorDid: string, db: Database) { 4 - const res = await db.models.ActorSync.findOne({ where: { did: actorDid } }); 4 + const res = await db.models.ActorSync.findOne({ did: actorDid }); 5 5 return { 6 6 rev: res?.repoRev ?? undefined, 7 7 };
+6 -3
main.ts
··· 7 7 import { createServer } from "./lex/index.ts"; 8 8 import wellKnown from "./api/well-known.ts"; 9 9 import health from "./api/health.ts"; 10 - import { IdResolver } from "@atp/identity"; 10 + import { IdResolver, MemoryCache } from "@atp/identity"; 11 11 import { DataPlane } from "./data-plane/index.ts"; 12 12 import { getLogger } from "@logtape/logtape"; 13 13 import { configureLogger } from "./utils/logger.ts"; ··· 49 49 const db = new Database(cfg); 50 50 db.connect(); 51 51 52 - // DID and resolver setup 53 - const idResolver = new IdResolver({ plcUrl: cfg.plcUrl }); 52 + // DID and resolver setup with caching 53 + const idResolver = new IdResolver({ 54 + plcUrl: cfg.plcUrl, 55 + didCache: new MemoryCache(), 56 + }); 54 57 55 58 const dataplane = new DataPlane(db, idResolver); 56 59 const hydrator = new Hydrator(dataplane, cfg.labelsFromIssuerDids);