Skip to content
2 changes: 2 additions & 0 deletions components/log-viewer-webui/server/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
"SqlDbPort": 3306,
"SqlDbName": "clp-db",
"SqlDbQueryJobsTableName": "query_jobs",

"MongoDbHost": "localhost",
"MongoDbPort": 27017,
"MongoDbName": "clp-query-results",
"MongoDbStreamFilesCollectionName": "stream-files",
"MongoDbSearchResultsMetadataCollectionName": "results-metadata",

"ClientDir": "../../client/dist",
"LogViewerDir": "../../yscope-log-viewer/dist",
Expand Down
5 changes: 4 additions & 1 deletion components/log-viewer-webui/server/src/fastify-v2/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import FastifyV1App from "../app.js";
const INTERNAL_SERVER_ERROR_CODE = 500;
const RATE_LIMIT_MAX_REQUESTS = 3;
const RATE_LIMIT_TIME_WINDOW_MS = 500;
const IGNORED_FILES_REGEX = /^.*(?:utils|typings)\.js$/;

/**
* Registers all plugins and routes.
Expand Down Expand Up @@ -47,14 +48,16 @@ export default async function serviceApp (
// Loads all application plugins.
fastify.register(fastifyAutoload, {
dir: path.join(import.meta.dirname, "plugins/app"),
ignorePattern: IGNORED_FILES_REGEX,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we need to ignore files here, I'd prefer to import the plugins at the top of the file and register them individually. There are only three anyway.
We could also avoid import.meta.dirname this way

Copy link
Member

@junhaoliao junhaoliao May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead, how about naming all plugin sources with .plugin.ts extension and using matchFilter to load only those plugin sources?

Copy link
Contributor Author

@davemarco davemarco May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are only three anyway.

There will be more plugins when we refactor the old webui.

If we need to ignore files here, I

We dont explicitly need the filter. It will only load the index files in a directory. I thought maybe if someone adds a shared typings or utils without an index file. It was safer to add this.

m okay with junhao suggestion or just leaving it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now i will just remove it. I dont think it will change anything, if someone adds something which breaks it, they can fix then

options: {...opts},
});

// Loads all routes.
fastify.register(fastifyAutoload, {
dir: path.join(import.meta.dirname, "routes"),
autoHooks: true,
cascadeHooks: true,
dir: path.join(import.meta.dirname, "routes"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as plugins

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also creates nice routes like api/search/query. Like it will add the folder structure to the route, so i like it

ignorePattern: IGNORED_FILES_REGEX,
options: {...opts},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type {MySQLPromisePool} from "@fastify/mysql";
import {encode} from "@msgpack/msgpack";
import settings from "@settings" with { type: "json" };
import {FastifyInstance} from "fastify";
import fp from "fastify-plugin";
import {ResultSetHeader} from "mysql2";
import {setTimeout} from "timers/promises";

import {
QUERY_JOB_STATUS,
QUERY_JOB_STATUS_WAITING_STATES,
QUERY_JOB_TYPE,
QUERY_JOBS_TABLE_COLUMN_NAMES,
QueryJob,
} from "../../../../../typings/query.js";
import {JOB_COMPLETION_STATUS_POLL_INTERVAL_MILLIS} from "./typings.js";


/**
* Class for submitting and monitoring query jobs in the database.
*/
class QueryJobsDbManager {
#sqlDbConnPool: MySQLPromisePool;

private constructor (sqlDbConnPool: MySQLPromisePool) {
this.#sqlDbConnPool = sqlDbConnPool;
}

/**
* Creates a new QueryJobsDbManager.
*
* @param fastify
* @return
*/
static create (fastify: FastifyInstance): QueryJobsDbManager {
const sqlDbConnPool = fastify.mysql;
return new QueryJobsDbManager(sqlDbConnPool);
}

/**
* Submits a search job to the database.
*
* @param searchConfig The arguments for the query.
* @return The job's ID.
* @throws {Error} on error.
*/
async submitSearchJob (searchConfig: object): Promise<number> {
const [queryInsertResults] = await this.#sqlDbConnPool.query<ResultSetHeader>(
`INSERT INTO ${settings.SqlDbQueryJobsTableName}
(${QUERY_JOBS_TABLE_COLUMN_NAMES.JOB_CONFIG},
${QUERY_JOBS_TABLE_COLUMN_NAMES.TYPE})
VALUES (?, ?)`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`INSERT INTO ${settings.SqlDbQueryJobsTableName}
(${QUERY_JOBS_TABLE_COLUMN_NAMES.JOB_CONFIG},
${QUERY_JOBS_TABLE_COLUMN_NAMES.TYPE})
VALUES (?, ?)`,
`
INSERT INTO ${settings.SqlDbQueryJobsTableName} (
${QUERY_JOBS_TABLE_COLUMN_NAMES.JOB_CONFIG},
${QUERY_JOBS_TABLE_COLUMN_NAMES.TYPE}
)
VALUES (?, ?)
`,

[
Buffer.from(encode(searchConfig)),
QUERY_JOB_TYPE.SEARCH_OR_AGGREGATION,
]
);

return queryInsertResults.insertId;
}

