-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsubscription.ts
More file actions
164 lines (148 loc) · 5.56 KB
/
Copy pathsubscription.ts
File metadata and controls
164 lines (148 loc) · 5.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import { Database } from './db'
import { JetstreamSubscriptionBase, JetstreamEvent } from './jetstream'
import { ILikeGraph } from './graph/types'
import { isInternable } from './graph/arena-interner'
export const LIKE_COLLECTION = 'app.bsky.feed.like'
export const POST_PATH = '/app.bsky.feed.post/'
type PendingLike = {
uri: string
liker_did: string
subject_uri: string
created_at: string
indexed_at: string
}
// Ingests the network-wide like stream from Jetstream into the `likes` edge
// table. Likes are the entire signal for the For You algorithm. Creates are
// buffered and flushed in batches to keep write load manageable at full
// network volume; deletes (far rarer) are applied directly.
export class LikesIngester extends JetstreamSubscriptionBase {
private buffer: PendingLike[] = []
private flushTimer?: NodeJS.Timeout
constructor(
db: Database,
endpoint: string,
reconnectDelay: number,
// Optional in-memory graph kept in sync with the firehose (live appends).
private readonly graph?: ILikeGraph,
private readonly flushIntervalMs = 500,
private readonly flushSize = 500,
) {
super(db, 'jetstream', endpoint, [LIKE_COLLECTION], reconnectDelay)
this.flushTimer = setInterval(() => {
this.flush().catch((err) => console.error('like flush failed', err))
}, this.flushIntervalMs)
}
async handleEvent(evt: JetstreamEvent): Promise<void> {
if (evt.kind !== 'commit' || !evt.commit) return
const c = evt.commit
if (c.collection !== LIKE_COLLECTION) return
const likeUri = `at://${evt.did}/${LIKE_COLLECTION}/${c.rkey}`
if (c.operation === 'delete') {
await this.db.deleteFrom('likes').where('uri', '=', likeUri).execute()
return
}
if (c.operation !== 'create' || !c.record) return
const subject = (c.record.subject ?? {}) as { uri?: unknown }
const subjectUri = subject.uri
// Only index likes on posts (ignore likes of feed generators, etc).
if (typeof subjectUri !== 'string' || !subjectUri.includes(POST_PATH)) return
// Valid post at-URIs are ASCII; drop malformed/hostile non-ASCII or oversized
// URIs at the boundary so they never reach Postgres or the graph interner.
if (!isInternable(subjectUri)) return
const rawCreatedAt = c.record.createdAt
const createdAt =
typeof rawCreatedAt === 'string' && !isNaN(Date.parse(rawCreatedAt))
? rawCreatedAt
: new Date().toISOString()
this.buffer.push({
uri: likeUri,
liker_did: evt.did,
subject_uri: subjectUri,
created_at: createdAt,
indexed_at: new Date().toISOString(),
})
// keep the in-memory graph live (no-op until it has finished building)
this.graph?.applyCreate(evt.did, subjectUri, Date.parse(createdAt))
if (this.buffer.length >= this.flushSize) {
await this.flush()
}
}
private async flush(): Promise<void> {
if (this.buffer.length === 0) return
const batch = this.buffer
this.buffer = []
await this.db
.insertInto('likes')
.values(batch)
.onConflict((oc) => oc.column('uri').doNothing())
.execute()
}
stop() {
if (this.flushTimer) clearInterval(this.flushTimer)
super.stop()
}
}
// Periodically drops like edges and post metadata older than the retention
// window so the working set stays bounded. The For You output is capped at
// `freshnessHours`, but we retain likes a bit longer to keep seed/co-liker
// coverage for infrequent likers.
//
// Likes on the picker account's posts (the onboarding "interest posts") are
// exempt when `pickerDid` is set: those posts are meant to be permanent hubs
// connecting interest-aligned users, so sweeping their edges after retention
// would silently sever a user who picked an interest long ago from newer
// onboarders who pick the same one.
export const startRetentionSweep = (
db: Database,
retentionHours: number,
opts: {
pickerDid?: string
intervalMs?: number
// Reward signal is kept longer than raw likes so parameter tuning has history.
interactionsRetentionHours?: number
} = {},
): NodeJS.Timeout => {
const {
pickerDid,
intervalMs = 10 * 60 * 1000,
interactionsRetentionHours = 30 * 24,
} = opts
const sweep = async () => {
const cutoff = new Date(
Date.now() - retentionHours * 60 * 60 * 1000,
).toISOString()
const interactionsCutoff = new Date(
Date.now() - interactionsRetentionHours * 60 * 60 * 1000,
).toISOString()
try {
let likesToDelete = db.deleteFrom('likes').where('indexed_at', '<', cutoff)
if (pickerDid) {
// DIDs contain no LIKE wildcards, so this prefix match is exact.
likesToDelete = likesToDelete.where(
'subject_uri',
'not like',
`at://${pickerDid}${POST_PATH}%`,
)
}
const likes = await likesToDelete.executeTakeFirst()
const posts = await db
.deleteFrom('post_meta')
.where('created_at', '<', cutoff)
.executeTakeFirst()
const interactions = await db
.deleteFrom('interactions')
.where('created_at', '<', interactionsCutoff)
.executeTakeFirst()
console.log(
`🧹 retention sweep removed ${Number(likes.numDeletedRows ?? 0)} likes, ` +
`${Number(posts.numDeletedRows ?? 0)} post_meta, ` +
`${Number(interactions.numDeletedRows ?? 0)} interactions (cutoff ${cutoff})`,
)
} catch (err) {
console.error('retention sweep failed', err)
}
}
// run once shortly after boot, then on the interval
setTimeout(() => sweep(), 30 * 1000)
return setInterval(sweep, intervalMs)
}