perf(api): improve inserting events
This commit is contained in:
@@ -4,7 +4,7 @@ import superjson from 'superjson';
|
|||||||
export function toDots(
|
export function toDots(
|
||||||
obj: Record<string, unknown>,
|
obj: Record<string, unknown>,
|
||||||
path = ''
|
path = ''
|
||||||
): Record<string, number | string | boolean> {
|
): Record<string, string> {
|
||||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -31,37 +31,130 @@ export class ProfileBuffer extends RedisBuffer<IClickhouseProfile> {
|
|||||||
public onCompleted?: OnCompleted<IClickhouseProfile> | undefined;
|
public onCompleted?: OnCompleted<IClickhouseProfile> | undefined;
|
||||||
|
|
||||||
public processQueue: ProcessQueue<IClickhouseProfile> = async (queue) => {
|
public processQueue: ProcessQueue<IClickhouseProfile> = async (queue) => {
|
||||||
|
const cleanedQueue = this.combineQueueItems(queue);
|
||||||
|
const redisProfiles = await this.getCachedProfiles(cleanedQueue);
|
||||||
|
const dbProfiles = await this.fetchDbProfiles(
|
||||||
|
cleanedQueue.filter((_, index) => !redisProfiles[index])
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = this.createProfileValues(
|
||||||
|
cleanedQueue,
|
||||||
|
redisProfiles,
|
||||||
|
dbProfiles
|
||||||
|
);
|
||||||
|
|
||||||
|
if (values.length > 0) {
|
||||||
|
await this.updateRedisCache(values);
|
||||||
|
await this.insertIntoClickhouse(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue.map((item) => item.index);
|
||||||
|
};
|
||||||
|
|
||||||
|
private matchPartialObject(
|
||||||
|
full: any,
|
||||||
|
partial: any,
|
||||||
|
options: { ignore: string[] }
|
||||||
|
): boolean {
|
||||||
|
if (typeof partial !== 'object' || partial === null) {
|
||||||
|
return partial === full;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in partial) {
|
||||||
|
if (options.ignore.includes(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(key in full) ||
|
||||||
|
!this.matchPartialObject(full[key], partial[key], options)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private combineQueueItems(
|
||||||
|
queue: QueueItem<IClickhouseProfile>[]
|
||||||
|
): QueueItem<IClickhouseProfile>[] {
|
||||||
const itemsToClickhouse = new Map<string, QueueItem<IClickhouseProfile>>();
|
const itemsToClickhouse = new Map<string, QueueItem<IClickhouseProfile>>();
|
||||||
|
|
||||||
// Combine all writes to the same profile
|
|
||||||
queue.forEach((item) => {
|
queue.forEach((item) => {
|
||||||
const key = item.event.project_id + item.event.id;
|
const key = item.event.project_id + item.event.id;
|
||||||
const existing = itemsToClickhouse.get(key);
|
const existing = itemsToClickhouse.get(key);
|
||||||
itemsToClickhouse.set(
|
itemsToClickhouse.set(key, mergeDeepRight(existing ?? {}, item));
|
||||||
item.event.project_id + item.event.id,
|
|
||||||
mergeDeepRight(existing ?? {}, item)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanedQueue = Array.from(itemsToClickhouse.values());
|
return Array.from(itemsToClickhouse.values());
|
||||||
|
}
|
||||||
|
|
||||||
const profiles = await chQuery<IClickhouseProfile>(
|
private async getCachedProfiles(
|
||||||
|
cleanedQueue: QueueItem<IClickhouseProfile>[]
|
||||||
|
): Promise<(IClickhouseProfile | null)[]> {
|
||||||
|
const redisCache = getRedisCache();
|
||||||
|
const keys = cleanedQueue.map(
|
||||||
|
(item) => `profile:${item.event.project_id}:${item.event.id}`
|
||||||
|
);
|
||||||
|
const cachedProfiles = await redisCache.mget(...keys);
|
||||||
|
return cachedProfiles.map((profile) => {
|
||||||
|
try {
|
||||||
|
return profile ? JSON.parse(profile) : null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchDbProfiles(
|
||||||
|
cleanedQueue: QueueItem<IClickhouseProfile>[]
|
||||||
|
): Promise<IClickhouseProfile[]> {
|
||||||
|
if (cleanedQueue.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await chQuery<IClickhouseProfile>(
|
||||||
`SELECT
|
`SELECT
|
||||||
*
|
*
|
||||||
FROM profiles
|
FROM ${TABLE_NAMES.profiles}
|
||||||
WHERE
|
WHERE
|
||||||
(id, project_id) IN (${cleanedQueue.map((item) => `('${item.event.id}', '${item.event.project_id}')`).join(',')})
|
(id, project_id) IN (${cleanedQueue.map((item) => `('${item.event.id}', '${item.event.project_id}')`).join(',')})
|
||||||
ORDER BY
|
ORDER BY
|
||||||
created_at DESC`
|
created_at DESC`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await ch.insert({
|
private createProfileValues(
|
||||||
table: TABLE_NAMES.profiles,
|
cleanedQueue: QueueItem<IClickhouseProfile>[],
|
||||||
values: cleanedQueue.map((item) => {
|
redisProfiles: (IClickhouseProfile | null)[],
|
||||||
const profile = profiles.find(
|
dbProfiles: IClickhouseProfile[]
|
||||||
|
): IClickhouseProfile[] {
|
||||||
|
return cleanedQueue
|
||||||
|
.map((item, index) => {
|
||||||
|
const cachedProfile = redisProfiles[index];
|
||||||
|
const dbProfile = dbProfiles.find(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.id === item.event.id && p.project_id === item.event.project_id
|
p.id === item.event.id && p.project_id === item.event.project_id
|
||||||
);
|
);
|
||||||
|
const profile = cachedProfile || dbProfile;
|
||||||
|
|
||||||
|
if (
|
||||||
|
profile &&
|
||||||
|
this.matchPartialObject(
|
||||||
|
profile,
|
||||||
|
{
|
||||||
|
...item.event,
|
||||||
|
properties: toDots(item.event.properties),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignore: ['created_at'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
console.log('Ignoring profile', item.event.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.event.id,
|
id: item.event.id,
|
||||||
@@ -74,14 +167,35 @@ export class ProfileBuffer extends RedisBuffer<IClickhouseProfile> {
|
|||||||
...(item.event.properties ?? {}),
|
...(item.event.properties ?? {}),
|
||||||
}),
|
}),
|
||||||
project_id: item.event.project_id ?? profile?.project_id ?? '',
|
project_id: item.event.project_id ?? profile?.project_id ?? '',
|
||||||
created_at: new Date(),
|
created_at: item.event.created_at ?? profile?.created_at ?? '',
|
||||||
is_external: item.event.is_external,
|
is_external: item.event.is_external,
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
|
.flatMap((item) => (item ? [item] : []));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateRedisCache(values: IClickhouseProfile[]): Promise<void> {
|
||||||
|
const redisCache = getRedisCache();
|
||||||
|
const multi = redisCache.multi();
|
||||||
|
values.forEach((value) => {
|
||||||
|
multi.setex(
|
||||||
|
`profile:${value.project_id}:${value.id}`,
|
||||||
|
60 * 30, // 30 minutes
|
||||||
|
JSON.stringify(value)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await multi.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async insertIntoClickhouse(
|
||||||
|
values: IClickhouseProfile[]
|
||||||
|
): Promise<void> {
|
||||||
|
await ch.insert({
|
||||||
|
table: TABLE_NAMES.profiles,
|
||||||
|
values,
|
||||||
format: 'JSONEachRow',
|
format: 'JSONEachRow',
|
||||||
});
|
});
|
||||||
return queue.map((item) => item.index);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
public findMany: FindMany<IClickhouseProfile, IServiceProfile> = async (
|
public findMany: FindMany<IClickhouseProfile, IServiceProfile> = async (
|
||||||
callback
|
callback
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ export async function getTopPages({
|
|||||||
}) {
|
}) {
|
||||||
const res = await chQuery<IServicePage>(`
|
const res = await chQuery<IServicePage>(`
|
||||||
SELECT path, count(*) as count, project_id, first_value(created_at) as first_seen, max(properties['__title']) as title, origin
|
SELECT path, count(*) as count, project_id, first_value(created_at) as first_seen, max(properties['__title']) as title, origin
|
||||||
FROM events_v2
|
FROM ${TABLE_NAMES.events}
|
||||||
WHERE name = 'screen_view'
|
WHERE name = 'screen_view'
|
||||||
AND project_id = ${escape(projectId)}
|
AND project_id = ${escape(projectId)}
|
||||||
AND created_at > now() - INTERVAL 30 DAY
|
AND created_at > now() - INTERVAL 30 DAY
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function getProfileById(id: string, projectId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [profile] = await chQuery<IClickhouseProfile>(
|
const [profile] = await chQuery<IClickhouseProfile>(
|
||||||
`SELECT * FROM profiles WHERE id = ${escape(String(id))} AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT 1`
|
`SELECT * FROM ${TABLE_NAMES.profiles} WHERE id = ${escape(String(id))} AND project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
|
|||||||
Reference in New Issue
Block a user