/**
* Submits an aggregation job to the database.
*
* @param searchConfig The arguments for the query.
* @param timeRangeBucketSizeMillis
* @return The aggregation job's ID.
* @throws {Error} on error.
*/
async submitAggregationJob (
searchConfig: object,
timeRangeBucketSizeMillis: number
): Promise<number> {
const searchAggregationConfig = {
...searchConfig,
aggregation_config: {
count_by_time_bucket_size: timeRangeBucketSizeMillis,
},
};

return await this.submitSearchJob(searchAggregationConfig);
}

/**
* Submits a query cancellation request to the database.
*
* @param jobId ID of the job to cancel.
* @return
* @throws {Error} on error.
*/
async submitQueryCancellation (jobId: number): Promise<void> {
await this.#sqlDbConnPool.query(
`UPDATE ${settings.SqlDbQueryJobsTableName}
SET ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS} = ${QUERY_JOB_STATUS.CANCELLING}
WHERE ${QUERY_JOBS_TABLE_COLUMN_NAMES.ID} = ?
AND ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS}
IN (${QUERY_JOB_STATUS.PENDING}, ${QUERY_JOB_STATUS.RUNNING})`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`UPDATE ${settings.SqlDbQueryJobsTableName}
SET ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS} = ${QUERY_JOB_STATUS.CANCELLING}
WHERE ${QUERY_JOBS_TABLE_COLUMN_NAMES.ID} = ?
AND ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS}
IN (${QUERY_JOB_STATUS.PENDING}, ${QUERY_JOB_STATUS.RUNNING})`,
`
UPDATE ${settings.SqlDbQueryJobsTableName}
SET ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS} = ${QUERY_JOB_STATUS.CANCELLING}
WHERE ${QUERY_JOBS_TABLE_COLUMN_NAMES.ID} = ?
AND ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS}
IN (${QUERY_JOB_STATUS.PENDING}, ${QUERY_JOB_STATUS.RUNNING})
`,

jobId,
);
}

/**
* Waits for the job to complete.
*
* @param jobId
* @return
* @throws {Error} on MySQL error, if the job wasn't found in the database, if the job was
* cancelled, or if the job completed in an unexpected state.
*/
async awaitJobCompletion (jobId: number): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
Comment on lines +117 to +118
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Consider using a condition in the while loop.

The infinite loop with a break statement could be replaced with a conditional while loop for better readability.

-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
-        while (true) {
+        let jobCompleted = false;
+        while (!jobCompleted) {

And then replace the break; on line 151 with:

-                break;
+                jobCompleted = true;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
- while (true) {
+ let jobCompleted = false;
+ while (!jobCompleted) {
@@
- break;
+ jobCompleted = true;
🤖 Prompt for AI Agents
In
components/log-viewer-webui/server/src/fastify-v2/plugins/app/search/QueryJobsDbManager/index.ts
around lines 111 to 112, replace the infinite while(true) loop with a while loop
that uses an explicit condition to control the loop execution. Identify the
condition that currently leads to the break statement on line 151 and use that
condition directly in the while loop. Remove the break statement on line 151
after updating the loop condition to improve readability and maintainability.

let rows: QueryJob[];
try {
const [queryRows] = await this.#sqlDbConnPool.query<QueryJob[]>(
`
SELECT ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS}
FROM ${settings.SqlDbQueryJobsTableName}
WHERE ${QUERY_JOBS_TABLE_COLUMN_NAMES.ID} = ?
`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`
SELECT ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS}
FROM ${settings.SqlDbQueryJobsTableName}
WHERE ${QUERY_JOBS_TABLE_COLUMN_NAMES.ID} = ?
`,
`
SELECT ${QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS}
FROM ${settings.SqlDbQueryJobsTableName}
WHERE ${QUERY_JOBS_TABLE_COLUMN_NAMES.ID} = ?
`,

jobId
);

rows = queryRows;
} catch (e: unknown) {
let errorMessage: string;

if (e instanceof Error) {
errorMessage = e.message;
} else {
errorMessage = String(e);
}

throw new Error(`Failed to query status for job ${jobId} - ${errorMessage}`);
Copy link
Contributor

@hoophalab hoophalab May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} catch (e: unknown) {
let errorMessage: string;
if (e instanceof Error) {
errorMessage = e.message;
} else {
errorMessage = String(e);
}
throw new Error(`Failed to query status for job ${jobId} - ${errorMessage}`);
} catch (e: unknown) {
console.error("Failed to query status for job ${jobId}")
throw e;
}

Current solution removes error's stack trace, which makes the error hard to debug. I prefer two solutions:

  1. simply console.error
  2. create a class JobQueryError { constructor(message, jobId, rawError)}

e 's info is not discarded in both way

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree we do not want to discard the stack trace in the original Error. how about

throw new Error("Failed to query status for job ${jobId}", { cause: e });

}
if (0 === rows.length) {
throw new Error(`Job ${jobId} not found in database.`);
}

const status = (rows[0] as QueryJob)[QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about:

let queryJob: QueryJob | undefined;
try {
    ...
    [queryJob] = queryRows;
} catch () {...}

if ("undefined" === typeof queryJob) {
    throw new Error(`Job ${jobId} not found in database.`);
}

const status = queryJob[QUERY_JOBS_TABLE_COLUMN_NAMES.STATUS];
...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ill try something like this


if (false === QUERY_JOB_STATUS_WAITING_STATES.has(status)) {
if (QUERY_JOB_STATUS.CANCELLED === status) {
throw new Error(`Job ${jobId} was cancelled.`);
} else if (QUERY_JOB_STATUS.SUCCEEDED !== status) {
throw new Error(
`Job ${jobId} exited with unexpected status=${status}: ` +
`${Object.keys(QUERY_JOB_STATUS)[status]}.`
);
}
break;
}

await setTimeout(JOB_COMPLETION_STATUS_POLL_INTERVAL_MILLIS);
}
}
}

