@atproto/ozone
Version:
Backend service for moderating the Bluesky network.
165 lines (148 loc) • 4.99 kB
text/typescript
import { lexicons } from '@atproto/api'
import { BackgroundQueue } from '../background'
import { Database } from '../db'
import { CommitCreateEvent, Jetstream } from '../jetstream/service'
import { verificationLogger } from '../logger'
import { VerificationService } from '../verification/service'
type VerificationRecord = {
subject: string
handle: string
displayName: string
createdAt: string
}
export class VerificationListener {
destroyed = false
private cursor?: number
private jetstream: Jetstream | null = null
private collection = 'app.bsky.graph.verification'
public backgroundQueue = new BackgroundQueue(this.db, { concurrency: 1 })
private verificationService = VerificationService.creator()(this.db)
constructor(
private db: Database,
private jetstreamUrl: string,
private verifierIssuersToIndex?: string[],
) {}
// When the queue has capacity, this method returns true which means we can continue to handle events
// otherwise, it will close jetstream connection and wait for all previously queued events to be processed first
// and then start jetstream listener again before returning false. At that point, the previous listeners should
// have updates the cursor in db to the last processed event and the new listener will start from that cursor
async ensureCoolDown() {
const { waitingCount, runningCount } = this.backgroundQueue.getStats()
if (waitingCount > 50 || runningCount > 50) {
verificationLogger.warn(`Background queue is full, pausing listener`)
this.jetstream?.close()
await this.backgroundQueue.processAll()
await this.start()
return false
}
return true
}
handleNewVerification(
issuer: string,
uri: string,
cid: string,
record: VerificationRecord,
cursor: number,
) {
this.backgroundQueue.add(async () => {
try {
const { subject, handle, displayName, createdAt } = record
await this.verificationService.create([
{ uri, cid, issuer, subject, handle, displayName, createdAt },
])
await this.updateCursor(cursor)
} catch (err) {
verificationLogger.error(
err,
'Error handling verification create event',
)
}
})
}
handleDeletedVerification(uri: string, cursor: number) {
this.backgroundQueue.add(async () => {
try {
await this.verificationService.markRevoked({
uris: [uri],
})
await this.updateCursor(cursor)
} catch (err) {
verificationLogger.error(
err,
'Error handling verification delete event',
)
}
})
}
async getCursor() {
await this.verificationService.createFirehoseCursor()
const cursor = await this.verificationService.getFirehoseCursor()
if (cursor) {
this.cursor = cursor
}
return this.cursor
}
async updateCursor(cursor: number) {
// Assuming cursors are always incremental, if we have processed an event with higher value cursor, let's not update to a lower value
if (this.cursor && this.cursor >= cursor) {
return
}
// This will only update if the cursor is higher than the current one in db
const updatedCursor =
await this.verificationService.updateFirehoseCursor(cursor)
if (updatedCursor) {
this.cursor = updatedCursor
}
}
async start() {
await this.getCursor()
this.jetstream = new Jetstream({
endpoint: this.jetstreamUrl,
cursor: this.cursor || undefined,
wantedCollections: [this.collection],
wantedDids: this.verifierIssuersToIndex?.length
? this.verifierIssuersToIndex
: undefined,
})
await this.jetstream.start({
onCreate: {
[this.collection]: async (e: CommitCreateEvent<VerificationRecord>) => {
const recordValidity = lexicons.validate(
this.collection,
e.commit.record,
)
if (!recordValidity.success) {
verificationLogger.error(
recordValidity.error,
'Invalid verification record in the firehose',
)
return
}
const hasCapacity = await this.ensureCoolDown()
if (hasCapacity) {
const issuer = e.did
const { record, rkey, collection, cid } = e.commit
const uri = `at://${issuer}/${collection}/${rkey}`
this.handleNewVerification(issuer, uri, cid, record, e.time_us)
}
},
},
onDelete: {
[this.collection]: async (e) => {
const hasCapacity = await this.ensureCoolDown()
if (hasCapacity) {
this.handleDeletedVerification(
`at://${e.did}/${e.commit.collection}/${e.commit.rkey}`,
e.time_us,
)
}
},
},
})
}
stop() {
this.jetstream?.close()
this.backgroundQueue.destroy()
this.destroyed = true
}
}