Skip to content

Commit 57d389c

Browse files
committed
chore: script to rewrite crustdata enrichment cache
Signed-off-by: Yeganathan S <63534555+skwowet@users.noreply.github.com>
1 parent c7b1bae commit 57d389c

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"script:fix-duplicate-members": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/fix-duplicate-members.ts",
3434
"script:fix-members-activities-after-unaffilation": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/fix-members-activities-after-unaffilation.ts",
3535
"script:process-bot-members": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/process-bot-members.ts",
36+
"script:rewrite-crustdata-enrichment-cache": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/rewrite-crustdata-enrichment-cache.ts",
3637
"script:backfill-email-domain-member-organization-dates": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/backfill-email-domain-member-organization-dates.ts",
3738
"script:onboard-default-tenant": "SERVICE=script TS_NODE_TRANSPILE_ONLY=true tsx src/bin/scripts/onboard-default-tenant.ts",
3839
"script:onboard-default-tenant:local": "set -a && . ./.env.dist.local && . ./.env.override.local && set +a && pnpm run script:onboard-default-tenant",
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import commandLineArgs from 'command-line-args'
2+
3+
import { redactNullByte } from '@crowd/common'
4+
import { pgpQx } from '@crowd/data-access-layer'
5+
import { getDbConnection } from '@crowd/data-access-layer/src/database'
6+
import { chunkArray } from '@crowd/data-access-layer/src/old/apps/merge_suggestions_worker/utils'
7+
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
8+
import { getServiceLogger } from '@crowd/logging'
9+
import { MemberEnrichmentSource } from '@crowd/types'
10+
11+
import { DB_CONFIG } from '@/conf'
12+
13+
const log = getServiceLogger()
14+
const SOURCE = MemberEnrichmentSource.CRUSTDATA
15+
const UPDATE_CONCURRENCY = 50
16+
17+
const options = [
18+
{
19+
name: 'testRun',
20+
alias: 't',
21+
type: Boolean,
22+
description: 'Process one small batch (10 rows) with real writes, then stop.',
23+
},
24+
{
25+
name: 'help',
26+
alias: 'h',
27+
type: Boolean,
28+
description: 'Print this usage guide.',
29+
},
30+
]
31+
32+
const parameters = commandLineArgs(options)
33+
34+
function mapEmployer(employer: {
35+
employer_name?: string
36+
employer_linkedin_id?: string
37+
employee_title?: string
38+
start_date?: string
39+
end_date?: string
40+
}) {
41+
return {
42+
name: employer.employer_name,
43+
title: employer.employee_title,
44+
professional_network_id: employer.employer_linkedin_id,
45+
start_date: employer.start_date ?? null,
46+
end_date: employer.end_date ?? null,
47+
}
48+
}
49+
50+
function mapProfile(old: Record<string, any>) {
51+
const skills = old.skills
52+
? (Array.isArray(old.skills) ? old.skills : String(old.skills).split(','))
53+
.map((s: string) => s.trim())
54+
.filter(Boolean)
55+
: []
56+
57+
const githubLogin = old.github_profiles?.[0]?.login
58+
59+
const mapped: Record<string, unknown> = {
60+
basic_profile: {
61+
name: old.name,
62+
current_title: old.title,
63+
headline: old.headline,
64+
summary: old.summary,
65+
languages: old.languages || [],
66+
profile_picture_permalink: old.profile_picture_permalink,
67+
},
68+
social_handles: {
69+
...(old.linkedin_flagship_url
70+
? {
71+
professional_network_identifier: {
72+
profile_url: old.linkedin_flagship_url,
73+
},
74+
}
75+
: {}),
76+
...(old.twitter_handle
77+
? {
78+
twitter_identifier: {
79+
slug: old.twitter_handle,
80+
},
81+
}
82+
: {}),
83+
...(githubLogin
84+
? {
85+
// Crustdata "dev_platform" = GitHub
86+
dev_platform_identifier: {
87+
profile_url: `https://github.com/${githubLogin}`,
88+
},
89+
}
90+
: {}),
91+
},
92+
professional_network: {
93+
connections: old.num_of_connections,
94+
profile_picture_url: old.profile_picture_url,
95+
profile_picture_permalink: old.profile_picture_permalink,
96+
},
97+
skills: {
98+
professional_network_skills: skills,
99+
},
100+
education: {
101+
schools: (old.all_schools || []).map((school: string) => ({ school })),
102+
},
103+
experience: {
104+
employment_details: {
105+
current: (old.current_employers || []).map(mapEmployer),
106+
past: (old.past_employers || []).map(mapEmployer),
107+
},
108+
},
109+
}
110+
111+
if (old.metadata) {
112+
mapped.metadata = old.metadata
113+
}
114+
115+
return mapped
116+
}
117+
118+
async function countRows(qx: QueryExecutor): Promise<number> {
119+
const row = await qx.selectOne(
120+
`
121+
SELECT COUNT(*)::int AS count
122+
FROM "memberEnrichmentCache"
123+
WHERE source = $(source)
124+
AND data IS NOT NULL
125+
`,
126+
{ source: SOURCE },
127+
)
128+
return row.count
129+
}
130+
131+
async function fetchRows(
132+
qx: QueryExecutor,
133+
limit: number,
134+
afterMemberId?: string,
135+
): Promise<Array<{ memberId: string; data: any }>> {
136+
// PK (memberId, source) index scan + data IS NOT NULL filter.
137+
return qx.select(
138+
`
139+
SELECT "memberId", data
140+
FROM "memberEnrichmentCache"
141+
WHERE source = $(source)
142+
AND data IS NOT NULL
143+
${afterMemberId ? 'AND "memberId" > $(afterMemberId)' : ''}
144+
ORDER BY "memberId"
145+
LIMIT $(limit)
146+
`,
147+
{
148+
source: SOURCE,
149+
limit,
150+
afterMemberId,
151+
},
152+
)
153+
}
154+
155+
async function updateCacheData(qx: QueryExecutor, memberId: string, data: unknown): Promise<void> {
156+
await qx.selectNone(
157+
`
158+
UPDATE "memberEnrichmentCache"
159+
SET
160+
data = $(data)::jsonb,
161+
"updatedAt" = NOW()
162+
WHERE "memberId" = $(memberId)
163+
AND source = $(source)
164+
`,
165+
{
166+
memberId,
167+
source: SOURCE,
168+
data: redactNullByte(JSON.stringify(data)),
169+
},
170+
)
171+
}
172+
173+
setImmediate(async () => {
174+
if (parameters.help) {
175+
log.info('Usage: pnpm run script:rewrite-crustdata-enrichment-cache [--testRun|-t]')
176+
process.exit(0)
177+
}
178+
179+
const testRun = parameters.testRun ?? false
180+
const BATCH_SIZE = testRun ? 10 : 500
181+
182+
const db = await getDbConnection({
183+
host: DB_CONFIG.writeHost,
184+
port: DB_CONFIG.port,
185+
database: DB_CONFIG.database,
186+
user: DB_CONFIG.username,
187+
password: DB_CONFIG.password,
188+
})
189+
const qx = pgpQx(db)
190+
191+
const total = await countRows(qx)
192+
const batchCount = testRun ? Math.min(1, Math.ceil(total / BATCH_SIZE)) : Math.ceil(total / BATCH_SIZE)
193+
194+
log.info(
195+
{ testRun, BATCH_SIZE, total, batchCount },
196+
'Rewriting crustdata enrichment cache to nested person_data shape!',
197+
)
198+
199+
let afterMemberId: string | undefined
200+
let totalUpdated = 0
201+
202+
for (let batch = 0; batch < batchCount; batch++) {
203+
const rows = await fetchRows(qx, BATCH_SIZE, afterMemberId)
204+
if (rows.length === 0) {
205+
break
206+
}
207+
208+
for (const chunk of chunkArray(rows, UPDATE_CONCURRENCY)) {
209+
await Promise.all(
210+
chunk.map(async (row) => {
211+
const profiles = Array.isArray(row.data) ? row.data : [row.data]
212+
213+
// Fail fast if a row is not the flat cache shape this script expects.
214+
if (profiles.some((p) => !p?.name || p.basic_profile)) {
215+
throw new Error(`Unexpected crustdata cache shape for member ${row.memberId}`)
216+
}
217+
218+
await updateCacheData(qx, row.memberId, profiles.map(mapProfile))
219+
220+
if (testRun) {
221+
log.info({ memberId: row.memberId }, 'Updated crustdata cache row!')
222+
}
223+
}),
224+
)
225+
totalUpdated += chunk.length
226+
}
227+
228+
afterMemberId = rows[rows.length - 1].memberId
229+
log.info(
230+
{ batch: batch + 1, batchCount, batchSize: rows.length, totalUpdated, afterMemberId },
231+
'Batch processed!',
232+
)
233+
}
234+
235+
log.info({ totalUpdated, testRun }, 'Done!')
236+
process.exit(0)
237+
})

0 commit comments

Comments
 (0)