declare module "fastify" {
export interface FastifyInstance {
QueryJobsDbManager: QueryJobsDbManager;
}
}

export default fp(
(fastify) => {
fastify.decorate("QueryJobsDbManager", QueryJobsDbManager.create(fastify));
},
{
name: "QueryJobsDbManager",
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Interval in milliseconds for polling the completion status of a job.
*/
export const JOB_COMPLETION_STATUS_POLL_INTERVAL_MILLIS = 500;
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {FastifyInstance} from "fastify";
import fp from "fastify-plugin";
import type {
Collection,
Db,
} from "mongodb";

import {Nullable} from "../../../../../typings/common.js";
import {
CollectionDroppedError,
SearchResultsDocument,
} from "./typings.js";


/**
* Class to keep track of MongoDB collections created for search jobs, ensuring all collections
* have unique names.
*/
class SearchJobCollectionsManager {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems the purpose of this plugin is really just to getOrCreateCollection then drop the collection. can we generalize this into a util function / a method in the DbManager?

Copy link
Contributor Author

@davemarco davemarco May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am planning to get rid of db manager, so i dont want to move it there. This is kind of like the dbManager for the old-webui.

This code was in the old webui, so i just tried to keep it the same. Note it wasnt immediately clear to me what is the difference between this and accessing the mongo database methods directly in the route. It appears the only difference is that this getOrCreateCollection() will throw an error if the collection was already dropped. Note we actually catch the custom error in one of the route methods updateSearchSignalWhenJobsFinish(see #913).it may be possible to get rid of this entirely and just use mongo methods directly, but i just tried to keep everything the same to avoid introducing new bugs.

Anyways let me know if we should keep it, or i should attempt to use Mongo methods in the routes.

Copy link
Member

@junhaoliao junhaoliao May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure. let's get rid of this

#jobIdToCollectionsMap: Map<string, Nullable<Collection<SearchResultsDocument>>> =
new Map();

#db: Db;

private constructor (db: Db) {
this.#db = db;
}

/**
* Creates a new SearchJobCollectionsManager.
*
* @param fastify
* @return
* @throws {Error} if the MongoDB database is not found.
*/
static create (fastify: FastifyInstance): SearchJobCollectionsManager {
if ("undefined" === typeof fastify.mongo.db) {
throw new Error("MongoDB database not found");
}

return new SearchJobCollectionsManager(fastify.mongo.db);
}

/**
* Gets, or if it doesn't exist, creates a MongoDB collection named with the given job ID.
*
* @param jobId
* @return MongoDB collection
* @throws {CollectionDroppedError} if the collection was already dropped.
*/
async getOrCreateCollection (jobId: number): Promise<Collection<SearchResultsDocument>> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is this used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see #913, you will see where both methods are used

const name = jobId.toString();
if (false === this.#jobIdToCollectionsMap.has(name)) {
this.#jobIdToCollectionsMap.set(name, this.#db.collection(name));
} else if (null === this.#jobIdToCollectionsMap.get(name)) {
throw new CollectionDroppedError(name);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (false === this.#jobIdToCollectionsMap.has(name)) {
this.#jobIdToCollectionsMap.set(name, this.#db.collection(name));
} else if (null === this.#jobIdToCollectionsMap.get(name)) {
throw new CollectionDroppedError(name);
const collection = this.#jobIdToCollectionsMap.get(name)
if ("undefined" === typeof collection) {
this.#jobIdToCollectionsMap.set(name, this.#db.collection(name));
} else if (null === collection) {
throw new CollectionDroppedError(name);

}


// `collections.get(name)` will always return a valid collection since:
// - Function creates a new collection if it doesn't exist in map.
// - Throws an error if the collection was already dropped.
// Therefore, we can safely cast to `Collection<SearchResultsDocument>`.
return this.#jobIdToCollectionsMap.get(name) as Collection<SearchResultsDocument>;
}

/**
* Drops the MongoDB collection associated with the given job ID.
*
* @param jobId
* @throws {Error} if the collection does not exist or has already been dropped.
*/
async dropCollection (jobId: number) {
const name = jobId.toString();
const collection = this.#jobIdToCollectionsMap.get(name);
if ("undefined" === typeof collection || null === collection) {
throw new Error(`Collection ${name} not found`);
Copy link
Contributor

@hoophalab hoophalab May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using CollectionNotFoundError

Copy link
Contributor Author

@davemarco davemarco May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are only using the custom error earlier, since it is caught later in the route see #913 . I dont want to add a custom error if not explicity used.

}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ("undefined" === typeof collection || null === collection) {
throw new Error(`Collection ${name} not found`);
}
if ("undefined" === typeof collection) {
throw new Error(`Collection ${name} not found`);
} else if (null === collection) {
throw new CollectionDroppedError(...);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure i guess this is okay, but i dont believe this error will be caught anywhere. I can make the change though


await collection.drop();
this.#jobIdToCollectionsMap.set(name, null);
}
}

declare module "fastify" {
export interface FastifyInstance {
SearchJobCollectionsManager: SearchJobCollectionsManager;
}
}

export default fp(
(fastify) => {
fastify.decorate(
"SearchJobCollectionsManager",
SearchJobCollectionsManager.create(fastify)
);
},
{
name: "SearchJobCollectionsManager",
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* MongoDB document for search results.
*/
interface SearchResultsDocument {
_id: string;
orig_file_id: string;
orig_file_path: string;
log_event_ix: number;
timestamp: number;
message: string;
}

/**
* Error thrown when a MongoDB collection has been dropped unexpectedly.
*/
class CollectionDroppedError extends Error {
constructor (collectionName: string) {
super(`Collection ${collectionName} has been dropped.`);
this.name = "CollectionDroppedError";
}
}

export {
CollectionDroppedError, SearchResultsDocument,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import settings from "@settings" with {type: "json"};
import {FastifyInstance} from "fastify";
import fp from "fastify-plugin";

import type {SearchResultsMetadataDocument} from "./typings.js";


/**
* Creates a MongoDB collection for search results metadata.
*
* @param fastify
* @return MongoDB collection
* @throws {Error} if the MongoDB database is not found.
Comment on lines +12 to +13
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Enhance JSDoc return type documentation.

The return type in the JSDoc comment could be more specific to help developers understand what's being returned.

- * @return MongoDB collection
+ * @return {Collection<SearchResultsMetadataDocument>} MongoDB collection for search results metadata
🤖 Prompt for AI Agents
In
components/log-viewer-webui/server/src/fastify-v2/plugins/app/search/SearchResultsMetadataCollection/index.ts
around lines 12 to 13, the JSDoc return type is currently generic. Update the
@return tag to specify the exact MongoDB collection type being returned, such as
including the document interface or schema type, to provide clearer and more
precise documentation for developers.

*/
const createSearchResultsMetadataCollection = (fastify: FastifyInstance) => {
if ("undefined" === typeof fastify.mongo.db) {
throw new Error("MongoDB database not found");
}

return fastify.mongo.db.collection<SearchResultsMetadataDocument>(
settings.MongoDbSearchResultsMetadataCollectionName
);
};

declare module "fastify" {
export interface FastifyInstance {
SearchResultsMetadataCollection: ReturnType<typeof createSearchResultsMetadataCollection>;
}
}

export default fp(
(fastify) => {
fastify.decorate(
"SearchResultsMetadataCollection",
createSearchResultsMetadataCollection(fastify)
);
},
{
name: "SearchResultsMetadataCollection",
}
);
Loading
Loading