Skip to content

Commit 975d22c

Browse files
committed
✨ feat(api): enhance job management in ScrapeController and SearchController; implement per-page result handling and logging for job status updates in SearchService.
1 parent c0a211b commit 975d22c

6 files changed

Lines changed: 166 additions & 14 deletions

File tree

apps/api/src/controllers/v1/ScrapeController.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,28 @@ export class ScrapeController {
1111
try {
1212
// Validate request body and transform it to the job payload structure
1313
const jobPayload = scrapeSchema.parse(req.body);
14+
// Keep engine name available for error paths (e.g., timeout) before awaiting
15+
engineName = jobPayload.engine;
1416

15-
jobId = await QueueManager.getInstance().addJob(`scrape-${jobPayload.engine}`, jobPayload);
17+
jobId = await QueueManager.getInstance().addJob(`scrape-${engineName}`, jobPayload);
1618
await createJob({
1719
job_id: jobId,
1820
job_type: 'scrape',
19-
job_queue_name: `scrape-${jobPayload.engine}`,
21+
job_queue_name: `scrape-${engineName}`,
2022
url: jobPayload.url,
2123
req,
2224
status: STATUS.PENDING,
2325
});
2426
// Propagate jobId for downstream middlewares (e.g., credits logging)
2527
req.jobId = jobId;
2628
// waiting job done
27-
const job = await QueueManager.getInstance().waitJobDone(`scrape-${jobPayload.engine}`, jobId, jobPayload.options.timeout || 60_000);
29+
const job = await QueueManager.getInstance().waitJobDone(`scrape-${engineName}`, jobId, jobPayload.options.timeout || 60_000);
2830
const { uniqueKey, queueName, options, engine, ...jobData } = job;
2931
// for failed job to cancel the job in the queue
30-
engineName = engine;
3132
// Check if job failed
3233
if (job.status === 'failed' || job.error) {
3334
const message = job.message || "The scraping task could not be completed";
34-
await QueueManager.getInstance().cancelJob(`scrape-${jobPayload.engine}`, jobId);
35+
await QueueManager.getInstance().cancelJob(`scrape-${engineName}`, jobId);
3536
await failedJob(jobId, message, false, { total: 1, completed: 0, failed: 1 });
3637
res.status(200).json({
3738
success: false,
@@ -90,8 +91,15 @@ export class ScrapeController {
9091
} else {
9192
const message = error instanceof Error ? error.message : "Unknown error occurred";
9293
if (jobId) {
93-
await QueueManager.getInstance().cancelJob(`scrape-${engineName}`, jobId);
94-
await failedJob(jobId, message, false, { total: 1, completed: 0, failed: 1 });
94+
// Best-effort cancel; do not block failed marking if cancel throws
95+
try {
96+
if (engineName) {
97+
await QueueManager.getInstance().cancelJob(`scrape-${engineName}`, jobId);
98+
}
99+
} catch { /* ignore cancel errors */ }
100+
try {
101+
await failedJob(jobId, message, false, { total: 1, completed: 0, failed: 1 });
102+
} catch { /* ignore DB errors to still return response */ }
95103
}
96104
res.status(500).json({
97105
success: false,

apps/api/src/controllers/v1/SearchController.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { SearchService } from "@anycrawl/search/SearchService";
44
import { log } from "@anycrawl/libs/log";
55
import { searchSchema } from "../../types/SearchSchema.js";
66
import { RequestWithAuth } from "../../types/Types.js";
7+
import { randomUUID } from "crypto";
8+
import { STATUS, createJob, insertJobResult, completedJob, failedJob, updateJobCounts, JOB_RESULT_STATUS } from "@anycrawl/db";
79
export class SearchController {
810
private searchService: SearchService;
911

@@ -26,21 +28,86 @@ export class SearchController {
2628
}
2729

2830
public handle = async (req: RequestWithAuth, res: Response): Promise<void> => {
31+
let jobId: string | null = null;
32+
let engineName: string | null = null;
2933
try {
3034
// Validate request body against searchSchema
3135
const validatedData = searchSchema.parse(req.body);
3236

3337
// Execute search and wait for results
34-
const results = await this.searchService.search(validatedData.engine ?? "google", {
38+
engineName = validatedData.engine ?? "google";
39+
40+
// Create job for search request (pending)
41+
jobId = randomUUID();
42+
await createJob({
43+
job_id: jobId,
44+
job_type: "search",
45+
job_queue_name: `search-${engineName}`,
46+
url: `search:${validatedData.query}`,
47+
req,
48+
status: STATUS.PENDING,
49+
});
50+
req.jobId = jobId;
51+
52+
const expectedPages = validatedData.pages || 1;
53+
let pagesProcessed = 0;
54+
let failedPages = 0;
55+
let successPages = 0;
56+
57+
const results = await this.searchService.search(engineName, {
3558
query: validatedData.query,
3659
limit: validatedData.limit || 10,
3760
offset: validatedData.offset || 0,
38-
pages: validatedData.pages || 1,
61+
pages: expectedPages,
3962
lang: validatedData.lang,
4063
// country: validatedData.country,
64+
}, async (page, pageResults, _uniqueKey, success) => {
65+
try {
66+
pagesProcessed += 1;
67+
if (!success) {
68+
failedPages += 1;
69+
// Record a failed page entry (single record per page)
70+
await insertJobResult(
71+
jobId!,
72+
`search:${engineName}:${validatedData.query}:page:${page}`,
73+
{ page, query: validatedData.query, results: [] },
74+
JOB_RESULT_STATUS.FAILED
75+
);
76+
} else {
77+
successPages += 1;
78+
// Insert a single record for this page with aggregated results
79+
await insertJobResult(
80+
jobId!,
81+
`search:${engineName}:${validatedData.query}:page:${page}`,
82+
{ page, query: validatedData.query, results: pageResults },
83+
JOB_RESULT_STATUS.SUCCESS
84+
);
85+
}
86+
87+
// Update job counts based on pages for progress
88+
await updateJobCounts(jobId!, { total: expectedPages, completed: successPages, failed: failedPages });
89+
} catch (e) {
90+
log.error(`Per-page handler error for job_id=${jobId}: ${e instanceof Error ? e.message : String(e)}`);
91+
}
4192
});
4293
// credits used is the number of pages.
43-
req.creditsUsed = validatedData.pages;
94+
req.creditsUsed = validatedData.pages ?? 1;
95+
96+
// Mark job status based on page results
97+
try {
98+
if (failedPages >= expectedPages) {
99+
await failedJob(
100+
jobId,
101+
`All pages failed (${failedPages}/${expectedPages})`,
102+
false,
103+
{ total: expectedPages, completed: successPages, failed: failedPages }
104+
);
105+
} else {
106+
await completedJob(jobId, true, { total: expectedPages, completed: successPages, failed: failedPages });
107+
}
108+
} catch (e) {
109+
log.error(`Failed to mark job final status for job_id=${jobId}: ${e instanceof Error ? e.message : String(e)}`);
110+
}
44111
res.json({
45112
success: true,
46113
data: results,
@@ -62,6 +129,13 @@ export class SearchController {
62129
},
63130
});
64131
} else {
132+
if (jobId) {
133+
try {
134+
await failedJob(jobId, error instanceof Error ? error.message : "Unknown error", false, { total: 0, completed: 0, failed: 0 });
135+
} catch (e) {
136+
log.error(`Failed to mark job failed for job_id=${jobId}: ${e instanceof Error ? e.message : String(e)}`);
137+
}
138+
}
65139
res.status(500).json({
66140
success: false,
67141
error: "Internal server error",

apps/api/src/types/BaseSchema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const baseSchema = z.object({
5757
/**
5858
* The timeout to be used
5959
*/
60-
timeout: z.number().min(1000).max(600_000).default(60_000),
60+
timeout: z.number().min(1000).max(600_000).default(300_000),
6161

6262
/**
6363
* The wait for to be used

packages/db/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { eq, and, gt, gte, sql } from "drizzle-orm";
22
import { getDB, schemas } from "./db/index.js";
3-
import { STATUS } from "./map.js";
3+
import { STATUS, JOB_RESULT_STATUS } from "./map.js";
44
import { Job, CreateJobParams } from "./model/Job.js";
55

66
// Backward compatibility functions
@@ -14,6 +14,7 @@ export const insertJobResult = Job.insertJobResult;
1414
export const getJobResults = Job.getJobResults;
1515
export const getJobResultsPaginated = Job.getJobResultsPaginated;
1616
export const getJobResultsCount = Job.getJobResultsCount;
17+
export const updateJobCounts = Job.updateCounts;
1718

18-
export { eq, and, gt, gte, sql, getDB, schemas, STATUS, Job };
19+
export { eq, and, gt, gte, sql, getDB, schemas, STATUS, JOB_RESULT_STATUS, Job };
1920
export type { CreateJobParams };

packages/db/src/model/Job.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { asc } from "drizzle-orm";
22
import { getDB, schemas, eq, STATUS, sql } from "../index.js";
33
import { JOB_RESULT_STATUS, JobResultStatus } from "../map.js";
4+
import { log } from "@anycrawl/libs/log";
45

56
export interface CreateJobParams {
67
job_id: string;
@@ -72,6 +73,10 @@ export class Job {
7273
createdAt: new Date(),
7374
updatedAt: new Date(),
7475
});
76+
77+
try {
78+
log.info(`[DB][Job] Created job_id=${job_id} type=${job_type} queue=${job_queue_name} origin=${origin} apiKey=${api_key_id ?? "none"}`);
79+
} catch { }
7580
}
7681

7782
/**
@@ -95,8 +100,14 @@ export class Job {
95100
const job = await Job.get(job_id);
96101
if (job) {
97102
await db.update(schemas.jobs).set({ status: STATUS.CANCELLED }).where(eq(schemas.jobs.jobId, job_id));
103+
try {
104+
log.info(`[DB][Job] Cancelled job_id=${job_id} status=${STATUS.CANCELLED}`);
105+
} catch { }
98106
return job;
99107
}
108+
try {
109+
log.warning(`[DB][Job] Cancel attempt for nonexistent job_id=${job_id}`);
110+
} catch { }
100111
return null;
101112
}
102113

@@ -112,6 +123,9 @@ export class Job {
112123
} else {
113124
await db.update(schemas.jobs).set({ status: status }).where(eq(schemas.jobs.jobId, job_id));
114125
}
126+
try {
127+
log.info(`[DB][Job] Updated status job_id=${job_id} status=${status}${isSuccess !== null ? ` isSuccess=${isSuccess}` : ""}`);
128+
} catch { }
115129
}
116130

117131
/**
@@ -132,6 +146,11 @@ export class Job {
132146
...(counts?.failed !== undefined ? { failed: counts.failed } : {}),
133147
updatedAt: new Date(),
134148
}).where(eq(schemas.jobs.jobId, jobId));
149+
150+
try {
151+
const countsInfo = `total=${counts?.total ?? "-"} completed=${counts?.completed ?? "-"} failed=${counts?.failed ?? "-"}`;
152+
log.info(`[DB][Job] Marked completed job_id=${jobId} isSuccess=${isSuccess} ${countsInfo}`);
153+
} catch { }
135154
}
136155

137156
/**
@@ -162,6 +181,11 @@ export class Job {
162181
...(counts?.failed !== undefined ? { failed: counts.failed } : {}),
163182
updatedAt: new Date(),
164183
}).where(eq(schemas.jobs.jobId, jobId));
184+
185+
try {
186+
const countsInfo = `total=${counts?.total ?? "-"} completed=${counts?.completed ?? "-"} failed=${counts?.failed ?? "-"}`;
187+
log.error(`[DB][Job] Marked failed job_id=${jobId} message="${errorMessage}" ${countsInfo}`);
188+
} catch { }
165189
}
166190

167191
/**
@@ -188,6 +212,11 @@ export class Job {
188212
createdAt: new Date(),
189213
updatedAt: new Date(),
190214
});
215+
216+
try {
217+
const dataType = data === null || data === undefined ? "null" : Array.isArray(data) ? `array(len=${data.length})` : typeof data;
218+
log.info(`[DB][JobResult] Inserted result job_id=${jobId} url=${url} status=${status} dataType=${dataType}`);
219+
} catch { }
191220
}
192221

193222
/**
@@ -248,4 +277,22 @@ export class Job {
248277
const count = rows?.[0]?.count as unknown as number;
249278
return Number(count || 0);
250279
}
280+
281+
/**
282+
* Update job counts without changing status
283+
*/
284+
public static async updateCounts(jobId: string, counts: { total?: number; completed?: number; failed?: number }) {
285+
const db = await getDB();
286+
await db.update(schemas.jobs).set({
287+
...(counts.total !== undefined ? { total: counts.total } : {}),
288+
...(counts.completed !== undefined ? { completed: counts.completed } : {}),
289+
...(counts.failed !== undefined ? { failed: counts.failed } : {}),
290+
updatedAt: new Date(),
291+
}).where(eq(schemas.jobs.jobId, jobId));
292+
293+
try {
294+
const countsInfo = `total=${counts.total ?? "-"} completed=${counts.completed ?? "-"} failed=${counts.failed ?? "-"}`;
295+
log.info(`[DB][Job] Updated counts job_id=${jobId} ${countsInfo}`);
296+
} catch { }
297+
}
251298
}

packages/search/src/SearchService.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ export class SearchService {
1111
private pendingRequests: Map<string, number>;
1212
private crawler: Engine | null = null;
1313
private searchQueue: any | null = null;
14+
private requestsToOnPage: Map<string, (page: number, results: SearchResult[], uniqueKey: string, success: boolean) => void>;
1415

1516
constructor() {
1617
this.engines = new Map();
1718
this.requestsToResponses = new Map();
1819
this.partialResults = new Map();
1920
this.pendingRequests = new Map();
21+
this.requestsToOnPage = new Map();
2022
}
2123

2224
private createEngine(name: string): SearchEngine {
@@ -65,6 +67,12 @@ export class SearchService {
6567
const results = await engine.parse(html, request);
6668
log.info(`Parsed results: ${results.length}`);
6769

70+
const pageNumber = request.userData.page ?? 1;
71+
72+
// Per-page callback
73+
const onPageCb = this.requestsToOnPage.get(uniqueKey);
74+
if (onPageCb) onPageCb(pageNumber, results, uniqueKey, true);
75+
6876
// Accumulate results
6977
const currentResults = this.partialResults.get(uniqueKey) || [];
7078
this.partialResults.set(uniqueKey, [...currentResults, ...results]);
@@ -83,6 +91,7 @@ export class SearchService {
8391
this.requestsToResponses.delete(uniqueKey);
8492
this.partialResults.delete(uniqueKey);
8593
this.pendingRequests.delete(uniqueKey);
94+
this.requestsToOnPage.delete(uniqueKey);
8695
}
8796
}
8897
} catch (error) {
@@ -97,6 +106,12 @@ export class SearchService {
97106
const uniqueKey = request.userData.uniqueKey;
98107
log.error(`Failed to process ${request.url}:`, error);
99108

109+
const pageNumber = request.userData.page ?? 1;
110+
111+
// Per-page callback (failure)
112+
const onPageCb = this.requestsToOnPage.get(uniqueKey);
113+
if (onPageCb) onPageCb(pageNumber, [], uniqueKey, false);
114+
100115
// Decrement pending requests even for failed requests
101116
const pendingCount = this.pendingRequests.get(uniqueKey) || 0;
102117
const newPendingCount = pendingCount - 1;
@@ -113,6 +128,7 @@ export class SearchService {
113128
this.requestsToResponses.delete(uniqueKey);
114129
this.partialResults.delete(uniqueKey);
115130
this.pendingRequests.delete(uniqueKey);
131+
this.requestsToOnPage.delete(uniqueKey);
116132
}
117133
}
118134
} catch (error) {
@@ -128,14 +144,19 @@ export class SearchService {
128144
}
129145
}
130146

131-
async search(engineName: string, options: SearchOptions): Promise<SearchResult[]> {
147+
async search(
148+
engineName: string,
149+
options: SearchOptions,
150+
onPage?: (page: number, results: SearchResult[], uniqueKey: string, success: boolean) => void,
151+
): Promise<SearchResult[]> {
132152
log.info("Search called with options:", options);
133153
return new Promise(async (resolve) => {
134154
const uniqueKey = randomUUID();
135155
log.info(`Created uniqueKey for search: ${uniqueKey}`);
136156
this.requestsToResponses.set(uniqueKey, resolve);
137157
this.partialResults.set(uniqueKey, []);
138158
this.pendingRequests.set(uniqueKey, options.pages ?? 1);
159+
if (onPage) this.requestsToOnPage.set(uniqueKey, onPage);
139160

140161
await this.executeSearch(engineName, options, uniqueKey);
141162
});
@@ -205,4 +226,5 @@ export class SearchService {
205226
}
206227
}
207228
}
229+
208230
}

0 commit comments

Comments
 (0)