import assert from 'node:assert/strict'; import type {SchedulerAdapter} from '@tryghost/adapter-base-scheduling'; import type {InternalKeys} from '../internal-keys'; // @ts-expect-error @tryghost/domain-events currently lacks type declarations. import type DomainEvents from '@tryghost/domain-events'; import {oneAtATime} from '../../../shared/one-at-a-time'; import {poll} from './poll'; import * as automationsApi from './automations-api'; import {getSchedulerIdempotencyKey} from './get-scheduler-idempotency-key'; import {setImmediate as flushEventLoop} from 'node:timers/promises'; import {SoonestTimer} from '../../lib/soonest-timer'; import {getSchedulerPollTime} from './scheduler-poll-time'; // @ts-expect-error This module currently lacks type definitions. import emailAnalyticsJobs from '../email-analytics/jobs'; const urlUtils = require('../../../shared/url-utils').default; const logging = require('@tryghost/logging'); const {getSignedAdminToken} = require('../../adapters/scheduling/utils'); const StartAutomationsPollEvent = require('./events/start-automations-poll-event'); const {welcomeEmailAutomationPoll} = require('./welcome-email-automation-poll'); const memberWelcomeEmailService = require('../member-welcome-emails/service'); type AutomationsServiceOptions = { apiUrl: string; domainEvents: Pick; internalKeys: InternalKeys; schedulerAdapter: Pick; siteUuid: unknown; }; const scheduleAutomationEmailAnalyticsJob = () => ( emailAnalyticsJobs.scheduleRecurringAutomationsJob(true) ); export class AutomationsService { #enqueuePollAt: undefined | ((date: Readonly) => Promise); init({domainEvents, apiUrl, schedulerAdapter, internalKeys, siteUuid}: AutomationsServiceOptions): void { const isInitialized = Boolean(this.#enqueuePollAt); if (isInitialized) { return; } // If we don't get a valid site UUID for some reason, compute jitter with the API URL. const siteIdentifier = typeof siteUuid === 'string' && siteUuid.length ? siteUuid : apiUrl; const enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create()); const soonestTimer = new SoonestTimer(enqueuePollNow); /** * Enqueue an automations poll at a given time. * * If the poll is in the future, we schedule an in-memory timer *and* * tell the scheduler. * * The in-memory timer can be more precise than the scheduler, and * avoids reliance on an external service. The scheduler will wake up * the server if it's stopped. * * (In an upcoming change (NY-1396), we plan to make the scheduler less * precise to reduce load--that will make the in-memory timer more * useful, but it's still useful now.) */ const enqueuePollAt = async (date: Readonly): Promise => { const isRequestedDateInTheFuture = new Date() < date; if (!isRequestedDateInTheFuture) { // If you're using synchronous SQLite, we want to finish unwinding the call stack // before dispatching another poll event. await flushEventLoop(); enqueuePollNow(); return; } soonestTimer.scheduleAt(date); try { const schedulerPollTime = getSchedulerPollTime(date, siteIdentifier); const key = await internalKeys.get('ghost-scheduler'); const signedAdminToken = getSignedAdminToken({ publishedAt: schedulerPollTime.toISOString(), apiUrl, key }); const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll')); url.searchParams.set('token', signedAdminToken); schedulerAdapter.schedule({ time: schedulerPollTime.getTime(), url: url.toString(), extra: { httpMethod: 'PUT', idempotencyKey: getSchedulerIdempotencyKey(date, url) } }); } catch (err) { logging.error({event: {name: 'automations.enqueue-poll.error'}, err, at: date.toISOString()}, 'Failed to enqueue automations poll'); } }; domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(() => poll({ automationsApi, memberWelcomeEmailService, scheduleAutomationEmailAnalyticsJob, enqueueAnotherPollAt: enqueuePollAt }))); domainEvents.subscribe(StartAutomationsPollEvent, oneAtATime(() => welcomeEmailAutomationPoll({ memberWelcomeEmailService, enqueueAnotherPollAt: enqueuePollAt }))); schedulerAdapter.register(this); enqueuePollAt(new Date()); this.#enqueuePollAt = enqueuePollAt; } /** * Re-arm the poll chain. A queued poll signed under the previous scheduler * key fails JWT verification when fired; this dispatches a fresh in-process * poll that re-schedules the next callback under the current key. */ async rescheduleAll(): Promise { await this.#enqueuePollAt?.(new Date()); } async __testOnlyEnqueuePollAt(date: Readonly): Promise { assert(this.#enqueuePollAt, 'Tests should not call this before initialization'); return await this.#enqueuePollAt(date); } }