mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-04 12:56:14 +08:00
chore: improve implementation of scheduled journeys (#689)
This commit is contained in:
parent
36f3652dce
commit
a86ec79444
5 changed files with 125 additions and 47 deletions
|
@ -19,6 +19,7 @@ export default (app: App) => {
|
||||||
JourneyDelayJob.enqueueActive(app)
|
JourneyDelayJob.enqueueActive(app)
|
||||||
app.queue.enqueue(ProcessCampaignsJob.from())
|
app.queue.enqueue(ProcessCampaignsJob.from())
|
||||||
app.queue.enqueue(CampaignStateJob.from())
|
app.queue.enqueue(CampaignStateJob.from())
|
||||||
|
app.queue.enqueue(ScheduledEntranceOrchestratorJob.from())
|
||||||
},
|
},
|
||||||
lockLength: 120,
|
lockLength: 120,
|
||||||
})
|
})
|
||||||
|
@ -30,7 +31,6 @@ export default (app: App) => {
|
||||||
delta: subHours(new Date(), 1),
|
delta: subHours(new Date(), 1),
|
||||||
}))
|
}))
|
||||||
app.queue.enqueue(UpdateJourneysJob.from())
|
app.queue.enqueue(UpdateJourneysJob.from())
|
||||||
app.queue.enqueue(ScheduledEntranceOrchestratorJob.from())
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
scheduler.schedule({
|
scheduler.schedule({
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import { Job } from '../queue'
|
import { Job } from '../queue'
|
||||||
import { JourneyEntrance } from './JourneyStep'
|
import { JourneyEntrance, JourneyStep } from './JourneyStep'
|
||||||
import JourneyUserStep from './JourneyUserStep'
|
import JourneyUserStep from './JourneyUserStep'
|
||||||
import { chunk, Chunker, uuid } from '../utilities'
|
import { uuid } from '../utilities'
|
||||||
import App from '../app'
|
import App from '../app'
|
||||||
import JourneyProcessJob from './JourneyProcessJob'
|
import JourneyProcessJob from './JourneyProcessJob'
|
||||||
import Journey from './Journey'
|
import Journey from './Journey'
|
||||||
import List from '../lists/List'
|
import List from '../lists/List'
|
||||||
import { getRuleQuery } from '../rules/RuleEngine'
|
import { getRuleQuery } from '../rules/RuleEngine'
|
||||||
import { User } from '../users/User'
|
import Project from '../projects/Project'
|
||||||
|
import { logger } from '../config/logger'
|
||||||
|
import { processUsers } from '../users/ProcessUsers'
|
||||||
|
|
||||||
interface ScheduledEntranceTrigger {
|
interface ScheduledEntranceTrigger {
|
||||||
entranceId: number
|
entranceId: number
|
||||||
|
@ -18,13 +20,12 @@ export default class ScheduledEntranceJob extends Job {
|
||||||
static $name = 'scheduled_entrance_job'
|
static $name = 'scheduled_entrance_job'
|
||||||
|
|
||||||
static from(params: ScheduledEntranceTrigger) {
|
static from(params: ScheduledEntranceTrigger) {
|
||||||
return new ScheduledEntranceJob(params)
|
return new ScheduledEntranceJob(params).deduplicationKey(`${this.$name}_${params.entranceId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
static async handler({ entranceId }: ScheduledEntranceTrigger) {
|
static async handler({ entranceId }: ScheduledEntranceTrigger) {
|
||||||
|
|
||||||
const entrance = await JourneyEntrance.find(entranceId)
|
const entrance = await JourneyEntrance.find(entranceId)
|
||||||
|
|
||||||
if (!entrance || entrance.type !== JourneyEntrance.type || !entrance.list_id) {
|
if (!entrance || entrance.type !== JourneyEntrance.type || !entrance.list_id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -33,42 +34,44 @@ export default class ScheduledEntranceJob extends Job {
|
||||||
Journey.find(entrance.journey_id),
|
Journey.find(entrance.journey_id),
|
||||||
List.find(entrance.list_id),
|
List.find(entrance.list_id),
|
||||||
])
|
])
|
||||||
|
if (!list || list.project_id !== journey?.project_id) return
|
||||||
|
|
||||||
if (!list || list.project_id !== journey?.project_id) {
|
const project = await Project.find(journey.project_id)
|
||||||
return // bad list id or project mismatch
|
|
||||||
}
|
|
||||||
|
|
||||||
const ref = uuid()
|
const query = getRuleQuery(list.project_id, list.rule)
|
||||||
const result = await User.clickhouse().query(
|
await processUsers({
|
||||||
getRuleQuery(list.project_id, list.rule),
|
query,
|
||||||
)
|
cacheKey: `journeys:${journey}:entrance:${entrance.id}:users`,
|
||||||
|
itemMap: (user) => ({
|
||||||
|
key: user.id,
|
||||||
|
value: `${user.id}`,
|
||||||
|
}),
|
||||||
|
callback: async (pairs) => {
|
||||||
|
try {
|
||||||
|
const ref = uuid()
|
||||||
|
const items = pairs.map(({ key }) => ({
|
||||||
|
user_id: parseInt(key),
|
||||||
|
type: 'completed',
|
||||||
|
journey_id: entrance.journey_id,
|
||||||
|
step_id: entrance.id,
|
||||||
|
ref,
|
||||||
|
}))
|
||||||
|
await JourneyUserStep.insert(items)
|
||||||
|
|
||||||
const chunker = new Chunker<Partial<JourneyUserStep>>(async items => {
|
const steps = await JourneyUserStep.all(qb => qb.select('id')
|
||||||
await App.main.db.transaction(async (trx) => {
|
.where('ref', ref),
|
||||||
await JourneyUserStep.query(trx)
|
)
|
||||||
.insert(items)
|
|
||||||
})
|
|
||||||
}, 500)
|
|
||||||
|
|
||||||
for await (const chunk of result.stream() as any) {
|
await App.main.queue.enqueueBatch(steps.map(({ id }) => JourneyProcessJob.from({ entrance_id: id })))
|
||||||
for (const result of chunk) {
|
} catch (error) {
|
||||||
const user = result.json()
|
logger.error({ error, journey: journey.id }, 'campaign:generate:progress:error')
|
||||||
chunker.add({
|
}
|
||||||
user_id: user.id,
|
},
|
||||||
type: 'completed',
|
afterCallback: async () => {
|
||||||
journey_id: entrance.journey_id,
|
await JourneyStep.update(q => q.where('id', entrance.id), {
|
||||||
step_id: entrance.id,
|
next_scheduled_at: entrance.nextDate(project?.timezone ?? 'UTC'),
|
||||||
ref,
|
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
|
||||||
await chunker.flush()
|
|
||||||
|
|
||||||
const query = JourneyUserStep.query().select('id').where('ref', ref)
|
|
||||||
|
|
||||||
await chunk<{ id: number }>(query, App.main.queue.batchSize, async items => {
|
|
||||||
await App.main.queue.enqueueBatch(items.map(({ id }) => JourneyProcessJob.from({ entrance_id: id })))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import App from '../app'
|
import App from '../app'
|
||||||
import { getProject } from '../projects/ProjectService'
|
|
||||||
import { Job } from '../queue'
|
import { Job } from '../queue'
|
||||||
import { JourneyEntrance, JourneyStep } from './JourneyStep'
|
import { JourneyEntrance } from './JourneyStep'
|
||||||
import ScheduledEntranceJob from './ScheduledEntranceJob'
|
import ScheduledEntranceJob from './ScheduledEntranceJob'
|
||||||
|
|
||||||
export default class ScheduledEntranceOrchestratorJob extends Job {
|
export default class ScheduledEntranceOrchestratorJob extends Job {
|
||||||
|
@ -32,12 +31,6 @@ export default class ScheduledEntranceOrchestratorJob extends Job {
|
||||||
|
|
||||||
const jobs: Job[] = []
|
const jobs: Job[] = []
|
||||||
for (const entrance of entrances) {
|
for (const entrance of entrances) {
|
||||||
|
|
||||||
const project = await getProject(entrance.project_id)
|
|
||||||
await JourneyStep.update(q => q.where('id', entrance.id), {
|
|
||||||
next_scheduled_at: entrance.nextDate(project?.timezone ?? 'UTC'),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (entrance.list_id) {
|
if (entrance.list_id) {
|
||||||
jobs.push(ScheduledEntranceJob.from({
|
jobs.push(ScheduledEntranceJob.from({
|
||||||
entranceId: entrance.id,
|
entranceId: entrance.id,
|
||||||
|
@ -49,5 +42,4 @@ export default class ScheduledEntranceOrchestratorJob extends Job {
|
||||||
await App.main.queue.enqueueBatch(jobs)
|
await App.main.queue.enqueueBatch(jobs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ export default class ListStatsJob extends Job {
|
||||||
listId: number,
|
listId: number,
|
||||||
projectId: number,
|
projectId: number,
|
||||||
): ListStatsJob {
|
): ListStatsJob {
|
||||||
return new this({ listId, projectId }).deduplicationKey(`${this.name}_${listId}`)
|
return new this({ listId, projectId }).deduplicationKey(`${this.$name}_${listId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
static async handler({ listId, projectId }: ListStatsParams) {
|
static async handler({ listId, projectId }: ListStatsParams) {
|
||||||
|
|
83
apps/platform/src/users/ProcessUsers.ts
Normal file
83
apps/platform/src/users/ProcessUsers.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { Chunker } from '../utilities'
|
||||||
|
import App from '../app'
|
||||||
|
import { logger } from '../config/logger'
|
||||||
|
import { cacheBatchHash, cacheBatchReadHashAndDelete, cacheDel, cacheGet, cacheHashExists, cacheSet, DataPair, HashScanCallback } from '../config/redis'
|
||||||
|
import { User } from './User'
|
||||||
|
|
||||||
|
type CachedQueryParams = {
|
||||||
|
query: string
|
||||||
|
cacheKey: string,
|
||||||
|
itemMap: (data: any) => DataPair
|
||||||
|
callback: HashScanCallback
|
||||||
|
beforeCallback?: (count: number) => Promise<void>
|
||||||
|
afterCallback?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const processUsers = async ({
|
||||||
|
query,
|
||||||
|
cacheKey,
|
||||||
|
itemMap,
|
||||||
|
callback,
|
||||||
|
beforeCallback,
|
||||||
|
afterCallback,
|
||||||
|
}: CachedQueryParams) => {
|
||||||
|
|
||||||
|
const redis = App.main.redis
|
||||||
|
const hashKey = cacheKey
|
||||||
|
const hashKeyReady = `${hashKey}:ready`
|
||||||
|
const hashExists = await cacheHashExists(redis, hashKey)
|
||||||
|
const isReady = await cacheGet(redis, hashKeyReady)
|
||||||
|
|
||||||
|
const cleanupQuery = async () => {
|
||||||
|
await afterCallback?.()
|
||||||
|
await cacheDel(redis, hashKeyReady)
|
||||||
|
await cacheDel(redis, hashKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
source: hashExists ? 'cache' : 'clickhouse',
|
||||||
|
key: hashKey,
|
||||||
|
}, 'users:generate:started')
|
||||||
|
|
||||||
|
// Return users from the hash if they exist
|
||||||
|
if (hashExists && isReady) {
|
||||||
|
await cacheBatchReadHashAndDelete(redis, hashKey, callback)
|
||||||
|
await cleanupQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
query,
|
||||||
|
key: hashKey,
|
||||||
|
}, 'users:generate:querying')
|
||||||
|
|
||||||
|
// Generate the initial send list from ClickHouse
|
||||||
|
const result = await User.clickhouse().query(query, {}, {
|
||||||
|
max_block_size: '16384',
|
||||||
|
send_progress_in_http_headers: 1,
|
||||||
|
http_headers_progress_interval_ms: '110000', // 110 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load the results into a Redis hash for easy retrieval
|
||||||
|
let count = 0
|
||||||
|
const chunker = new Chunker<DataPair>(async pairs => {
|
||||||
|
count += pairs.length
|
||||||
|
await cacheBatchHash(redis, hashKey, pairs)
|
||||||
|
}, 2500)
|
||||||
|
|
||||||
|
// Stream the data from ClickHouse and pass it to the Redis chunker
|
||||||
|
for await (const chunk of result.stream() as any) {
|
||||||
|
for (const result of chunk) {
|
||||||
|
const item = result.json()
|
||||||
|
await chunker.add(itemMap(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await chunker.flush()
|
||||||
|
|
||||||
|
// Prepare anything before running, otherwise just set the ready flag
|
||||||
|
await beforeCallback?.(count)
|
||||||
|
await cacheSet(redis, hashKeyReady, 1, 86400)
|
||||||
|
|
||||||
|
// Now that we have results, pass them back to the callback
|
||||||
|
await cacheBatchReadHashAndDelete(redis, hashKey, callback)
|
||||||
|
await cleanupQuery()
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue