···1919 }
20202121 try {
2222+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
2223 const url = new URL(`https://${pds}/`); // if this still throws, the PDS name is invalid
2324 return pds;
2425 } catch (error) {
···4344 const res = await ky.get(`https://${pds}/xrpc/com.atproto.server.describeServer`, {
4445 timeout: 30000,
4546 retry: {
4646- limit: 5,
4747+ limit: 3,
4748 statusCodes: [429, 500, 502, 503, 504],
4849 },
4950 });
···52535354 return data.availableUserDomains !== undefined;
5455 } catch (error) {
5555- logger.error(`Error checking health for PDS ${pds}: ${error}`);
5656+ console.error(`Error checking health for PDS ${pds}: ${error}`);
5657 return false;
5758 }
5859}
+17-315
src/index.ts
···11-import axios from 'axios';
21import 'dotenv/config';
33-import Database from 'libsql';
44-import fs from 'node:fs/promises';
55-import { performance } from 'node:perf_hooks';
66-import pLimit from 'p-limit';
77-import readline from 'readline';
8293import {
1010- DATA_OUTPUT_FILE,
114 DIDS_TO_PROCESS,
1212- HEALTH_CHECK_FILE,
1313- PDS_DATA_FETCH_CONCURRENCY,
1414- PDS_HEALTH_CHECK_CONCURRENCY,
1515- PLC_DB_PATH,
1616- RELAY_URL,
1717- SQL_OUTPUT_FILE,
185} from './constants.js';
1919-import { isPDSHealthy, sanitizePDSName } from './helpers.js';
206import logger from './logger.js';
2121-import { BskyData, DIDsFromDB, PDSDIDGrouped, PDSHealthStatus } from './types.js';
2222-2323-const db = new Database(PLC_DB_PATH);
2424-2525-async function fetchAndDumpDidsPdses() {
2626- logger.info('Fetching DIDs from database');
2727- const startTime = performance.now();
2828-2929- const didquery = db.prepare(`
3030- SELECT
3131- identity.did,
3232- atproto_pds.endpoint
3333- FROM
3434- identity
3535- JOIN
3636- plc_log ON identity.identity_id = plc_log.identity
3737- JOIN
3838- atproto_pds ON plc_log.atproto_pds = atproto_pds.pds_id
3939- WHERE
4040- plc_log.entry_id IN (
4141- SELECT MAX(entry_id)
4242- FROM plc_log
4343- GROUP BY identity
4444- )
4545- ORDER BY identity.did ASC
4646- `);
4747-4848- const plcDbFile = await fs.open(SQL_OUTPUT_FILE, 'w');
4949- const plcDbWriteStream = plcDbFile.createWriteStream();
5050-5151- let count = 0;
5252- let lastLogTime = performance.now();
5353- for (const row of didquery.iterate()) {
5454- count += 1;
5555- if (count % 1000000 === 0) {
5656- const currentTime = performance.now();
5757- const elapsedTime = currentTime - lastLogTime;
5858- const recordsPerSecond = 1000000 / (elapsedTime / 1000);
5959- logger.info(`Processed ${count} DIDs (${recordsPerSecond.toFixed(2)} records/sec)`);
6060- lastLogTime = currentTime;
6161- }
6262- const { did, endpoint } = row as DIDsFromDB;
6363- const processedEndpoint = endpoint
6464- .replace(/^(https?:\/\/)/, '')
6565- .replace(/\/+$/, '')
6666- .trim();
6767- const finalEndpoint =
6868- processedEndpoint.includes('bsky.social') || processedEndpoint.includes('bsky.network') ?
6969- RELAY_URL
7070- : processedEndpoint;
7171-7272- plcDbWriteStream.write(JSON.stringify({ did, pds: finalEndpoint }) + '\n');
7373- }
7474-7575- plcDbWriteStream.close();
7676- const endTime = performance.now();
7777- const totalTime = (endTime - startTime) / 1000;
7878- const averageSpeed = count / totalTime;
7979- logger.info(`Data dumped to ${SQL_OUTPUT_FILE}`);
8080- logger.info(`Total DIDs processed: ${count}`);
8181- logger.info(`Total time: ${totalTime.toFixed(2)} seconds`);
8282- logger.info(`Average speed: ${averageSpeed.toFixed(2)} DIDs/second`);
8383-}
8484-8585-async function checkAllPDSHealth() {
8686- const startTime = performance.now();
8787- logger.info('Starting to process data from file');
8888-8989- const readFile = await fs.open(SQL_OUTPUT_FILE, 'r');
9090- const fileStream = readFile.createReadStream();
9191- const rl = readline.createInterface({
9292- input: fileStream,
9393- crlfDelay: Infinity,
9494- });
9595-9696- const groupedByPDS: PDSDIDGrouped = {};
9797-9898- let lineCount = 0;
9999- let lastLogTime = performance.now();
100100- for await (const line of rl) {
101101- lineCount++;
102102- if (lineCount % 1000000 === 0) {
103103- const currentTime = performance.now();
104104- const elapsedTime = currentTime - lastLogTime;
105105- const linesPerSecond = 1000000 / (elapsedTime / 1000);
106106- logger.info(`Processed ${lineCount} lines (${linesPerSecond.toFixed(2)} lines/sec)`);
107107- lastLogTime = currentTime;
108108- }
109109-110110- const { did, pds } = JSON.parse(line) as { did: string; pds: string };
111111- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
112112- if (!groupedByPDS[pds]) {
113113- groupedByPDS[pds] = [];
114114- }
115115- groupedByPDS[pds].push(did);
116116- }
117117-118118- logger.info(`Finished processing file. Total lines processed: ${lineCount}`);
119119-120120- let pdsHealthStatus: PDSHealthStatus = {};
121121-122122- const healthCheckStartTime = performance.now();
123123- try {
124124- const healthData = await fs.readFile(HEALTH_CHECK_FILE, 'utf-8');
125125- pdsHealthStatus = JSON.parse(healthData) as PDSHealthStatus;
126126- logger.info('Loaded health check data from file');
127127- } catch {
128128- logger.info('No existing health check data found, performing health checks');
129129-130130- const limit = pLimit(PDS_HEALTH_CHECK_CONCURRENCY);
131131-132132- logger.info('Checking PDS health status');
133133-134134- const sanitizedPDSMap = new Set<string>();
135135- let failedCount = 0;
136136- const originalPDSCount = Object.keys(groupedByPDS).length;
137137-138138- for (const pds of Object.keys(groupedByPDS)) {
139139- try {
140140- const sanitizedPDS = sanitizePDSName(pds);
141141- sanitizedPDSMap.add(sanitizedPDS);
142142- } catch {
143143- failedCount++;
144144- }
145145- }
146146-147147- logger.info(`Sanitization removed ${failedCount} invalid PDSes out of ${originalPDSCount}`);
148148-149149- const healthCheckPromises = Array.from(sanitizedPDSMap.entries()).map(([sanitizedPDS]) =>
150150- limit(async () => {
151151- const healthy = await isPDSHealthy(sanitizedPDS);
152152- pdsHealthStatus[sanitizedPDS] = healthy;
153153- logger.info(`PDS ${sanitizedPDS} is healthy: ${healthy}`);
154154- }),
155155- );
156156-157157- await Promise.all(healthCheckPromises);
158158-159159- await fs.writeFile(HEALTH_CHECK_FILE, JSON.stringify(pdsHealthStatus, null, 2));
160160- logger.info('Health check data saved to file');
161161- }
162162- const healthCheckEndTime = performance.now();
163163- const healthCheckTime = (healthCheckEndTime - healthCheckStartTime) / 1000;
164164- logger.info(`Health check process took ${healthCheckTime.toFixed(2)} seconds`);
165165-166166- const PDSCount = Object.keys(groupedByPDS).length;
167167- const healthyCount = Object.values(pdsHealthStatus).filter(Boolean).length;
168168- const unhealthyCount = Object.values(pdsHealthStatus).length - healthyCount;
169169- logger.info(`Total PDS count: ${PDSCount}`);
170170- logger.info(`Healthy PDS count: ${healthyCount}`);
171171- logger.info(`Unhealthy PDS count: ${unhealthyCount}`);
172172-173173- const endTime = performance.now();
174174- const totalTime = (endTime - startTime) / 1000;
175175- logger.info(`Total processing time: ${totalTime.toFixed(2)} seconds`);
176176-177177- return { groupedByPDS, pdsHealthStatus };
178178-}
179179-180180-async function processDidsAndFetchData(dids: { did: string; pds: string }[]) {
181181- const limit = pLimit(PDS_DATA_FETCH_CONCURRENCY);
182182- const fetchedData: BskyData[] = [];
183183- let successfulRequests = 0;
184184- let unsuccessfulRequests = 0;
185185- let successfulDids = 0;
186186- let failedDids = 0;
187187-188188- const tasks = dids.map(({ did, pds }) =>
189189- limit(async () => {
190190- try {
191191- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
192192- const res = await axios.post(
193193- 'http://localhost:8000/fetch',
194194- { did, pds },
195195- {
196196- responseType: 'stream',
197197- timeout: 60000,
198198- },
199199- );
200200-201201- successfulRequests++;
202202-203203- await new Promise<void>((resolve, reject) => {
204204- let buffer = '';
205205- let didSucceeded = false;
206206-207207- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
208208- res.data.on('data', (chunk: Buffer) => {
209209- buffer += chunk.toString();
210210- let boundary = buffer.indexOf('\n');
211211- while (boundary !== -1) {
212212- const line = buffer.substring(0, boundary);
213213- buffer = buffer.substring(boundary + 1);
214214- if (line.trim()) {
215215- try {
216216- const json = JSON.parse(line) as BskyData;
217217- fetchedData.push(json);
218218- didSucceeded = true;
219219- } catch (err) {
220220- logger.error(`JSON parse error for DID ${did}: ${(err as Error).message}`);
221221- }
222222- }
223223- boundary = buffer.indexOf('\n');
224224- }
225225- });
226226-227227- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
228228- res.data.on('end', () => {
229229- if (buffer.trim()) {
230230- try {
231231- const json = JSON.parse(buffer) as BskyData;
232232- fetchedData.push(json);
233233- didSucceeded = true;
234234- } catch (err) {
235235- logger.error(`JSON parse error at stream end for DID ${did}: ${(err as Error).message}`);
236236- }
237237- }
238238- if (didSucceeded) {
239239- successfulDids++;
240240- } else {
241241- failedDids++;
242242- }
243243- resolve();
244244- });
245245-246246- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
247247- res.data.on('error', (err: Error) => {
248248- logger.error(`Stream error for DID ${did}: ${err.message}`);
249249- failedDids++;
250250- reject(err);
251251- });
252252- });
253253- return;
254254- } catch (error) {
255255- logger.error(`Error fetching data for DID ${did}: ${(error as Error).message}`);
256256- unsuccessfulRequests++;
257257- failedDids++;
258258- }
259259- }),
260260- );
77+import { fetchAndDumpDidsPdses } from './stages/stage1.js';
88+import { processDidsAndFetchData } from './stages/stage3.js';
99+import { checkAllPDSHealth, selectAllDids } from './stages/stage2.js';
26110262262- await Promise.all(tasks);
263263- logger.info(`Fetched data for ${fetchedData.length} DIDs.`);
264264- logger.info(`Successful requests: ${successfulRequests}, Unsuccessful requests: ${unsuccessfulRequests}`);
265265- logger.info(`Successful DIDs: ${successfulDids}, Failed DIDs: ${failedDids}`);
26611267267- const writeFile = await fs.open(DATA_OUTPUT_FILE, 'w');
268268- const writeStream = writeFile.createWriteStream();
269269- for (const data of fetchedData) {
270270- writeStream.write(JSON.stringify(data) + '\n');
271271- }
272272- writeStream.close();
273273- logger.info(`Streamed fetched data to ${DATA_OUTPUT_FILE}`);
274274- return fetchedData;
275275-}
2761227713async function main() {
278278- if (
279279- !(await fs
280280- .access(SQL_OUTPUT_FILE)
281281- .then(() => true)
282282- .catch(() => false))
283283- ) {
284284- await fetchAndDumpDidsPdses();
285285- }
1414+ // stage 1
1515+ await fetchAndDumpDidsPdses();
1616+1717+ // stage 2
28618 const { groupedByPDS, pdsHealthStatus } = await checkAllPDSHealth();
287287-288288- const healthyGroupedByPDS = Object.entries(groupedByPDS).reduce<PDSDIDGrouped>((acc, [pds, dids]) => {
289289- if (pdsHealthStatus[pds]) {
290290- acc[pds] = dids;
291291- }
292292- return acc;
293293- }, {});
1919+ const allDids: { did: string; pds: string; }[] = selectAllDids(groupedByPDS, pdsHealthStatus);
29420295295- const unhealthyGroupedByPDS = Object.entries(groupedByPDS).reduce<PDSDIDGrouped>((acc, [pds, dids]) => {
296296- if (!pdsHealthStatus[pds]) {
297297- acc[pds] = dids;
298298- }
299299- return acc;
300300- }, {});
301301-302302- const totalHealthyDIDs = Object.values(healthyGroupedByPDS).flat().length;
303303- const totalUnhealthyDIDs = Object.values(unhealthyGroupedByPDS).flat().length;
304304-305305- logger.info(`Total DIDs from healthy PDSes: ${totalHealthyDIDs}`);
306306- logger.info(`Total DIDs from unhealthy PDSes: ${totalUnhealthyDIDs}`);
307307-308308- // Prepare the list of DIDs to process
309309- const allDids: { did: string; pds: string }[] = [];
310310-311311- for (const [pds, dids] of Object.entries(healthyGroupedByPDS)) {
312312- for (const did of dids) {
313313- allDids.push({ did, pds });
314314- }
315315- }
316316-317317- logger.info(`Total DIDs from DB: ${allDids.length}`);
318318-2121+ // stage 3
31922 const didsToProcess = allDids.slice(0, DIDS_TO_PROCESS);
320320- logger.info(`Processing the first ${didsToProcess.length} DIDs for testing.`);
2323+ console.log(`Processing ${didsToProcess.length} DIDs`);
3212432225 const fetchedData = await processDidsAndFetchData(didsToProcess);
32326324324- logger.info(`Fetched data array length: ${fetchedData.length}`);
2727+ console.log(`Fetched data array length: ${fetchedData.length}`);
32528}
3262932730main().catch((error: unknown) => {
328328- logger.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
3131+ console.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
32932 process.exit(1);
33033});
33134332332-// Keep the existing shutdown logic
333333-33435let isShuttingDown = false;
3636+3353733638async function shutdown() {
33739 if (isShuttingDown) {
338338- logger.info('Shutdown called but one is already in progress.');
4040+ console.log('Shutdown called but one is already in progress.');
33941 return;
34042 }
3414334244 isShuttingDown = true;
34345344344- logger.info('Shutting down gracefully...');
4646+ console.log('Shutting down gracefully...');
3454734648 process.exit(0);
34749}
3485034951process.on('SIGINT', () => {
35052 shutdown().catch((error: unknown) => {
351351- logger.error(`Shutdown failed: ${(error as Error).message}`);
5353+ console.error(`Shutdown failed: ${(error as Error).message}`);
35254 process.exit(1);
35355 });
35456});
3555735658process.on('SIGTERM', () => {
35759 shutdown().catch((error: unknown) => {
358358- logger.error(`Shutdown failed: ${(error as Error).message}`);
6060+ console.error(`Shutdown failed: ${(error as Error).message}`);
35961 process.exit(1);
36062 });
36163});
+38
src/metrics.ts
···11+// import { monitorPgPool } from '@christiangalsterer/node-postgres-prometheus-exporter';
22+import express from 'express';
33+import { Registry, collectDefaultMetrics } from 'prom-client';
44+55+import logger from './logger.js';
66+// import { pool } from './postgres.js';
77+88+const register = new Registry();
99+collectDefaultMetrics({ register });
1010+1111+// monitorPgPool(pool, register);
1212+1313+const app = express();
1414+1515+app.get('/metrics', (req, res) => {
1616+ register
1717+ .metrics()
1818+ .then((metrics) => {
1919+ res.set('Content-Type', register.contentType);
2020+ res.send(metrics);
2121+ })
2222+ .catch((ex: unknown) => {
2323+ console.error(`Error serving metrics: ${(ex as Error).message}`);
2424+ res.status(500).end((ex as Error).message);
2525+ });
2626+});
2727+2828+export const startMetricsServer = (port: number, host = '127.0.0.1') => {
2929+ const server = app.listen(port, host, () => {
3030+ console.log(`Metrics server listening on port ${port}`);
3131+ });
3232+3333+ server.on('close', () => {
3434+ console.log('Metrics server closed.');
3535+ });
3636+3737+ return server;
3838+};
+24
src/postgres.ts
···11+import { Kysely, PostgresDialect } from 'kysely';
22+import pg from 'pg';
33+44+import type { DB } from './schema.js';
55+66+// const { native } = pg;
77+const { Pool } = pg;
88+99+const pool = new Pool({
1010+ connectionString: process.env.DATABASE_URL,
1111+ max: 100,
1212+ // we need these two, even though they are superflous
1313+ // because the monitoring library depends on them
1414+ port: parseInt(process.env.DATABASE_PORT ?? '5432', 10),
1515+ host: process.env.DATABASE_HOST ?? 'localhost',
1616+});
1717+1818+const dialect = new PostgresDialect({ pool });
1919+2020+const db = new Kysely<DB>({
2121+ dialect,
2222+});
2323+2424+export { pool, db };
+36
src/schema.d.ts
···11+/**
22+ * This file was generated by kysely-codegen.
33+ * Please do not edit it manually.
44+ */
55+66+import type { ColumnType } from "kysely";
77+88+export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
99+ ? ColumnType<S, I | undefined, U>
1010+ : ColumnType<T, T | undefined, T>;
1111+1212+export type Timestamp = ColumnType<Date, Date | string>;
1313+1414+export interface Emojis {
1515+ created_at: Generated<Timestamp>;
1616+ emoji: string;
1717+ id: Generated<number>;
1818+ lang: string;
1919+ post_id: number;
2020+}
2121+2222+export interface Posts {
2323+ cid: string;
2424+ created_at: Generated<Timestamp>;
2525+ did: string;
2626+ has_emojis: Generated<boolean>;
2727+ id: Generated<number>;
2828+ langs: Generated<string[]>;
2929+ rkey: string;
3030+ text: string | null;
3131+}
3232+3333+export interface DB {
3434+ emojis: Emojis;
3535+ posts: Posts;
3636+}