mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3834 from omnivore-app/fix/refresh-digest
do not reuse candidates after refreshing
This commit is contained in:
commit
84b7845acc
9 changed files with 231 additions and 200 deletions
|
|
@ -4,16 +4,24 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import { createPrometheusExporterPlugin } from '@bmatei/apollo-prometheus-exporter'
|
||||
import { makeExecutableSchema } from '@graphql-tools/schema'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { ContextFunction, PluginDefinition } from 'apollo-server-core'
|
||||
import { Express } from 'express'
|
||||
import {
|
||||
ApolloServerPluginDrainHttpServer,
|
||||
ContextFunction,
|
||||
PluginDefinition,
|
||||
} from 'apollo-server-core'
|
||||
import { ApolloServer } from 'apollo-server-express'
|
||||
import { ExpressContext } from 'apollo-server-express/dist/ApolloServer'
|
||||
import { ApolloServerPlugin } from 'apollo-server-plugin-base'
|
||||
import { Express } from 'express'
|
||||
import * as httpContext from 'express-http-context2'
|
||||
import type http from 'http'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { EntityManager } from 'typeorm'
|
||||
import { promisify } from 'util'
|
||||
import { ReadingProgressDataSource } from './datasources/reading_progress_data_source'
|
||||
import { appDataSource } from './data_source'
|
||||
import { sanitizeDirectiveTransformer } from './directives'
|
||||
import { env } from './env'
|
||||
|
|
@ -22,17 +30,14 @@ import { functionResolvers } from './resolvers/function_resolvers'
|
|||
import { ClaimsToSet, RequestContext, ResolverContext } from './resolvers/types'
|
||||
import ScalarResolvers from './scalars'
|
||||
import typeDefs from './schema'
|
||||
import { tracer } from './tracing'
|
||||
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
|
||||
import { SetClaimsRole } from './utils/dictionary'
|
||||
import { logger } from './utils/logger'
|
||||
import { ReadingProgressDataSource } from './datasources/reading_progress_data_source'
|
||||
import { createPrometheusExporterPlugin } from '@bmatei/apollo-prometheus-exporter'
|
||||
import { ApolloServerPlugin } from 'apollo-server-plugin-base'
|
||||
import {
|
||||
countDailyServiceUsage,
|
||||
createServiceUsage,
|
||||
} from './services/service_usage'
|
||||
import { tracer } from './tracing'
|
||||
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
|
||||
import { SetClaimsRole } from './utils/dictionary'
|
||||
import { logger } from './utils/logger'
|
||||
|
||||
const signToken = promisify(jwt.sign)
|
||||
const pubsub = createPubSubClient()
|
||||
|
|
@ -100,7 +105,10 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
|
|||
return ctx
|
||||
}
|
||||
|
||||
export function makeApolloServer(app: Express): ApolloServer {
|
||||
export function makeApolloServer(
|
||||
app: Express,
|
||||
httpServer: http.Server
|
||||
): ApolloServer {
|
||||
let schema = makeExecutableSchema({
|
||||
resolvers,
|
||||
typeDefs,
|
||||
|
|
@ -169,7 +177,14 @@ export function makeApolloServer(app: Express): ApolloServer {
|
|||
const apollo = new ApolloServer({
|
||||
schema: schema,
|
||||
context: contextFunc,
|
||||
plugins: [promExporter, usageLimitPlugin],
|
||||
plugins: [
|
||||
// Our httpServer handles incoming requests to our Express app.
|
||||
// Below, we tell Apollo Server to "drain" this httpServer,
|
||||
// enabling our servers to shut down gracefully.
|
||||
ApolloServerPluginDrainHttpServer({ httpServer }),
|
||||
promExporter,
|
||||
usageLimitPlugin,
|
||||
],
|
||||
formatError: (err) => {
|
||||
logger.info('server error', err)
|
||||
Sentry.captureException(err)
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import { logger } from '../../utils/logger'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { OpenAI } from '@langchain/openai'
|
||||
import { JsonOutputParser } from '@langchain/core/output_parsers'
|
||||
import { PromptTemplate } from '@langchain/core/prompts'
|
||||
import { LibraryItem } from '../../entity/library_item'
|
||||
import { OpenAI } from '@langchain/openai'
|
||||
import {
|
||||
htmlToSpeechFile,
|
||||
SpeechFile,
|
||||
SSMLOptions,
|
||||
} from '@omnivore/text-to-speech-handler'
|
||||
import axios from 'axios'
|
||||
import showdown from 'showdown'
|
||||
import yaml from 'yaml'
|
||||
import { LibraryItem } from '../../entity/library_item'
|
||||
import { TaskState } from '../../generated/graphql'
|
||||
import { redisDataSource } from '../../redis_data_source'
|
||||
import { Digest, writeDigest } from '../../services/digest'
|
||||
import {
|
||||
findLibraryItemsByIds,
|
||||
searchLibraryItems,
|
||||
} from '../../services/library_item'
|
||||
import { redisDataSource } from '../../redis_data_source'
|
||||
import { findDeviceTokensByUserId } from '../../services/user_device_tokens'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { htmlToMarkdown } from '../../utils/parser'
|
||||
import yaml from 'yaml'
|
||||
import { JsonOutputParser } from '@langchain/core/output_parsers'
|
||||
import showdown from 'showdown'
|
||||
import { Digest, writeDigest } from '../../services/digest'
|
||||
import { TaskState } from '../../generated/graphql'
|
||||
import { sendMulticastPushNotifications } from '../../utils/sendNotification'
|
||||
|
||||
export type CreateDigestJobSchedule = 'daily' | 'weekly'
|
||||
|
||||
|
|
@ -73,9 +73,18 @@ interface RankedTitle {
|
|||
}
|
||||
|
||||
export const CREATE_DIGEST_JOB = 'create-digest'
|
||||
export const CRON_PATTERNS = {
|
||||
// every day at 10:30 UTC
|
||||
daily: '30 10 * * *',
|
||||
// every Sunday at 10:30 UTC
|
||||
weekly: '30 10 * * 7',
|
||||
}
|
||||
|
||||
let digestDefinition: DigestDefinition
|
||||
|
||||
export const getCronPattern = (schedule: CreateDigestJobSchedule) =>
|
||||
CRON_PATTERNS[schedule]
|
||||
|
||||
const fetchDigestDefinition = async (): Promise<DigestDefinition> => {
|
||||
const promptFileUrl = process.env.PROMPT_FILE_URL
|
||||
if (!promptFileUrl) {
|
||||
|
|
@ -131,7 +140,7 @@ const getPreferencesList = async (userId: string): Promise<LibraryItem[]> => {
|
|||
// Makes multiple DB queries and combines the results
|
||||
const getCandidatesList = async (
|
||||
userId: string,
|
||||
libraryItemIds?: string[]
|
||||
selectedLibraryItemIds?: string[]
|
||||
): Promise<LibraryItem[]> => {
|
||||
// use the queries from the digest definitions to lookup preferences
|
||||
// There should be a list of multiple queries we use. For now we can
|
||||
|
|
@ -140,18 +149,25 @@ const getCandidatesList = async (
|
|||
// count: 100
|
||||
// reason: "most recent 100 items saved over 500 words
|
||||
|
||||
if (libraryItemIds) {
|
||||
logger.info('Using libraryItemIds')
|
||||
return findLibraryItemsByIds(libraryItemIds, userId)
|
||||
if (selectedLibraryItemIds) {
|
||||
return findLibraryItemsByIds(selectedLibraryItemIds, userId)
|
||||
}
|
||||
|
||||
// get the existing candidate ids from cache
|
||||
const key = `digest:${userId}:existingCandidateIds`
|
||||
const existingCandidateIds = await redisDataSource.redisClient?.get(key)
|
||||
|
||||
logger.info('existingCandidateIds: ', { existingCandidateIds })
|
||||
|
||||
const candidates = await Promise.all(
|
||||
digestDefinition.candidateSelectors.map(async (selector) => {
|
||||
// use the selector to fetch items
|
||||
const results = await searchLibraryItems(
|
||||
{
|
||||
includeContent: true,
|
||||
query: selector.query,
|
||||
query: existingCandidateIds
|
||||
? `(${selector.query}) -includes:${existingCandidateIds}` // exclude the existing candidates
|
||||
: selector.query,
|
||||
size: selector.count,
|
||||
},
|
||||
userId
|
||||
|
|
@ -172,6 +188,23 @@ const getCandidatesList = async (
|
|||
readableContent: htmlToMarkdown(item.readableContent),
|
||||
})) // convert the html content to markdown
|
||||
|
||||
if (dedupedCandidates.length === 0) {
|
||||
logger.info('No new candidates found')
|
||||
|
||||
if (existingCandidateIds) {
|
||||
// reuse the existing candidates
|
||||
const existingIds = existingCandidateIds.split(',')
|
||||
return findLibraryItemsByIds(existingIds, userId)
|
||||
}
|
||||
|
||||
// return empty array if no existing candidates
|
||||
return []
|
||||
}
|
||||
|
||||
// store the ids in cache
|
||||
const candidateIds = dedupedCandidates.map((item) => item.id).join(',')
|
||||
await redisDataSource.redisClient?.set(key, candidateIds)
|
||||
|
||||
return dedupedCandidates
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +236,7 @@ const createUserProfile = async (
|
|||
// it to redis
|
||||
const findOrCreateUserProfile = async (userId: string): Promise<string> => {
|
||||
// check redis for user profile, return if found
|
||||
const key = `userProfile:${userId}`
|
||||
const key = `digest:${userId}:userProfile`
|
||||
const existingProfile = await redisDataSource.redisClient?.get(key)
|
||||
if (existingProfile) {
|
||||
return existingProfile
|
||||
|
|
@ -266,6 +299,9 @@ const rankCandidates = async (
|
|||
return rankedItems
|
||||
}
|
||||
|
||||
const filterTopics = (rankedTopics: string[]) =>
|
||||
rankedTopics.filter((topic) => topic?.length > 0)
|
||||
|
||||
// Does some grouping by topic while trying to maintain ranking
|
||||
// adds some basic topic diversity
|
||||
const chooseRankedSelections = (rankedCandidates: RankedItem[]) => {
|
||||
|
|
@ -289,7 +325,6 @@ const chooseRankedSelections = (rankedCandidates: RankedItem[]) => {
|
|||
}
|
||||
|
||||
logger.info('rankedTopics: ', rankedTopics)
|
||||
logger.info('finalSelections: ', selected)
|
||||
|
||||
const finalSelections = []
|
||||
|
||||
|
|
@ -298,9 +333,15 @@ const chooseRankedSelections = (rankedCandidates: RankedItem[]) => {
|
|||
finalSelections.push(...matches)
|
||||
}
|
||||
|
||||
logger.info('finalSelections: ', finalSelections)
|
||||
logger.info(
|
||||
'finalSelections: ',
|
||||
finalSelections.map((item) => item.libraryItem.title)
|
||||
)
|
||||
|
||||
return { finalSelections, rankedTopics }
|
||||
return {
|
||||
finalSelections,
|
||||
rankedTopics: filterTopics(rankedTopics),
|
||||
}
|
||||
}
|
||||
|
||||
const summarizeItems = async (
|
||||
|
|
@ -363,7 +404,9 @@ const generateSpeechFiles = (
|
|||
// we should have a QA step here that does some
|
||||
// basic checks to make sure the summaries are good.
|
||||
const filterSummaries = (summaries: RankedItem[]): RankedItem[] => {
|
||||
return summaries.filter((item) => item.summary.length > 100)
|
||||
return summaries.filter(
|
||||
(item) => item.summary.length < item.libraryItem.readableContent.length
|
||||
)
|
||||
}
|
||||
|
||||
// we can use something more sophisticated to generate titles
|
||||
|
|
@ -376,11 +419,8 @@ const generateDescription = (
|
|||
summaries: RankedItem[],
|
||||
rankedTopics: string[]
|
||||
): string =>
|
||||
`We selected ${
|
||||
summaries.length
|
||||
} articles from your last 24 hours of saved items, covering ${rankedTopics.join(
|
||||
', '
|
||||
)}.`
|
||||
`We selected ${summaries.length} articles from your last 24 hours of saved items` +
|
||||
(rankedTopics.length ? `, covering ${rankedTopics.join(', ')}.` : '.')
|
||||
|
||||
// generate content based on the summaries
|
||||
const generateContent = (summaries: RankedItem[]): string =>
|
||||
|
|
@ -395,45 +435,77 @@ const generateByline = (summaries: RankedItem[]): string =>
|
|||
.join(', ')
|
||||
|
||||
export const createDigestJob = async (jobData: CreateDigestJobData) => {
|
||||
digestDefinition = await fetchDigestDefinition()
|
||||
try {
|
||||
digestDefinition = await fetchDigestDefinition()
|
||||
|
||||
const candidates = await getCandidatesList(
|
||||
jobData.userId,
|
||||
jobData.libraryItemIds
|
||||
)
|
||||
const userProfile = await findOrCreateUserProfile(jobData.userId)
|
||||
const rankedCandidates = await rankCandidates(candidates, userProfile)
|
||||
const { finalSelections, rankedTopics } =
|
||||
chooseRankedSelections(rankedCandidates)
|
||||
const candidates = await getCandidatesList(
|
||||
jobData.userId,
|
||||
jobData.libraryItemIds
|
||||
)
|
||||
if (candidates.length === 0) {
|
||||
logger.info('No candidates found')
|
||||
return writeDigest(jobData.userId, {
|
||||
id: jobData.id,
|
||||
jobState: TaskState.Succeeded,
|
||||
title: 'No articles found',
|
||||
})
|
||||
}
|
||||
|
||||
const summaries = await summarizeItems(finalSelections)
|
||||
const userProfile = await findOrCreateUserProfile(jobData.userId)
|
||||
const rankedCandidates = await rankCandidates(candidates, userProfile)
|
||||
const { finalSelections, rankedTopics } =
|
||||
chooseRankedSelections(rankedCandidates)
|
||||
|
||||
const filteredSummaries = filterSummaries(summaries)
|
||||
const summaries = await summarizeItems(finalSelections)
|
||||
|
||||
const speechFiles = generateSpeechFiles(filteredSummaries, {
|
||||
...jobData,
|
||||
primaryVoice: jobData.voices?.[0],
|
||||
secondaryVoice: jobData.voices?.[1],
|
||||
})
|
||||
const title = generateTitle(summaries)
|
||||
const digest: Digest = {
|
||||
id: jobData.id,
|
||||
title,
|
||||
content: generateContent(summaries),
|
||||
urlsToAudio: [],
|
||||
jobState: TaskState.Succeeded,
|
||||
speechFiles,
|
||||
chapters: filteredSummaries.map((item, index) => ({
|
||||
title: item.libraryItem.title,
|
||||
id: item.libraryItem.id,
|
||||
url: item.libraryItem.originalUrl,
|
||||
thumbnail: item.libraryItem.thumbnail ?? undefined,
|
||||
wordCount: speechFiles[index].wordCount,
|
||||
})),
|
||||
createdAt: new Date(),
|
||||
description: generateDescription(summaries, rankedTopics),
|
||||
byline: generateByline(summaries),
|
||||
const filteredSummaries = filterSummaries(summaries)
|
||||
|
||||
const speechFiles = generateSpeechFiles(filteredSummaries, {
|
||||
...jobData,
|
||||
primaryVoice: jobData.voices?.[0],
|
||||
secondaryVoice: jobData.voices?.[1],
|
||||
})
|
||||
const title = generateTitle(summaries)
|
||||
const digest: Digest = {
|
||||
id: jobData.id,
|
||||
title,
|
||||
content: generateContent(summaries),
|
||||
jobState: TaskState.Succeeded,
|
||||
speechFiles,
|
||||
chapters: filteredSummaries.map((item, index) => ({
|
||||
title: item.libraryItem.title,
|
||||
id: item.libraryItem.id,
|
||||
url: item.libraryItem.originalUrl,
|
||||
thumbnail: item.libraryItem.thumbnail ?? undefined,
|
||||
wordCount: speechFiles[index].wordCount,
|
||||
})),
|
||||
createdAt: new Date(),
|
||||
description: generateDescription(summaries, rankedTopics),
|
||||
byline: generateByline(summaries),
|
||||
urlsToAudio: [],
|
||||
}
|
||||
|
||||
await writeDigest(jobData.userId, digest)
|
||||
} catch (error) {
|
||||
logger.error('createDigestJob error', error)
|
||||
|
||||
await writeDigest(jobData.userId, {
|
||||
id: jobData.id,
|
||||
jobState: TaskState.Failed,
|
||||
})
|
||||
} finally {
|
||||
// send notification
|
||||
const tokens = await findDeviceTokensByUserId(jobData.userId)
|
||||
if (tokens.length > 0) {
|
||||
const message = {
|
||||
notification: {
|
||||
title: 'Digest ready',
|
||||
body: 'Your digest is ready to listen',
|
||||
},
|
||||
tokens: tokens.map((token) => token.token),
|
||||
}
|
||||
|
||||
await sendMulticastPushNotifications(jobData.userId, message, 'reminder')
|
||||
}
|
||||
}
|
||||
|
||||
await writeDigest(jobData.userId, digest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -786,6 +786,7 @@ export const updatesSinceResolver = authorized<
|
|||
size: size + 1, // fetch one more item to get next cursor
|
||||
includeDeleted: true,
|
||||
query,
|
||||
includeContent: true, // by default include content for offline use for now
|
||||
},
|
||||
uid
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import cors from 'cors'
|
||||
import express from 'express'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { env } from '../env'
|
||||
import { TaskState } from '../generated/graphql'
|
||||
import { CreateDigestJobSchedule } from '../jobs/ai/create_digest'
|
||||
|
|
@ -11,7 +12,6 @@ import { getClaimsByToken, getTokenByRequest } from '../utils/auth'
|
|||
import { corsConfig } from '../utils/corsConfig'
|
||||
import { enqueueCreateDigest } from '../utils/createTask'
|
||||
import { logger } from '../utils/logger'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
interface Feedback {
|
||||
digestRating: number
|
||||
|
|
@ -153,15 +153,11 @@ export function digestRouter() {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (digest.jobState === TaskState.Running) {
|
||||
// if job is running then return job state
|
||||
return res.send({
|
||||
jobId: digest.id,
|
||||
jobState: digest.jobState,
|
||||
})
|
||||
if (digest.jobState === TaskState.Failed) {
|
||||
logger.error(`Digest job failed: ${userId}`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
// if job is done then return the digest
|
||||
return res.send(digest)
|
||||
} catch (error) {
|
||||
logger.error('Error while getting digest', error)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@
|
|||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
import * as lw from '@google-cloud/logging-winston'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { ApolloServer } from 'apollo-server-express'
|
||||
import { json, urlencoded } from 'body-parser'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import express, { Express } from 'express'
|
||||
import * as httpContext from 'express-http-context2'
|
||||
import promBundle from 'express-prom-bundle'
|
||||
import { createServer, Server } from 'http'
|
||||
import { createServer } from 'http'
|
||||
import * as prom from 'prom-client'
|
||||
import { config, loggers } from 'winston'
|
||||
import { makeApolloServer } from './apollo'
|
||||
|
|
@ -150,7 +149,8 @@ const main = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
const app = createApp()
|
||||
const apollo = makeApolloServer(app)
|
||||
const httpServer = createServer(app)
|
||||
const apollo = makeApolloServer(app, httpServer)
|
||||
await apollo.start()
|
||||
apollo.applyMiddleware({ app, path: '/api/graphql', cors: corsConfig })
|
||||
|
||||
|
|
@ -159,7 +159,7 @@ const main = async (): Promise<void> => {
|
|||
const mw = await lw.express.makeMiddleware(mwLogger, transport)
|
||||
app.use(mw)
|
||||
|
||||
const listener = app.listen({ port: PORT }, async () => {
|
||||
const listener = httpServer.listen({ port: PORT }, async () => {
|
||||
const logger = buildLogger('app.dispatch')
|
||||
logger.notice(`🚀 Server ready at ${apollo.graphqlPath}`)
|
||||
})
|
||||
|
|
@ -176,22 +176,12 @@ const main = async (): Promise<void> => {
|
|||
const gracefulShutdown = async (signal: string) => {
|
||||
console.log(`[api]: Received ${signal}, closing server...`)
|
||||
await apollo.stop()
|
||||
console.log('[api]: Apollo server stopped')
|
||||
console.log('[api]: Express server stopped')
|
||||
|
||||
console.log('[posthog]: flushing events')
|
||||
await analytics.shutdownAsync()
|
||||
console.log('[posthog]: events flushed')
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
listener.close((err) => {
|
||||
console.log('[api]: Express listener closed')
|
||||
if (err) {
|
||||
console.log('[api]: error stopping listener', { err })
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
// Shutdown redis before DB because the quit sequence can
|
||||
// cause appDataSource to get reloaded in the callback
|
||||
await redisDataSource.shutdown()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { logger } from '../utils/logger'
|
|||
const MAX_ULTRA_REALISTIC_USERS = 1500
|
||||
const MAX_YOUTUBE_TRANSCRIPT_USERS = 500
|
||||
const MAX_NOTION_USERS = 1000
|
||||
const MAX_AIDIGEST_USERS = 5
|
||||
const MAX_AIDIGEST_USERS = 10
|
||||
|
||||
export enum FeatureName {
|
||||
AISummaries = 'ai-summaries',
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@ import {
|
|||
CreateDigestJobResponse,
|
||||
CreateDigestJobSchedule,
|
||||
CREATE_DIGEST_JOB,
|
||||
CRON_PATTERNS,
|
||||
getCronPattern,
|
||||
} from '../jobs/ai/create_digest'
|
||||
import { BulkActionData, BULK_ACTION_JOB_NAME } from '../jobs/bulk_action'
|
||||
import { CallWebhookJobData, CALL_WEBHOOK_JOB_NAME } from '../jobs/call_webhook'
|
||||
import { SendEmailJobData, SEND_EMAIL_JOB } from '../jobs/email/send_email'
|
||||
import { THUMBNAIL_JOB } from '../jobs/find_thumbnail'
|
||||
import { EXPORT_ALL_ITEMS_JOB_NAME } from '../jobs/integration/export_all_items'
|
||||
import {
|
||||
|
|
@ -42,7 +45,6 @@ import {
|
|||
REFRESH_ALL_FEEDS_JOB_NAME,
|
||||
REFRESH_FEED_JOB_NAME,
|
||||
} from '../jobs/rss/refreshAllFeeds'
|
||||
import { SendEmailJobData, SEND_EMAIL_JOB } from '../jobs/email/send_email'
|
||||
import { SYNC_READ_POSITIONS_JOB_NAME } from '../jobs/sync_read_positions'
|
||||
import { TriggerRuleJobData, TRIGGER_RULE_JOB_NAME } from '../jobs/trigger_rule'
|
||||
import {
|
||||
|
|
@ -53,13 +55,13 @@ import {
|
|||
} from '../jobs/update_db'
|
||||
import { getBackendQueue, JOB_VERSION } from '../queue-processor'
|
||||
import { redisDataSource } from '../redis_data_source'
|
||||
import { writeDigest } from '../services/digest'
|
||||
import { signFeatureToken } from '../services/features'
|
||||
import { OmnivoreAuthorizationHeader } from './auth'
|
||||
import { CreateTaskError } from './errors'
|
||||
import { stringToHash } from './helpers'
|
||||
import { logger } from './logger'
|
||||
import View = google.cloud.tasks.v2.Task.View
|
||||
import { writeDigest } from '../services/digest'
|
||||
|
||||
// Instantiates a client.
|
||||
const client = new CloudTasksClient()
|
||||
|
|
@ -870,22 +872,22 @@ export const enqueueCreateDigest = async (
|
|||
throw new Error('No queue found')
|
||||
}
|
||||
|
||||
// enqueue create digest job immediately
|
||||
const jobId = `${CREATE_DIGEST_JOB}_${data.userId}`
|
||||
const job = await queue.add(CREATE_DIGEST_JOB, data, {
|
||||
jobId: data.id, // dedupe by job id
|
||||
jobId, // dedupe by job id
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
attempts: 3,
|
||||
attempts: 1,
|
||||
priority: getJobPriority(CREATE_DIGEST_JOB),
|
||||
repeat: schedule
|
||||
? {
|
||||
immediately: true, // run immediately
|
||||
pattern: schedule === 'daily' ? '0 13 * * *' : '0 13 * * 7', // every day or every Sunday at 1PM
|
||||
utc: true,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
logger.info('create digest job enqueued', { jobId: job.id })
|
||||
if (!job || !job.id) {
|
||||
logger.error('Error while enqueuing create digest job', data)
|
||||
throw new Error('Error while enqueuing create digest job')
|
||||
}
|
||||
|
||||
logger.info('create digest job enqueued', { jobId })
|
||||
|
||||
const digest = {
|
||||
id: data.id,
|
||||
|
|
@ -895,6 +897,44 @@ export const enqueueCreateDigest = async (
|
|||
// update digest job state in redis
|
||||
await writeDigest(data.userId, digest)
|
||||
|
||||
if (schedule) {
|
||||
await Promise.all(
|
||||
Object.keys(CRON_PATTERNS).map(async (key) => {
|
||||
// remove existing repeated job if any
|
||||
const isDeleted = await queue.removeRepeatable(
|
||||
CREATE_DIGEST_JOB,
|
||||
{
|
||||
pattern: CRON_PATTERNS[key as keyof typeof CRON_PATTERNS],
|
||||
tz: 'UTC',
|
||||
},
|
||||
jobId
|
||||
)
|
||||
|
||||
if (isDeleted) {
|
||||
logger.info('existing repeated job removed', { jobId, schedule: key })
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// schedule repeated job
|
||||
const job = await queue.add(CREATE_DIGEST_JOB, data, {
|
||||
attempts: 1,
|
||||
priority: getJobPriority(CREATE_DIGEST_JOB),
|
||||
repeat: {
|
||||
pattern: getCronPattern(schedule),
|
||||
jobId,
|
||||
tz: 'UTC',
|
||||
},
|
||||
})
|
||||
|
||||
if (!job || !job.id) {
|
||||
logger.error('Error while scheduling create digest job', data)
|
||||
throw new Error('Error while scheduling create digest job')
|
||||
}
|
||||
|
||||
logger.info('create digest job scheduled', { jobId, schedule })
|
||||
}
|
||||
|
||||
return {
|
||||
jobId: digest.id,
|
||||
jobState: digest.jobState,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ConnectionOptions, Job, QueueEvents, Worker } from 'bullmq'
|
||||
import { createServer } from 'http'
|
||||
import { nanoid } from 'nanoid'
|
||||
import supertest from 'supertest'
|
||||
import { v4 } from 'uuid'
|
||||
|
|
@ -8,7 +9,8 @@ import { createApp } from '../src/server'
|
|||
import { corsConfig } from '../src/utils/corsConfig'
|
||||
|
||||
const app = createApp()
|
||||
const apollo = makeApolloServer(app)
|
||||
const httpServer = createServer(app)
|
||||
const apollo = makeApolloServer(app, httpServer)
|
||||
export const request = supertest(app)
|
||||
let worker: Worker
|
||||
let queueEvents: QueueEvents
|
||||
|
|
|
|||
95
yarn.lock
95
yarn.lock
|
|
@ -6493,11 +6493,6 @@
|
|||
resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20"
|
||||
integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg==
|
||||
|
||||
"@sqltools/formatter@^1.2.5":
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.5.tgz#3abc203c79b8c3e90fd6c156a0c62d5403520e12"
|
||||
integrity sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==
|
||||
|
||||
"@stitches/react@^1.2.5":
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@stitches/react/-/react-1.2.8.tgz#954f8008be8d9c65c4e58efa0937f32388ce3a38"
|
||||
|
|
@ -8353,13 +8348,6 @@
|
|||
dependencies:
|
||||
undici-types "~5.26.4"
|
||||
|
||||
"@types/node@^20.11.0":
|
||||
version "20.12.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.7.tgz#04080362fa3dd6c5822061aa3124f5c152cff384"
|
||||
integrity sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==
|
||||
dependencies:
|
||||
undici-types "~5.26.4"
|
||||
|
||||
"@types/node@^20.8.4":
|
||||
version "20.11.30"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.30.tgz#9c33467fc23167a347e73834f788f4b9f399d66f"
|
||||
|
|
@ -9928,11 +9916,6 @@ app-root-path@^3.0.0:
|
|||
resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-3.0.0.tgz#210b6f43873227e18a4b810a032283311555d5ad"
|
||||
integrity sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw==
|
||||
|
||||
app-root-path@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-3.1.0.tgz#5971a2fc12ba170369a7a1ef018c71e6e47c2e86"
|
||||
integrity sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==
|
||||
|
||||
apparatus@^0.0.10:
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/apparatus/-/apparatus-0.0.10.tgz#81ea756772ada77863db54ceee8202c109bdca3e"
|
||||
|
|
@ -13397,7 +13380,7 @@ dateformat@^3.0.0, dateformat@^3.0.3:
|
|||
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
|
||||
integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
|
||||
|
||||
dayjs@1.x, dayjs@^1.10.4, dayjs@^1.11.7, dayjs@^1.11.9:
|
||||
dayjs@1.x, dayjs@^1.10.4, dayjs@^1.11.7:
|
||||
version "1.11.10"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
|
||||
integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==
|
||||
|
|
@ -14133,7 +14116,7 @@ dotenv@^16.0.1:
|
|||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d"
|
||||
integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ==
|
||||
|
||||
dotenv@^16.0.3, dotenv@^16.3.1:
|
||||
dotenv@^16.3.1:
|
||||
version "16.4.5"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f"
|
||||
integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==
|
||||
|
|
@ -16828,17 +16811,6 @@ glob@^10.2.2:
|
|||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
path-scurry "^1.10.1"
|
||||
|
||||
glob@^10.3.10:
|
||||
version "10.3.12"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.12.tgz#3a65c363c2e9998d220338e88a5f6ac97302960b"
|
||||
integrity sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==
|
||||
dependencies:
|
||||
foreground-child "^3.1.0"
|
||||
jackspeak "^2.3.6"
|
||||
minimatch "^9.0.1"
|
||||
minipass "^7.0.4"
|
||||
path-scurry "^1.10.2"
|
||||
|
||||
glob@^8.0.0:
|
||||
version "8.0.3"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e"
|
||||
|
|
@ -19292,7 +19264,7 @@ iterator.prototype@^1.1.2:
|
|||
reflect.getprototypeof "^1.0.4"
|
||||
set-function-name "^2.0.1"
|
||||
|
||||
jackspeak@^2.3.5, jackspeak@^2.3.6:
|
||||
jackspeak@^2.3.5:
|
||||
version "2.3.6"
|
||||
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8"
|
||||
integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==
|
||||
|
|
@ -21284,11 +21256,6 @@ lowlight@^1.14.0:
|
|||
fault "^1.0.0"
|
||||
highlight.js "~10.7.0"
|
||||
|
||||
lru-cache@^10.2.0:
|
||||
version "10.2.0"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3"
|
||||
integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==
|
||||
|
||||
lru-cache@^4.1.5:
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
|
||||
|
|
@ -22514,7 +22481,7 @@ minipass@^5.0.0:
|
|||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
|
||||
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
|
||||
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.3, minipass@^7.0.4:
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.3:
|
||||
version "7.0.4"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c"
|
||||
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
|
||||
|
|
@ -22602,11 +22569,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
|
|||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
||||
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
||||
|
||||
mkdirp@^2.1.3:
|
||||
version "2.1.6"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-2.1.6.tgz#964fbcb12b2d8c5d6fbc62a963ac95a273e2cc19"
|
||||
integrity sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==
|
||||
|
||||
mkdirp@~0.3.5:
|
||||
version "0.3.5"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.5.tgz#de3e5f8961c88c787ee1368df849ac4413eca8d7"
|
||||
|
|
@ -24930,14 +24892,6 @@ path-scurry@^1.10.1, path-scurry@^1.6.1:
|
|||
lru-cache "^9.1.1 || ^10.0.0"
|
||||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
|
||||
path-scurry@^1.10.2:
|
||||
version "1.10.2"
|
||||
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.2.tgz#8f6357eb1239d5fa1da8b9f70e9c080675458ba7"
|
||||
integrity sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==
|
||||
dependencies:
|
||||
lru-cache "^10.2.0"
|
||||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
|
||||
path-to-regexp@0.1.7:
|
||||
version "0.1.7"
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
|
||||
|
|
@ -27007,14 +26961,6 @@ read-pkg@^7.1.0:
|
|||
parse-json "^5.2.0"
|
||||
type-fest "^2.0.0"
|
||||
|
||||
read-yaml-file@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/read-yaml-file/-/read-yaml-file-2.1.0.tgz#c5866712db9ef5343b4d02c2413bada53c41c4a9"
|
||||
integrity sha512-UkRNRIwnhG+y7hpqnycCL/xbTk7+ia9VuVTC0S+zVbwd65DI9eUpRMfsWIGrCWxTU/mi+JW8cHQCrv+zfCbEPQ==
|
||||
dependencies:
|
||||
js-yaml "^4.0.0"
|
||||
strip-bom "^4.0.0"
|
||||
|
||||
read@1, read@^1.0.7, read@~1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4"
|
||||
|
|
@ -27159,11 +27105,6 @@ reflect-metadata@^0.1.13:
|
|||
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"
|
||||
integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==
|
||||
|
||||
reflect-metadata@^0.2.1:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b"
|
||||
integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==
|
||||
|
||||
reflect.getprototypeof@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz#aaccbf41aca3821b87bb71d9dcbc7ad0ba50a3f3"
|
||||
|
|
@ -30230,7 +30171,7 @@ tslib@^1.0.0, tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
|
|||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
||||
tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2:
|
||||
tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.6.2:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||
|
|
@ -30446,27 +30387,6 @@ typeorm-naming-strategies@^4.1.0:
|
|||
resolved "https://registry.yarnpkg.com/typeorm-naming-strategies/-/typeorm-naming-strategies-4.1.0.tgz#1ec6eb296c8d7b69bb06764d5b9083ff80e814a9"
|
||||
integrity sha512-vPekJXzZOTZrdDvTl1YoM+w+sUIfQHG4kZTpbFYoTsufyv9NIBRe4Q+PdzhEAFA2std3D9LZHEb1EjE9zhRpiQ==
|
||||
|
||||
typeorm@^0.3.19:
|
||||
version "0.3.20"
|
||||
resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.20.tgz#4b61d737c6fed4e9f63006f88d58a5e54816b7ab"
|
||||
integrity sha512-sJ0T08dV5eoZroaq9uPKBoNcGslHBR4E4y+EBHs//SiGbblGe7IeduP/IH4ddCcj0qp3PHwDwGnuvqEAnKlq/Q==
|
||||
dependencies:
|
||||
"@sqltools/formatter" "^1.2.5"
|
||||
app-root-path "^3.1.0"
|
||||
buffer "^6.0.3"
|
||||
chalk "^4.1.2"
|
||||
cli-highlight "^2.1.11"
|
||||
dayjs "^1.11.9"
|
||||
debug "^4.3.4"
|
||||
dotenv "^16.0.3"
|
||||
glob "^10.3.10"
|
||||
mkdirp "^2.1.3"
|
||||
reflect-metadata "^0.2.1"
|
||||
sha.js "^2.4.11"
|
||||
tslib "^2.5.0"
|
||||
uuid "^9.0.0"
|
||||
yargs "^17.6.2"
|
||||
|
||||
typeorm@^0.3.4:
|
||||
version "0.3.7"
|
||||
resolved "https://registry.yarnpkg.com/typeorm/-/typeorm-0.3.7.tgz#5776ed5058f0acb75d64723b39ff458d21de64c1"
|
||||
|
|
@ -30505,11 +30425,6 @@ typescript@^4.4.4:
|
|||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||
|
||||
typescript@^5.3.3:
|
||||
version "5.4.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611"
|
||||
integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==
|
||||
|
||||
ua-parser-js@^0.7.30:
|
||||
version "0.7.33"
|
||||
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532"
|
||||
|
|
|
|||
Loading…
Reference in a new issue