Skip to content

Commit 1dbdfd9

Browse files
authored
Merge pull request #470 from shamanic-technologies/main
chore: sync hotfix v0.39.88 to staging
2 parents 7c34380 + e3be0c1 commit 1dbdfd9

8 files changed

Lines changed: 280 additions & 46 deletions

File tree

‎CLAUDE.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,9 @@ window. `src/lib/linkedin-engagement-spec.ts` (pure) + `src/lib/linkedin-engagem
438438
A dead page (`linkedin_company_pages.posts_status = not_found`, re-checked after
439439
24h) is SKIPPED with a loud log + warn trace, the other pages served; every page
440440
dead = 422 `competitor_pages_unreadable` (all providers down = 502, retryable).
441+
A page every provider FAILED on (`posts_failed_at`, BOX-LOAD-1028: diffco-us drew
442+
~1,000 reads/h from lead-service's serve loop) is skipped WITHOUT a call for
443+
`POSTS_FAILED_RETRY_MS` (1h), same answer; an unreadable 200/4xx stops the chain.
441444
No provider resolves `showcase/eimmigration` (all three probed).
442445
- **`doneReason` says WHY a serve is done** (`/search/next`, engagement only, null
443446
while `done=false`): `no_recent_posts` = no competitor page has a post published
@@ -1137,6 +1140,9 @@ campaign. features-service prices workflows from runs, so it read 11x ROI.
11371140
`actualCostInUsdCents`, descendants included. Other-org spend counts (the
11381141
reveal cache is fleet-wide) and is split out as `paidByOtherOrgsCostInUsdCents`.
11391142
A paying run runs-service does not return lands in `unpricedRunIds`, never as 0.
1143+
- **Fast (BOX-LOAD-1028, was ~1.6 s mean, the top apollo statement):** each person's
1144+
paid rows are kept in memory per key (person id / email) for 10 min, empty included;
1145+
each SQL branch hits a covering index (migration 0047). Do not add a branch without one.
11401146
- **Nothing is billed, declared or moved.** Declaring the cost again on the
11411147
serving run would bill the org twice; the earlier payer stays the payer.
11421148
- Prod replay 2026-10-17 (aldhanab campaigns a41c4e52 + 3de6bdb2): 382 served,
@@ -1504,9 +1510,9 @@ paying (/enrich, /match, firmographics, job postings, audience companies, Linked
15041510
pages). A new caller follows the same shape: a lock taken before the cache check
15051511
makes every hit a transaction + lock, and a waiter holds a pool connection and a
15061512
Postgres backend for the whole wait. Where a miss is slow and NEVER cached (a
1507-
LinkedIn page no provider can read is re-read by every serve, ~2.4 s under the
1508-
lock), wrap the locked part in `singleFlight(key, …)`: concurrent callers in this
1509-
process share the one read; a follower whose leader threw runs its own.
1513+
LinkedIn page every provider fails on, before it is parked), wrap the locked part
1514+
in `singleFlight(key, …)`: concurrent callers in this process share the one read; a
1515+
follower whose leader threw runs its own.
15101516

15111517
## U+0000 in provider data is stripped at the DRIVER, never per field
15121518

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- BOX-LOAD-1028-APOLLO-WASTE.
2+
-- (1) A competitor page every posts provider failed on is remembered for a short
3+
-- backoff (POSTS_FAILED_RETRY_MS) instead of being re-read by every serve: diffco-us
4+
-- drew ~1,000 failed reads an hour (2026-10-27/28, ~1.2 s each, tikhub 400).
5+
ALTER TABLE "linkedin_company_pages" ADD COLUMN IF NOT EXISTS "posts_failed_at" timestamp with time zone;
6+
--> statement-breakpoint
7+
ALTER TABLE "linkedin_company_pages" ADD COLUMN IF NOT EXISTS "posts_failure" text;
8+
--> statement-breakpoint
9+
-- (2) POST /internal/acquisition-costs: covering indexes matching each branch's
10+
-- predicate, so a lookup never reads the wide heap (~1.6 s mean per call before,
11+
-- mostly heap reads of apollo_people_enrichments and email_verifications).
12+
CREATE INDEX IF NOT EXISTS idx_enrichments_person_acq ON apollo_people_enrichments (apollo_person_id) INCLUDE (enrichment_run_id, org_id) WHERE enrichment_run_id IS NOT NULL;
13+
--> statement-breakpoint
14+
CREATE INDEX IF NOT EXISTS idx_enrichments_email_norm_acq ON apollo_people_enrichments (lower(btrim(email))) INCLUDE (enrichment_run_id, org_id) WHERE email IS NOT NULL AND enrichment_run_id IS NOT NULL;
15+
--> statement-breakpoint
16+
CREATE INDEX IF NOT EXISTS idx_email_verifications_billed_acq ON email_verifications (email) INCLUDE (verify_run_id, org_id) WHERE billed AND verify_run_id IS NOT NULL;

‎drizzle/meta/_journal.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,13 @@
330330
"when": 1781395200000,
331331
"tag": "0046_enrichments_email_norm",
332332
"breakpoints": true
333+
},
334+
{
335+
"idx": 47,
336+
"version": "7",
337+
"when": 1781481600000,
338+
"tag": "0047_box_load_apollo_waste",
339+
"breakpoints": true
333340
}
334341
]
335342
}

‎src/db/schema.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,11 @@ export const linkedinCompanyPages = pgTable("linkedin_company_pages", {
720720
// "ok" | "not_found" (no posts provider knows the page; re-checked after the refresh period). null = ok (pre-0036 rows).
721721
postsStatus: text("posts_status"),
722722
postsError: text("posts_error"),
723+
// Last time EVERY posts provider failed on the page (none said "not found" for all,
724+
// none answered posts): the page is skipped without a provider call until
725+
// POSTS_FAILED_RETRY_MS has passed (migration 0047). posts_failure = that read's reason.
726+
postsFailedAt: timestamp("posts_failed_at", { withTimezone: true }),
727+
postsFailure: text("posts_failure"),
723728
});
724729

725730
// Silver: one post of a company page. `published_at` is APPROXIMATE (LinkedIn

‎src/lib/acquisition-costs.ts‎

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -178,40 +178,101 @@ export function attributeAcquisitions(
178178
}
179179

180180
interface RawRow {
181-
idx: number | string;
181+
by: "pid" | "email";
182+
key: string;
182183
kind: AcquisitionKind;
183184
run_id: string;
184185
org_id: string;
185186
}
186187

188+
type FoundAcquisition = Omit<AcquisitionRow, "idx">;
189+
190+
/**
191+
* BOX-LOAD-1028: lead-service re-asks the same orgs' whole served stock every few
192+
* minutes (features-service polls it), ~1,300 people per call, and this lookup was
193+
* the biggest apollo statement on the box (~1.6 s mean, 14k calls in 40 h). What an
194+
* EARLIER run paid for a person only grows by new acquisitions, which start after
195+
* the serves already asked about, so a person's paid rows are kept per lookup key
196+
* (apollo person id / normalized email) for ACQUISITION_ROWS_TTL_MS, empty answers
197+
* included, and only keys not seen recently go to Postgres. Same TTL as the run
198+
* prices (RUN_COST_TTL_MS): a new acquisition shows within 10 minutes.
199+
*/
200+
export const ACQUISITION_ROWS_TTL_MS = 10 * 60_000;
201+
const ACQUISITION_ROWS_CACHE_MAX = 200_000;
202+
const acquisitionRowsCache = new Map<string, { rows: FoundAcquisition[]; until: number }>();
203+
const pidKey = (pid: string) => `pid:${pid}`;
204+
const emailKey = (email: string) => `email:${email}`;
205+
206+
/** Test seam. */
207+
export function __resetAcquisitionRowsCache(): void {
208+
acquisitionRowsCache.clear();
209+
}
210+
187211
/** Every PAID acquisition of the asked people in our tables, any org. */
188212
export async function findAcquisitionRows(people: ServedLead[]): Promise<AcquisitionRow[]> {
189-
const asked = people.map((p, idx) => ({ idx, pid: p.apolloPersonId ?? null, email: normEmail(p.email) }));
190-
const rows = (await db.execute(sql`
191-
WITH asked AS (
192-
SELECT * FROM jsonb_to_recordset(${JSON.stringify(asked)}::jsonb) AS a(idx int, pid text, email text)
193-
)
194-
SELECT a.idx, 'apollo_reveal' AS kind, e.enrichment_run_id AS run_id, e.org_id::text AS org_id
195-
FROM asked a JOIN apollo_people_enrichments e ON e.apollo_person_id = a.pid
196-
WHERE a.pid IS NOT NULL AND e.enrichment_run_id IS NOT NULL
197-
UNION
198-
SELECT a.idx, 'apollo_reveal', e.enrichment_run_id, e.org_id::text
199-
FROM asked a JOIN apollo_people_enrichments e ON lower(btrim(e.email)) = a.email
200-
WHERE a.email IS NOT NULL AND e.enrichment_run_id IS NOT NULL
201-
UNION
202-
SELECT a.idx, 'email_find', f.find_run_id, f.org_id::text
203-
FROM asked a JOIN email_findings f ON f.apollo_person_id = a.pid
204-
WHERE a.pid IS NOT NULL AND f.find_run_id IS NOT NULL
205-
UNION
206-
SELECT a.idx, 'email_find', f.find_run_id, f.org_id::text
207-
FROM asked a JOIN email_findings f ON lower(btrim(f.email)) = a.email
208-
WHERE a.email IS NOT NULL AND f.find_run_id IS NOT NULL
209-
UNION
210-
SELECT a.idx, 'email_verification', v.verify_run_id, v.org_id::text
211-
FROM asked a JOIN email_verifications v ON v.email = a.email
212-
WHERE a.email IS NOT NULL AND v.billed AND v.verify_run_id IS NOT NULL
213-
`)) as unknown as RawRow[];
214-
return rows.map((r) => ({ idx: Number(r.idx), kind: r.kind, runId: r.run_id, orgId: r.org_id }));
213+
const now = Date.now();
214+
const asked = people.map((p) => ({ pid: p.apolloPersonId ?? null, email: normEmail(p.email) }));
215+
const pids = new Set<string>();
216+
const emails = new Set<string>();
217+
for (const a of asked) {
218+
if (a.pid !== null && !((acquisitionRowsCache.get(pidKey(a.pid))?.until ?? 0) > now)) pids.add(a.pid);
219+
if (a.email !== null && !((acquisitionRowsCache.get(emailKey(a.email))?.until ?? 0) > now)) emails.add(a.email);
220+
}
221+
222+
const fresh = new Map<string, FoundAcquisition[]>();
223+
if (pids.size > 0 || emails.size > 0) {
224+
// Each branch matches on exactly the predicate of its covering index
225+
// (migration 0047), so every lookup is an index-only scan.
226+
const rows = (await db.execute(sql`
227+
WITH pids AS (
228+
SELECT value AS pid FROM jsonb_array_elements_text(${JSON.stringify([...pids])}::jsonb)
229+
), emails AS (
230+
SELECT value AS email FROM jsonb_array_elements_text(${JSON.stringify([...emails])}::jsonb)
231+
)
232+
SELECT 'pid' AS by, a.pid AS key, 'apollo_reveal' AS kind, e.enrichment_run_id AS run_id, e.org_id::text AS org_id
233+
FROM pids a JOIN apollo_people_enrichments e ON e.apollo_person_id = a.pid
234+
WHERE e.enrichment_run_id IS NOT NULL
235+
UNION
236+
SELECT 'email', a.email, 'apollo_reveal', e.enrichment_run_id, e.org_id::text
237+
FROM emails a JOIN apollo_people_enrichments e ON lower(btrim(e.email)) = a.email
238+
WHERE e.email IS NOT NULL AND e.enrichment_run_id IS NOT NULL
239+
UNION
240+
SELECT 'pid', a.pid, 'email_find', f.find_run_id, f.org_id::text
241+
FROM pids a JOIN email_findings f ON f.apollo_person_id = a.pid
242+
WHERE f.find_run_id IS NOT NULL
243+
UNION
244+
SELECT 'email', a.email, 'email_find', f.find_run_id, f.org_id::text
245+
FROM emails a JOIN email_findings f ON lower(btrim(f.email)) = a.email
246+
WHERE f.find_run_id IS NOT NULL
247+
UNION
248+
SELECT 'email', a.email, 'email_verification', v.verify_run_id, v.org_id::text
249+
FROM emails a JOIN email_verifications v ON v.email = a.email
250+
WHERE v.billed AND v.verify_run_id IS NOT NULL
251+
`)) as unknown as RawRow[];
252+
for (const pid of pids) fresh.set(pidKey(pid), []);
253+
for (const email of emails) fresh.set(emailKey(email), []);
254+
for (const r of rows) {
255+
const key = r.by === "pid" ? pidKey(r.key) : emailKey(r.key);
256+
fresh.get(key)?.push({ kind: r.kind, runId: r.run_id, orgId: r.org_id });
257+
}
258+
if (acquisitionRowsCache.size + fresh.size > ACQUISITION_ROWS_CACHE_MAX) acquisitionRowsCache.clear();
259+
const until = Date.now() + ACQUISITION_ROWS_TTL_MS;
260+
for (const [key, found] of fresh) acquisitionRowsCache.set(key, { rows: found, until });
261+
}
262+
263+
const rowsOf = (key: string): FoundAcquisition[] => fresh.get(key) ?? acquisitionRowsCache.get(key)?.rows ?? [];
264+
const out: AcquisitionRow[] = [];
265+
asked.forEach((a, idx) => {
266+
const seen = new Set<string>();
267+
const found = [...(a.pid !== null ? rowsOf(pidKey(a.pid)) : []), ...(a.email !== null ? rowsOf(emailKey(a.email)) : [])];
268+
for (const f of found) {
269+
const k = `${f.kind}:${f.runId}:${f.orgId}`;
270+
if (seen.has(k)) continue;
271+
seen.add(k);
272+
out.push({ idx, ...f });
273+
}
274+
});
275+
return out;
215276
}
216277

217278
/**

‎src/lib/linkedin-engagement.ts‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ export const PROFILE_MAX_MICRO = 5_000;
8181

8282
export const POSTS_REFRESH_MS = 24 * 3_600_000;
8383
export const ENGAGEMENT_REFRESH_MS = 24 * 3_600_000;
84+
/**
85+
* A page every provider FAILED on (not a "not found" from all of them) is skipped
86+
* without a call for this long, then read again. Before (BOX-LOAD-1028) nothing
87+
* remembered it and every serve re-read it: diffco-us drew ~1,000 failed reads an
88+
* hour from lead-service's serve loop (tikhub 400 "Request failed. Please retry").
89+
* Short on purpose: a provider blip only parks the page for an hour.
90+
*/
91+
export const POSTS_FAILED_RETRY_MS = 3_600_000;
8492
export const PROFILE_CACHE_MS = 30 * 86_400_000;
8593
/** 100 reactions + 100 comments per page; 5 pages = up to 500 of each per post per day. */
8694
export const MAX_ENGAGEMENT_PAGES_PER_POST = 5;
@@ -454,17 +462,26 @@ async function readPosts(meter: EngagementMeter, page: CompetitorPage, providers
454462
notFound.push(verdict.reason);
455463
continue;
456464
}
457-
if (verdict.kind === "fail") throw new LinkedinTregError(verdict.reason);
465+
// An answer we do not understand stops the chain (no other provider is paid) and
466+
// fails the page for this serve; it is remembered like any failure.
467+
if (verdict.kind === "fail") return { kind: "failed", reason: verdict.reason };
458468
return verdict;
459469
}
460470
if (notFound.length === providers.length) return { kind: "not_found", reason: `no posts provider knows the page: ${notFound.join(" | ")}` };
461471
return { kind: "failed", reason: `no posts provider could read the page: ${[...notFound, ...skipped].join(" | ")}` };
462472
}
463473

464474
/** The page's remembered posts answer while it is fresh; null = due for a read. */
465-
function freshPostsOutcome(row: typeof linkedinCompanyPages.$inferSelect | undefined, page: CompetitorPage, now: Date): PageOutcome | null {
466-
if (!row?.postsFetchedAt || now.getTime() - row.postsFetchedAt.getTime() >= POSTS_REFRESH_MS) return null;
467-
return row.postsStatus === "not_found" ? { page, state: "not_found", reason: row.postsError ?? "not found" } : { page, state: "readable" };
475+
export function freshPostsOutcome(row: typeof linkedinCompanyPages.$inferSelect | undefined, page: CompetitorPage, now: Date): PageOutcome | null {
476+
if (row?.postsFetchedAt && now.getTime() - row.postsFetchedAt.getTime() < POSTS_REFRESH_MS) {
477+
return row.postsStatus === "not_found" ? { page, state: "not_found", reason: row.postsError ?? "not found" } : { page, state: "readable" };
478+
}
479+
// Due, but every provider failed on it a moment ago: skipped, no provider call.
480+
const failedAt = row?.postsFailedAt;
481+
if (failedAt && now.getTime() - failedAt.getTime() < POSTS_FAILED_RETRY_MS && (!row.postsFetchedAt || failedAt > row.postsFetchedAt)) {
482+
return { page, state: "failed", reason: row.postsFailure ?? "every posts provider failed" };
483+
}
484+
return null;
468485
}
469486

470487
async function ensurePosts(meter: EngagementMeter, page: CompetitorPage, now: Date): Promise<PageOutcome> {
@@ -473,9 +490,8 @@ async function ensurePosts(meter: EngagementMeter, page: CompetitorPage, now: Da
473490
const [seen] = await db.select().from(linkedinCompanyPages).where(eq(linkedinCompanyPages.slug, page.slug)).limit(1);
474491
const known = freshPostsOutcome(seen, page, now);
475492
if (known) return known;
476-
// A page no provider can read is never remembered, so every serve re-reads it
477-
// (~2.4 s of provider calls under the lock): concurrent serves of the same page
478-
// share that one read instead of queuing on the lock and repeating it.
493+
// Concurrent serves of a due page share one read instead of queuing on the lock
494+
// and repeating it (a failed read is remembered only once it returns).
479495
const outcome = await singleFlight(`linkedin-page:${page.slug}`, () => readPageUnderLock(meter, page, now));
480496
return { ...outcome, page };
481497
}
@@ -488,10 +504,16 @@ async function readPageUnderLock(meter: EngagementMeter, page: CompetitorPage, n
488504
if (fresh) return fresh;
489505

490506
const read = await readPosts(meter, page, POSTS_PROVIDERS);
491-
if (read.kind === "failed") return { page, state: "failed", reason: read.reason };
507+
if (read.kind === "failed") {
508+
// Remembered for POSTS_FAILED_RETRY_MS so the next serves skip it without a call.
509+
// The posts already listed (posts_fetched_at, posts_count, posts_status) stay.
510+
const failed = { postsFailedAt: now, postsFailure: read.reason.slice(0, 1000) };
511+
await tx.insert(linkedinCompanyPages).values({ slug: page.slug, url: page.url, ...failed }).onConflictDoUpdate({ target: linkedinCompanyPages.slug, set: failed });
512+
return { page, state: "failed", reason: read.reason };
513+
}
492514
if (read.kind === "not_found") {
493515
// A permanent answer: remembered for the refresh period so no serve pays to re-learn it.
494-
const set = { postsFetchedAt: now, postsCount: 0, postsStatus: "not_found", postsError: read.reason.slice(0, 1000) };
516+
const set = { postsFetchedAt: now, postsCount: 0, postsStatus: "not_found", postsError: read.reason.slice(0, 1000), postsFailedAt: null, postsFailure: null };
495517
await tx.insert(linkedinCompanyPages).values({ slug: page.slug, url: page.url, ...set }).onConflictDoUpdate({ target: linkedinCompanyPages.slug, set });
496518
return { page, state: "not_found", reason: read.reason };
497519
}
@@ -505,7 +527,7 @@ async function readPageUnderLock(meter: EngagementMeter, page: CompetitorPage, n
505527
.values({ postId, pageSlug: page.slug, postUrl: p.url ?? null, text: p.text ?? null, publishedAt: published && !Number.isNaN(published.getTime()) ? published : null })
506528
.onConflictDoUpdate({ target: linkedinCompanyPosts.postId, set: { postUrl: sql`excluded.post_url`, text: sql`excluded.text`, lastSeenAt: now } });
507529
}
508-
const set = { postsFetchedAt: now, postsCount: read.posts.length, postsStatus: "ok", postsError: null };
530+
const set = { postsFetchedAt: now, postsCount: read.posts.length, postsStatus: "ok", postsError: null, postsFailedAt: null, postsFailure: null };
509531
await tx.insert(linkedinCompanyPages).values({ slug: page.slug, url: page.url, ...set }).onConflictDoUpdate({ target: linkedinCompanyPages.slug, set });
510532
return { page, state: "readable" };
511533
});

0 commit comments

Comments
 (0)