finish up the scheduler
This commit is contained in:
		
							parent
							
								
									3e7def3a26
								
							
						
					
					
						commit
						8f584b5b05
					
				|  | @ -0,0 +1,18 @@ | |||
| { | ||||
| 	"semi": true, | ||||
| 	"singleQuote": false, | ||||
| 	"trailingComma": "none", | ||||
| 	"useTabs": false, | ||||
| 	"printWidth": 120, | ||||
| 	"endOfLine": "lf", | ||||
| 	"overrides": [ | ||||
| 		{ | ||||
| 			"files": ["**/*.scss"], | ||||
| 			"options": { | ||||
| 				"useTabs": false, | ||||
| 				"tabWidth": 2, | ||||
| 				"singleQuote": true | ||||
| 			} | ||||
| 		} | ||||
| 	] | ||||
| } | ||||
|  | @ -4,21 +4,13 @@ import * as O from "fp-ts/lib/Option"; | |||
| import { createTransport } from "nodemailer"; | ||||
| import { toError } from "fp-ts/lib/Either"; | ||||
| import { pipe } from "fp-ts/lib/function"; | ||||
| import { | ||||
|   ImapFlow, | ||||
|   type FetchMessageObject, | ||||
|   type FetchQueryObject, | ||||
|   type MailboxLockObject, | ||||
| } from "imapflow"; | ||||
| import { ImapFlow, type FetchMessageObject, type FetchQueryObject, type MailboxLockObject } from "imapflow"; | ||||
| import * as IO from "fp-ts/lib/IO"; | ||||
| import * as T from "fp-ts/lib/Task"; | ||||
| import { ConsoleLogger } from "../util"; | ||||
| 
 | ||||
| interface ImapClientI { | ||||
|   fetchAll: ( | ||||
|     range: string, | ||||
|     options: FetchQueryObject, | ||||
|   ) => Promise<FetchMessageObject[]>; | ||||
|   fetchAll: (range: string, options: FetchQueryObject) => Promise<FetchMessageObject[]>; | ||||
|   connect: () => Promise<void>; | ||||
|   getMailboxLock: (mailbox: string) => Promise<MailboxLockObject>; | ||||
|   messageDelete: (uids: number[]) => Promise<boolean>; | ||||
|  | @ -40,10 +32,7 @@ class ErrorWithLock extends Error { | |||
|   } | ||||
| } | ||||
| const ToErrorWithLock = (lock?: MailboxLockObject) => (error: unknown) => | ||||
|   new ErrorWithLock( | ||||
|     error instanceof Error ? error.message : "Unknown error", | ||||
|     lock, | ||||
|   ); | ||||
|   new ErrorWithLock(error instanceof Error ? error.message : "Unknown error", lock); | ||||
| 
 | ||||
| /** | ||||
|  * Generate a unique email. | ||||
|  | @ -51,42 +40,31 @@ const ToErrorWithLock = (lock?: MailboxLockObject) => (error: unknown) => | |||
|  * @param to is the email to send to. | ||||
|  * @returns an {@link Email}. | ||||
|  */ | ||||
| type EmailGenerator = ( | ||||
|   from: EmailFromInstruction, | ||||
|   to: EmailToInstruction, | ||||
| ) => IO.IO<Email>; | ||||
| const generateEmail: EmailGenerator = | ||||
|   (from: EmailFromInstruction, to: EmailToInstruction) => () => ({ | ||||
|     from: from.email, | ||||
|     to: to.email, | ||||
|     subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "), | ||||
|     text: crypto.randomUUID(), | ||||
|   }); | ||||
| type EmailGenerator = (from: EmailFromInstruction, to: EmailToInstruction) => IO.IO<Email>; | ||||
| const generateEmail: EmailGenerator = (from: EmailFromInstruction, to: EmailToInstruction) => () => ({ | ||||
|   from: from.email, | ||||
|   to: to.email, | ||||
|   subject: [new Date().toISOString(), crypto.randomUUID()].join(" | "), | ||||
|   text: crypto.randomUUID() | ||||
| }); | ||||
| 
 | ||||
| /** | ||||
|  * Get the transport layer for a mailbox to send a piece of mail. | ||||
|  * @param param0 is the mailbox to send from. | ||||
|  * @returns a function that takes an email and sends it. | ||||
|  */ | ||||
| type GetSendEmail = ( | ||||
|   from: EmailFromInstruction, | ||||
| ) => (email: Email) => TE.TaskEither<Error, Email>; | ||||
| const getSendTransport: GetSendEmail = ({ | ||||
|   username, | ||||
|   password, | ||||
|   server, | ||||
|   send_port, | ||||
| }) => { | ||||
| type GetSendEmail = (from: EmailFromInstruction) => (email: Email) => TE.TaskEither<Error, Email>; | ||||
| const getSendTransport: GetSendEmail = ({ username, password, server, send_port }) => { | ||||
|   const transport = createTransport({ | ||||
|     host: server, | ||||
|     port: send_port, | ||||
|     auth: { | ||||
|       user: username, | ||||
|       pass: password, | ||||
|       pass: password | ||||
|     }, | ||||
|     tls: { | ||||
|       rejectUnauthorized: false, | ||||
|     }, | ||||
|       rejectUnauthorized: false | ||||
|     } | ||||
|   }); | ||||
|   return (email: Email) => | ||||
|     TE.tryCatch( | ||||
|  | @ -98,9 +76,9 @@ const getSendTransport: GetSendEmail = ({ | |||
|             } else { | ||||
|               resolve(email); | ||||
|             } | ||||
|           }), | ||||
|           }) | ||||
|         ), | ||||
|       toError, | ||||
|       toError | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
|  | @ -109,9 +87,7 @@ const getSendTransport: GetSendEmail = ({ | |||
|  * @param param0 is the mailbox to read from. | ||||
|  * @returns a Right({@link ImapFlow}) if it connected, else an Left(error). | ||||
|  */ | ||||
| type GetImapClient = ( | ||||
|   to: EmailToInstruction, | ||||
| ) => TE.TaskEither<Error, ImapClientI>; | ||||
| type GetImapClient = (to: EmailToInstruction) => TE.TaskEither<Error, ImapClientI>; | ||||
| const getImap: GetImapClient = ({ username, password, server, read_port }) => { | ||||
|   const imap = new ImapFlow({ | ||||
|     logger: false, | ||||
|  | @ -120,8 +96,8 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => { | |||
|     secure: true, | ||||
|     auth: { | ||||
|       user: username, | ||||
|       pass: password, | ||||
|     }, | ||||
|       pass: password | ||||
|     } | ||||
|   }); | ||||
|   return TE.tryCatch(() => imap.connect().then(() => imap), toError); | ||||
| }; | ||||
|  | @ -130,18 +106,16 @@ const getImap: GetImapClient = ({ username, password, server, read_port }) => { | |||
|  * @param imap is the Imap client to fetch messages from. | ||||
|  * @returns a Right({@link FetchMessageObject}[]) if successful, else a Left(error). | ||||
|  */ | ||||
| const fetchMessages = ( | ||||
|   imap: ImapClientI, | ||||
| ): TE.TaskEither<Error, FetchMessageObject[]> => | ||||
| const fetchMessages = (imap: ImapClientI): TE.TaskEither<Error, FetchMessageObject[]> => | ||||
|   TE.tryCatch( | ||||
|     () => | ||||
|       imap.fetchAll("*", { | ||||
|         uid: true, | ||||
|         envelope: true, | ||||
|         headers: true, | ||||
|         bodyParts: ["text"], | ||||
|         bodyParts: ["text"] | ||||
|       }), | ||||
|     toError, | ||||
|     toError | ||||
|   ); | ||||
| 
 | ||||
| /** | ||||
|  | @ -152,8 +126,7 @@ const fetchMessages = ( | |||
| type EmailMatcher = (email: Email) => (message: FetchMessageObject) => boolean; | ||||
| const matchesEmail: EmailMatcher = (email) => (message) => { | ||||
|   const subjectMatches = email.subject === message.envelope.subject; | ||||
|   const bodyMatches = | ||||
|     message.bodyParts.get("text")?.toString().trim() === email.text.trim(); | ||||
|   const bodyMatches = message.bodyParts.get("text")?.toString().trim() === email.text.trim(); | ||||
|   const headers = message.headers.toLocaleString(); | ||||
|   const fromMatches = headers.includes(`Return-Path: <${email.from}>`); | ||||
|   const toMatches = headers.includes(`Delivered-To: ${email.to}`); | ||||
|  | @ -172,14 +145,9 @@ type FindEmailUidInInbox = ( | |||
|   imap: ImapClientI, | ||||
|   equalsEmail: (message: FetchMessageObject) => boolean, | ||||
|   retries: number, | ||||
|   pollIntervalMs: number, | ||||
|   pollIntervalMs: number | ||||
| ) => TE.TaskEither<Error, number>; | ||||
| const findEmailUidInInbox: FindEmailUidInInbox = ( | ||||
|   imap, | ||||
|   equalsEmail, | ||||
|   retries, | ||||
|   pollIntervalMs, | ||||
| ) => | ||||
| const findEmailUidInInbox: FindEmailUidInInbox = (imap, equalsEmail, retries, pollIntervalMs) => | ||||
|   pipe( | ||||
|     fetchMessages(imap), | ||||
|     TE.flatMap((messages) => { | ||||
|  | @ -193,17 +161,11 @@ const findEmailUidInInbox: FindEmailUidInInbox = ( | |||
|       (e) => | ||||
|         pipe( | ||||
|           TE.fromIO(ConsoleLogger.log(`failed; ${retries} retries left.`)), | ||||
|           TE.chain(() => | ||||
|             retries === 0 | ||||
|               ? TE.left(e) | ||||
|               : T.delay(pollIntervalMs)(TE.right(null)), | ||||
|           ), | ||||
|           TE.chain(() => | ||||
|             findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs), | ||||
|           ), | ||||
|           TE.chain(() => (retries === 0 ? TE.left(e) : T.delay(pollIntervalMs)(TE.right(null)))), | ||||
|           TE.chain(() => findEmailUidInInbox(imap, equalsEmail, retries - 1, pollIntervalMs)) | ||||
|         ), | ||||
|       TE.of, | ||||
|     ), | ||||
|       TE.of | ||||
|     ) | ||||
|   ); | ||||
| 
 | ||||
| export type EmailJobDependencies = { | ||||
|  | @ -225,40 +187,31 @@ export const perform = ( | |||
|     getSendImpl = getSendTransport, | ||||
|     getImapImpl = getImap, | ||||
|     findEmailUidInInboxImpl = findEmailUidInInbox, | ||||
|     matchesEmailImpl = matchesEmail, | ||||
|   }: Partial<EmailJobDependencies> = {}, | ||||
|     matchesEmailImpl = matchesEmail | ||||
|   }: Partial<EmailJobDependencies> = {} | ||||
| ): TE.TaskEither<Error, boolean> => | ||||
|   pipe( | ||||
|     // arrange.
 | ||||
|     TE.fromIO(generateEmailImpl(from, to)), | ||||
|     TE.bindTo("email"), | ||||
|     // act.
 | ||||
|     TE.tap(({ email }) => | ||||
|       pipe(getSendImpl(from)(email), TE.mapLeft(ToErrorWithLock())), | ||||
|     ), | ||||
|     TE.tap(({ email }) => pipe(getSendImpl(from)(email), TE.mapLeft(ToErrorWithLock()))), | ||||
|     TE.bind("imap", () => pipe(getImapImpl(to), TE.mapLeft(ToErrorWithLock()))), | ||||
|     TE.bind("mailboxLock", ({ imap }) => | ||||
|       TE.tryCatch(() => imap.getMailboxLock("INBOX"), ToErrorWithLock()), | ||||
|     ), | ||||
|     TE.bind("mailboxLock", ({ imap }) => TE.tryCatch(() => imap.getMailboxLock("INBOX"), ToErrorWithLock())), | ||||
|     // "assert".
 | ||||
|     TE.bind("uid", ({ imap, email, mailboxLock }) => | ||||
|       pipe( | ||||
|         findEmailUidInInboxImpl( | ||||
|           imap, | ||||
|           matchesEmailImpl(email), | ||||
|           retries, | ||||
|           interval, | ||||
|         ), | ||||
|         TE.mapLeft(ToErrorWithLock(mailboxLock)), | ||||
|       ), | ||||
|         findEmailUidInInboxImpl(imap, matchesEmailImpl(email), retries, interval), | ||||
|         TE.mapLeft(ToErrorWithLock(mailboxLock)) | ||||
|       ) | ||||
|     ), | ||||
|     // cleanup.
 | ||||
|     TE.bind("deleted", ({ imap, uid, mailboxLock }) => | ||||
|       TE.tryCatch( | ||||
|         //        () => imap.messageDelete([uid], { uid: true }),
 | ||||
|         () => imap.messageDelete([uid]), | ||||
|         ToErrorWithLock(mailboxLock), | ||||
|       ), | ||||
|         ToErrorWithLock(mailboxLock) | ||||
|       ) | ||||
|     ), | ||||
|     TE.fold( | ||||
|       (e) => { | ||||
|  | @ -270,6 +223,6 @@ export const perform = ( | |||
|       ({ mailboxLock, deleted }) => { | ||||
|         mailboxLock.release(); | ||||
|         return TE.right(deleted); | ||||
|       }, | ||||
|     ), | ||||
|       } | ||||
|     ) | ||||
|   ); | ||||
|  |  | |||
|  | @ -38,4 +38,4 @@ export interface EmailJob { | |||
|   readRetry: Retry; | ||||
| } | ||||
| 
 | ||||
| export type Job = EmailJob | PingJob | HealthCheckJob | DnsJob; | ||||
| export type TestJob = EmailJob | PingJob | HealthCheckJob | DnsJob; | ||||
|  |  | |||
|  | @ -0,0 +1,190 @@ | |||
| import * as TE from "fp-ts/lib/TaskEither"; | ||||
| import * as IO from "fp-ts/lib/IO"; | ||||
| import * as RA from "fp-ts/lib/ReadonlyArray"; | ||||
| import * as RM from "fp-ts/lib/ReadonlyMap"; | ||||
| import * as T from "fp-ts/Task"; | ||||
| import * as S from "fp-ts/string"; | ||||
| import { pipe, identity } from "fp-ts/lib/function"; | ||||
| import type { Separated } from "fp-ts/lib/Separated"; | ||||
| import type { Magma } from "fp-ts/lib/Magma"; | ||||
| import { intercalate } from "fp-ts/lib/Foldable"; | ||||
| import { nextSchedule, type Schedule } from "../canary"; | ||||
| import { ConsoleLogger, type Logger } from "../util"; | ||||
| 
 | ||||
| export interface Job { | ||||
|   id: string; | ||||
|   toString: () => string; | ||||
|   execute: <TResult>() => TE.TaskEither<Error, TResult>; | ||||
|   schedule: Schedule; | ||||
|   maxRetries: number; | ||||
| } | ||||
| 
 | ||||
| export const logExecutingJobs = (jobs: ReadonlyArray<Job>, now: Date, logger: Logger = ConsoleLogger) => { | ||||
|   const stringifieds = pipe( | ||||
|     jobs, | ||||
|     RA.map(({ toString }) => toString()), | ||||
|     (stringified) => intercalate(S.Monoid, RA.Foldable)("|", stringified) | ||||
|   ); | ||||
|   return logger.log(`Executing ${stringifieds} at ${now.toUTCString()}`); | ||||
| }; | ||||
| 
 | ||||
| export const execute = <TResult>( | ||||
|   jobs: ReadonlyArray<Job> | ||||
| ): T.Task<Separated<ReadonlyArray<[Job, Error]>, ReadonlyArray<[Job, TResult]>>> => | ||||
|   pipe( | ||||
|     T.of(jobs), | ||||
|     T.map( | ||||
|       RA.map((job) => | ||||
|         pipe( | ||||
|           job.execute(), | ||||
|           TE.bimap( | ||||
|             (error) => [job, error] as [Job, Error], | ||||
|             (result) => [job, result] as [Job, TResult] | ||||
|           ) | ||||
|         ) | ||||
|       ) | ||||
|     ), | ||||
|     T.flatMap(RA.wilt(T.ApplicativePar)(identity)) | ||||
|   ); | ||||
| 
 | ||||
| export type JobsTableRow = { | ||||
|   scheduled: Date; | ||||
|   retries: number; | ||||
| }; | ||||
| export type JobsTable = ReadonlyMap<string, JobsTableRow>; | ||||
| 
 | ||||
| export const jobsTableRowMagma: Magma<JobsTableRow> = { | ||||
|   concat: (a: JobsTableRow, b: JobsTableRow): JobsTableRow => { | ||||
|     if (a.scheduled <= b.scheduled) { | ||||
|       // always prefer the later schedule.
 | ||||
|       return b; | ||||
|     } | ||||
|     //    if (a.retries <= b.retries) {
 | ||||
|     //      return a;
 | ||||
|     //    }
 | ||||
|     return b; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export const formatJobResults = <TResult>( | ||||
|   jobsTable: JobsTable, | ||||
|   { left, right }: Separated<ReadonlyArray<[Job, Error]>, ReadonlyArray<[Job, TResult]>> | ||||
| ) => { | ||||
|   const failures = left | ||||
|     .map(([job, err]) => ({ | ||||
|       job, | ||||
|       retries: jobsTable.get(job.id)?.retries ?? job.maxRetries, | ||||
|       err | ||||
|     })) | ||||
|     .filter(({ job, retries }) => retries >= job.maxRetries); | ||||
|   const retries = left | ||||
|     .map(([job, err]) => ({ | ||||
|       job, | ||||
|       retries: jobsTable.get(job.id)?.retries ?? 0, | ||||
|       err | ||||
|     })) | ||||
|     .filter(({ job, retries }) => retries < job.maxRetries); | ||||
| 
 | ||||
|   const failureMessages = failures | ||||
|     .map(({ job, err, retries }) => job.toString() + ` | ${retries} / ${job.maxRetries} |  (err) :(  | ${err.message}`) | ||||
|     .join("\n"); | ||||
|   const retryMessages = retries | ||||
|     .map( | ||||
|       ({ job, err, retries }) => job.toString() + ` | ${retries} / ${job.maxRetries} |  (retry) :/  | ${err.message}` | ||||
|     ) | ||||
|     .join("\n"); | ||||
|   const successMessages = right.map(([job, val]) => job.toString() + " | (success) :) | " + val).join("\n"); | ||||
|   return `FAILURES:\n${failureMessages}\nRETRIES:\n${retryMessages}\nSUCCESSES:\n${successMessages}`; | ||||
| }; | ||||
| 
 | ||||
| export const perform = ( | ||||
|   jobsTable: JobsTable, | ||||
|   jobs: ReadonlyArray<Job>, | ||||
|   publishers: ReadonlyArray<(message: string) => TE.TaskEither<Error, number>>, | ||||
|   lastPingAck: Date, | ||||
|   publishAckEvery = 24 * (60 * 60 * 1_000), | ||||
|   logger = ConsoleLogger | ||||
| ) => | ||||
|   pipe( | ||||
|     T.of(jobs), | ||||
|     T.bindTo("jobs"), | ||||
|     T.bind( | ||||
|       "now", | ||||
|       T.fromIOK(() => IO.of(new Date())) | ||||
|     ), | ||||
|     T.bind("toExecute", ({ jobs, now }) => | ||||
|       pipe( | ||||
|         jobs, | ||||
|         RA.filter((job) => (jobsTable.get(job.id)?.scheduled ?? now) <= now), | ||||
|         T.of | ||||
|       ) | ||||
|     ), | ||||
|     T.tap(({ toExecute, now }) => T.fromIO(logExecutingJobs(toExecute, now))), | ||||
|     T.bind("results", ({ toExecute }) => execute(toExecute)), | ||||
|     T.bind("shouldAck", ({ now }) => T.of(now.getTime() - lastPingAck.getTime() >= publishAckEvery)), | ||||
|     T.tap(({ results, shouldAck }) => { | ||||
|       if (results.left.length === 0 && !shouldAck) { | ||||
|         return pipe( | ||||
|           T.of({ right: [] as ReadonlyArray<number> }), | ||||
|           T.tap(() => T.fromIO(logger.log("not publishing"))) | ||||
|         ); | ||||
|       } | ||||
| 
 | ||||
|       return pipe( | ||||
|         publishers, | ||||
|         RA.map((publish) => publish(formatJobResults(jobsTable, results))), | ||||
|         RA.wilt(T.ApplicativePar)(identity), | ||||
|         T.tap(({ left }) => | ||||
|           pipe( | ||||
|             left | ||||
|               ? logger.error("Encountered publishing errors: " + left.toString()) | ||||
|               : logger.log("Published successfully"), | ||||
|             T.fromIO | ||||
|           ) | ||||
|         ) | ||||
|       ); | ||||
|     }), | ||||
|     T.map(({ now, results: { left, right }, shouldAck }) => { | ||||
|       const ack = shouldAck ? now : lastPingAck; | ||||
| 
 | ||||
|       const leftResults = pipe( | ||||
|         left, | ||||
|         RA.map(([job]): readonly [string, JobsTableRow] => { | ||||
|           const row = jobsTable.get(job.id); | ||||
|           if (!row) { | ||||
|             return [job.id, { retries: 1, scheduled: now }]; | ||||
|           } | ||||
|           if (row.retries >= job.maxRetries) { | ||||
|             logger.error(`Hit max retries; scheduling again ;-; ${job.toString()}`); | ||||
|             return [job.id, { retries: 0, scheduled: nextSchedule(job.schedule, now) }] as const; | ||||
|           } | ||||
|           return [ | ||||
|             job.id, | ||||
|             { | ||||
|               retries: row.retries + 1, | ||||
|               scheduled: now | ||||
|             } | ||||
|           ] as const; | ||||
|         }) | ||||
|       ); | ||||
|       const results = pipe( | ||||
|         right, | ||||
|         RA.map(([job]): readonly [string, JobsTableRow] => [ | ||||
|           job.id, | ||||
|           { | ||||
|             retries: 0, | ||||
|             scheduled: nextSchedule(job.schedule, now) | ||||
|           } | ||||
|         ]), | ||||
|         RA.concat(leftResults) | ||||
|       ); | ||||
| 
 | ||||
|       const newJobsTable = pipe( | ||||
|         jobsTable, | ||||
|         RM.toReadonlyArray(S.Ord), | ||||
|         RA.concat(results), | ||||
|         RM.fromFoldable(S.Eq, jobsTableRowMagma, RA.Foldable) | ||||
|       ); | ||||
|       return { lastPingAck: ack, jobsTable: newJobsTable }; | ||||
|     }) | ||||
|   ); | ||||
|  | @ -1,13 +1,13 @@ | |||
| import * as TE from "fp-ts/lib/TaskEither"; | ||||
| import * as O from "fp-ts/lib/Option"; | ||||
| import type { Job } from "./job"; | ||||
| import type { TestJob } from "./job"; | ||||
| import type { D } from "../util"; | ||||
| 
 | ||||
| export enum TestType { | ||||
|   EMAIL = "email", | ||||
|   PING = "ping", | ||||
|   HEALTHCHECK = "healthcheck", | ||||
|   DNS = "dns", | ||||
|   DNS = "dns" | ||||
| } | ||||
| 
 | ||||
| export interface Schedule { | ||||
|  | @ -18,13 +18,14 @@ export interface Schedule { | |||
| export interface Test { | ||||
|   name: string; | ||||
|   type: TestType; | ||||
|   job: Job; | ||||
|   job: TestJob; | ||||
|   schedule: Schedule; | ||||
| } | ||||
| 
 | ||||
| export type Testable<T extends Job> = (job: T) => TE.TaskEither<Error, boolean>; | ||||
| export type Testable<TJob extends TestJob> = (job: TJob) => TE.TaskEither<Error, boolean>; | ||||
| 
 | ||||
| export const parseTestType = (testType: string): O.Option<TestType> => | ||||
|   Object.values(TestType).includes(testType as TestType) | ||||
|     ? O.some(testType as TestType) | ||||
|     : O.none; | ||||
|   Object.values(TestType).includes(testType as TestType) ? O.some(testType as TestType) : O.none; | ||||
| 
 | ||||
| export const nextSchedule = (schedule: Schedule, date: Date) => | ||||
|   new Date(date.getTime() + schedule.every + Math.random() * schedule.jitter); | ||||
|  |  | |||
|  | @ -8,9 +8,6 @@ const main: TE.TaskEither<Error, void> = pipe( | |||
|   TE.fromEither(parseArgs(Bun.argv)), | ||||
|   TE.bindTo("args"), | ||||
|   TE.bind("config", ({ args }) => TE.fromIO(readConfig(args.testsFile))), | ||||
|   TE.fold( | ||||
|     (e) => TE.left(toError(e)), | ||||
|     () => TE.right(undefined), | ||||
|   ), | ||||
|   TE.map(({ config }) => TE.tryCatch(() => {}, toError)) | ||||
| ); | ||||
| main(); | ||||
|  |  | |||
|  | @ -0,0 +1,19 @@ | |||
| import { $ } from "bun"; | ||||
| 
 | ||||
| export interface DiscordPost { | ||||
|   webhook: string; | ||||
|   role_id: string; | ||||
| } | ||||
| 
 | ||||
| export const publishDiscord = async (discordPost: DiscordPost, message: string) => { | ||||
|   console.log("Publishing to Discord"); | ||||
|   const ip = await $`dig +noall +short discord.com @1.1.1.1 A | shuf -n 1`.text(); | ||||
|   return fetch(discordPost.webhook.replace("discord.com", ip), { | ||||
|     headers: { | ||||
|       Host: "discord.com" | ||||
|     }, | ||||
|     method: "POST", | ||||
|     body: JSON.stringify({ message: `<@${discordPost.role_id}>\n${message}` }), | ||||
|     tls: { rejectUnauthorized: false } | ||||
|   }).then((r) => r.status); | ||||
| }; | ||||
|  | @ -1,23 +1,26 @@ | |||
| import { D } from "../util"; | ||||
| import { toError } from "fp-ts/lib/Either"; | ||||
| import * as TE from "fp-ts/TaskEither"; | ||||
| import { publishDiscord, type DiscordPost } from "./discord"; | ||||
| import { publishNtfy, type NtfyPost } from "./ntfy"; | ||||
| 
 | ||||
| export enum PublisherType { | ||||
|   DISCORD = "discord", | ||||
|   NTFY = "ntfy", | ||||
|   NTFY = "ntfy" | ||||
| } | ||||
| 
 | ||||
| export interface DiscordPost { | ||||
|   webhook: string; | ||||
|   role_id: string; | ||||
| } | ||||
| 
 | ||||
| export interface NtfyPost { | ||||
|   webhook: string; | ||||
| } | ||||
| 
 | ||||
| export type PublisherPost = DiscordPost | NtfyPost; | ||||
| 
 | ||||
| export interface Publisher { | ||||
|   type: PublisherType; | ||||
|   at: D.Duration; | ||||
|   post: PublisherPost; | ||||
| } | ||||
| 
 | ||||
| export const publish = (publisher: Publisher, message: string): TE.TaskEither<Error, number> => { | ||||
|   switch (publisher.type) { | ||||
|     case PublisherType.DISCORD: | ||||
|       return TE.tryCatch(() => publishDiscord(publisher.post as DiscordPost, message), toError); | ||||
|     case PublisherType.NTFY: | ||||
|       return TE.tryCatch(() => publishNtfy(publisher.post as NtfyPost, message), toError); | ||||
|     default: | ||||
|       return TE.left(new Error("unknown publisher type: " + publisher.type)); | ||||
|   } | ||||
| }; | ||||
|  |  | |||
|  | @ -0,0 +1,11 @@ | |||
| export interface NtfyPost { | ||||
|   webhook: string; | ||||
| } | ||||
| 
 | ||||
| export const publishNtfy = async (ntfyPost: NtfyPost, message: string) => { | ||||
|   console.log("publishing to Ntfy"); | ||||
|   return fetch(ntfyPost.webhook, { | ||||
|     method: "POST", | ||||
|     body: message | ||||
|   }).then((r) => r.status); | ||||
| }; | ||||
|  | @ -10,7 +10,7 @@ export enum DurationUnit { | |||
|   MILLISECOND, | ||||
|   SECOND, | ||||
|   MINUTE, | ||||
|   HOUR, | ||||
|   HOUR | ||||
| } | ||||
| const durationUnitMap: Record<string, DurationUnit> = { | ||||
|   ms: DurationUnit.MILLISECOND, | ||||
|  | @ -21,17 +21,14 @@ const durationUnitMap: Record<string, DurationUnit> = { | |||
|   minutes: DurationUnit.MINUTE, | ||||
|   hr: DurationUnit.HOUR, | ||||
|   hour: DurationUnit.HOUR, | ||||
|   hours: DurationUnit.HOUR, | ||||
|   hours: DurationUnit.HOUR | ||||
| }; | ||||
| const getDurationUnit = (key: string): O.Option<DurationUnit> => | ||||
|   O.fromNullable(durationUnitMap[key.toLowerCase()]); | ||||
| const getDurationUnit = (key: string): O.Option<DurationUnit> => O.fromNullable(durationUnitMap[key.toLowerCase()]); | ||||
| 
 | ||||
| export const getMs = (duration: Duration): number => duration; | ||||
| export const getSeconds = (duration: Duration): number => duration / 1000; | ||||
| export const getMinutes = (duration: Duration): number => | ||||
|   getSeconds(duration) / 60; | ||||
| export const getHours = (duration: Duration): number => | ||||
|   getMinutes(duration) / 60; | ||||
| export const getMinutes = (duration: Duration): number => getSeconds(duration) / 60; | ||||
| export const getHours = (duration: Duration): number => getMinutes(duration) / 60; | ||||
| export const format = (duration: Duration): string => { | ||||
|   const ms = getMs(duration) % 1000; | ||||
|   const seconds = getSeconds(duration) % 60; | ||||
|  | @ -39,9 +36,7 @@ export const format = (duration: Duration): string => { | |||
|   const hours = getHours(duration); | ||||
| 
 | ||||
|   return ( | ||||
|     [hours, minutes, seconds] | ||||
|       .map((x) => Math.floor(x).toString().padStart(2, "0")) | ||||
|       .join(":") + | ||||
|     [hours, minutes, seconds].map((x) => Math.floor(x).toString().padStart(2, "0")).join(":") + | ||||
|     "." + | ||||
|     ms.toString().padStart(3, "0") | ||||
|   ); | ||||
|  | @ -57,49 +52,40 @@ export const createDurationBuilder = (): DurationBuilder => ({ | |||
|   millis: 0, | ||||
|   seconds: 0, | ||||
|   minutes: 0, | ||||
|   hours: 0, | ||||
|   hours: 0 | ||||
| }); | ||||
| 
 | ||||
| export type DurationBuilderField<T> = ( | ||||
|   arg: T, | ||||
| ) => (builder: DurationBuilder) => DurationBuilder; | ||||
| export type DurationBuilderField<T> = (arg: T) => (builder: DurationBuilder) => DurationBuilder; | ||||
| 
 | ||||
| export const withMillis: DurationBuilderField<number> = | ||||
|   (millis) => (builder) => ({ | ||||
|     ...builder, | ||||
|     millis, | ||||
|   }); | ||||
| export const withMillis: DurationBuilderField<number> = (millis) => (builder) => ({ | ||||
|   ...builder, | ||||
|   millis | ||||
| }); | ||||
| 
 | ||||
| export const withSeconds: DurationBuilderField<number> = | ||||
|   (seconds) => (builder) => ({ | ||||
|     ...builder, | ||||
|     seconds, | ||||
|   }); | ||||
| export const withSeconds: DurationBuilderField<number> = (seconds) => (builder) => ({ | ||||
|   ...builder, | ||||
|   seconds | ||||
| }); | ||||
| 
 | ||||
| export const withMinutes: DurationBuilderField<number> = | ||||
|   (minutes) => (builder) => ({ | ||||
|     ...builder, | ||||
|     minutes, | ||||
|   }); | ||||
| export const withMinutes: DurationBuilderField<number> = (minutes) => (builder) => ({ | ||||
|   ...builder, | ||||
|   minutes | ||||
| }); | ||||
| 
 | ||||
| export const withHours: DurationBuilderField<number> = | ||||
|   (hours) => (builder) => ({ | ||||
|     ...builder, | ||||
|     hours, | ||||
|   }); | ||||
| export const withHours: DurationBuilderField<number> = (hours) => (builder) => ({ | ||||
|   ...builder, | ||||
|   hours | ||||
| }); | ||||
| 
 | ||||
| export const build = (builder: DurationBuilder): Duration => | ||||
|   builder.millis + | ||||
|   builder.seconds * 1000 + | ||||
|   builder.minutes * 60 * 1000 + | ||||
|   builder.hours * 60 * 60 * 1000; | ||||
|   builder.millis + builder.seconds * 1000 + builder.minutes * 60 * 1000 + builder.hours * 60 * 60 * 1000; | ||||
| 
 | ||||
| export const parse = (duration: string): E.Either<string, Duration> => { | ||||
|   const parts = pipe( | ||||
|     duration, | ||||
|     S.split(" "), | ||||
|     R.map(S.trim), | ||||
|     R.filter((part) => !S.isEmpty(part)), | ||||
|     R.filter((part) => !S.isEmpty(part)) | ||||
|   ); | ||||
| 
 | ||||
|   const valueUnitPairs = pipe( | ||||
|  | @ -120,36 +106,34 @@ export const parse = (duration: string): E.Either<string, Duration> => { | |||
|     E.map( | ||||
|       flow( | ||||
|         R.filter(O.isSome), | ||||
|         R.map(({ value }) => value), | ||||
|       ), | ||||
|     ), | ||||
|         R.map(({ value }) => value) | ||||
|       ) | ||||
|     ) | ||||
|   ); | ||||
| 
 | ||||
|   return pipe( | ||||
|     valueUnitPairs, | ||||
|     E.flatMap( | ||||
|       R.reduce( | ||||
|         E.of<string, DurationBuilder>(createDurationBuilder()), | ||||
|         (builderEither, [unit, value]) => | ||||
|           pipe( | ||||
|             builderEither, | ||||
|             E.chain((builder) => { | ||||
|               switch (unit) { | ||||
|                 case DurationUnit.MILLISECOND: | ||||
|                   return E.right(withMillis(value)(builder)); | ||||
|                 case DurationUnit.SECOND: | ||||
|                   return E.right(withSeconds(value)(builder)); | ||||
|                 case DurationUnit.MINUTE: | ||||
|                   return E.right(withMinutes(value)(builder)); | ||||
|                 case DurationUnit.HOUR: | ||||
|                   return E.right(withHours(value)(builder)); | ||||
|                 default: | ||||
|                   return E.left(`unknown unit: ${unit}`); | ||||
|               } | ||||
|             }), | ||||
|           ), | ||||
|       ), | ||||
|       R.reduce(E.of<string, DurationBuilder>(createDurationBuilder()), (builderEither, [unit, value]) => | ||||
|         pipe( | ||||
|           builderEither, | ||||
|           E.chain((builder) => { | ||||
|             switch (unit) { | ||||
|               case DurationUnit.MILLISECOND: | ||||
|                 return E.right(withMillis(value)(builder)); | ||||
|               case DurationUnit.SECOND: | ||||
|                 return E.right(withSeconds(value)(builder)); | ||||
|               case DurationUnit.MINUTE: | ||||
|                 return E.right(withMinutes(value)(builder)); | ||||
|               case DurationUnit.HOUR: | ||||
|                 return E.right(withHours(value)(builder)); | ||||
|               default: | ||||
|                 return E.left(`unknown unit: ${unit}`); | ||||
|             } | ||||
|           }) | ||||
|         ) | ||||
|       ) | ||||
|     ), | ||||
|     E.map(build), | ||||
|     E.map(build) | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -2,8 +2,10 @@ import type { IO } from "fp-ts/lib/IO"; | |||
| 
 | ||||
| export interface Logger { | ||||
|   log: (message: string) => IO<void>; | ||||
|   error: (message: string) => IO<void>; | ||||
| } | ||||
| 
 | ||||
| export const ConsoleLogger: Logger = { | ||||
|   log: (message: string) => () => console.log(message), | ||||
|   log: (message: string) => () => console.log(new Date(), "[INFO]", message), | ||||
|   error: (message: string) => () => console.error(new Date(), "[ERROR]", message) | ||||
| }; | ||||
|  |  | |||
|  | @ -1,93 +0,0 @@ | |||
| import * as TE from "fp-ts/lib/TaskEither"; | ||||
| import * as IO from "fp-ts/lib/IO"; | ||||
| import * as RA from "fp-ts/lib/ReadonlyArray"; | ||||
| import * as RM from "fp-ts/lib/ReadonlyMap"; | ||||
| import * as T from "fp-ts/Task"; | ||||
| import * as S from "fp-ts/string"; | ||||
| import { contramap, type Eq } from "fp-ts/Eq"; | ||||
| import { pipe, identity } from "fp-ts/lib/function"; | ||||
| import type { Schedule } from "../canary"; | ||||
| import { ConsoleLogger, type Logger } from "./logger"; | ||||
| import type { Separated } from "fp-ts/lib/Separated"; | ||||
| import type { Magma } from "fp-ts/lib/Magma"; | ||||
| import { intercalate } from "fp-ts/lib/Foldable"; | ||||
| 
 | ||||
| interface Unit {} | ||||
| const Unit: Unit = {}; | ||||
| 
 | ||||
| interface ScheduledJob { | ||||
|   id: string; | ||||
|   execute: <TResult>() => TE.TaskEither<Error, TResult>; | ||||
|   at: Date; | ||||
|   schedule: Schedule; | ||||
| } | ||||
| 
 | ||||
| type SchedulerState = ReadonlyArray<ScheduledJob>; | ||||
| 
 | ||||
| const logState = ( | ||||
|   state: SchedulerState, | ||||
|   now: Date, | ||||
|   logger: Logger = ConsoleLogger, | ||||
| ) => { | ||||
|   const ids = pipe( | ||||
|     state, | ||||
|     RA.map(({ id }) => id), | ||||
|     (ids) => intercalate(S.Monoid, RA.Foldable)("|", ids), | ||||
|   ); | ||||
|   return logger.log(`Executing ${ids} at ${now.toUTCString()}`); | ||||
| }; | ||||
| 
 | ||||
| const executeDueJobs = <TResult>( | ||||
|   state: SchedulerState, | ||||
| ): IO.IO< | ||||
|   T.Task< | ||||
|     Separated<ReadonlyArray<[string, Error]>, ReadonlyArray<[string, TResult]>> | ||||
|   > | ||||
| > => | ||||
|   pipe( | ||||
|     IO.of(state), | ||||
|     IO.bindTo("state"), | ||||
|     IO.bind("now", () => () => new Date()), | ||||
|     IO.bind("toExecute", ({ now, state }) => | ||||
|       pipe(IO.of(state), IO.map(RA.filter(({ at }) => at <= now))), | ||||
|     ), | ||||
|     IO.flatMap(({ state, now }) => | ||||
|       pipe( | ||||
|         IO.of(state), | ||||
|         IO.tap((state) => logState(state, now)), | ||||
|         IO.map((state) => | ||||
|           pipe( | ||||
|             state, | ||||
|             RA.map(({ execute, id }) => | ||||
|               pipe( | ||||
|                 execute(), | ||||
|                 TE.bimap( | ||||
|                   (error) => [id, error] as [string, Error], | ||||
|                   (result) => [id, result] as [string, TResult], | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ), | ||||
|     IO.map((jobs) => pipe(jobs, RA.wilt(T.ApplicativePar)(identity))), | ||||
|   ); | ||||
| 
 | ||||
| const jobEq: Eq<ScheduledJob> = pipe( | ||||
|   S.Eq, | ||||
|   contramap(({ id }) => id), | ||||
| ); | ||||
| const jobMagma: Magma<ScheduledJob> = { | ||||
|   concat: (a: ScheduledJob, b: ScheduledJob): ScheduledJob => ({ | ||||
|     ...a, | ||||
|     ...b, | ||||
|   }), | ||||
| }; | ||||
| 
 | ||||
| export const schedulerLoop = (jobs: ReadonlyArray<ScheduledJob>) => { | ||||
|   const jobsTable: Map< | ||||
|     string, | ||||
|     { retries: number; specification: ScheduledJob } | ||||
|   > = pipe(jobs, RM.fromFoldable(jobEq, jobMagma, RA.Foldable)); | ||||
| }; | ||||
|  | @ -0,0 +1,197 @@ | |||
| import { mock, test, expect } from "bun:test"; | ||||
| import * as TE from "fp-ts/lib/TaskEither"; | ||||
| import type { Logger } from "../../src/util"; | ||||
| import { | ||||
|   perform, | ||||
|   type JobsTable, | ||||
|   execute, | ||||
|   formatJobResults, | ||||
|   logExecutingJobs, | ||||
|   type Job | ||||
| } from "../../src/canary/scheduler"; | ||||
| 
 | ||||
| const getMocks = () => { | ||||
|   const mockLogger: Logger = { | ||||
|     log: mock(), | ||||
|     error: mock() | ||||
|   }; | ||||
|   return { mockLogger }; | ||||
| }; | ||||
| 
 | ||||
| const schedule = { | ||||
|   every: 200, | ||||
|   jitter: 100 | ||||
| }; | ||||
| 
 | ||||
| test("logging", () => { | ||||
|   const { mockLogger } = getMocks(); | ||||
|   const jobs: Job[] = [ | ||||
|     { id: "1", toString: () => "Job 1", execute: mock(), schedule, maxRetries: 3 }, | ||||
|     { id: "2", toString: () => "Job 2", execute: mock(), schedule, maxRetries: 3 } | ||||
|   ]; | ||||
|   const now = new Date("2023-01-01T00:00:00Z"); | ||||
| 
 | ||||
|   logExecutingJobs(jobs, now, mockLogger); | ||||
| 
 | ||||
|   expect(mockLogger.log).toHaveBeenCalledWith("Executing Job 1|Job 2 at Sun, 01 Jan 2023 00:00:00 GMT"); | ||||
| }); | ||||
| 
 | ||||
| test("should separate jobs into successful and failed executions", async () => { | ||||
|   const job1: Job = { | ||||
|     id: "1", | ||||
|     toString: () => "Job 1", | ||||
|     execute: mock(() => TE.right("Result 1") as any), | ||||
|     schedule, | ||||
|     maxRetries: 3 | ||||
|   }; | ||||
|   const job2: Job = { | ||||
|     id: "2", | ||||
|     toString: () => "Job 2", | ||||
|     execute: mock(() => TE.left(new Error("Failure 2")) as any), | ||||
|     schedule, | ||||
|     maxRetries: 3 | ||||
|   }; | ||||
|   const jobs: Job[] = [job1, job2]; | ||||
| 
 | ||||
|   const result = await execute(jobs)(); | ||||
| 
 | ||||
|   expect(result.left).toEqual([[job2, new Error("Failure 2")]]); | ||||
|   expect(result.right).toEqual([[job1, "Result 1"]]); | ||||
| }); | ||||
| 
 | ||||
| test("should format job results correctly", () => { | ||||
|   const jobsTable: JobsTable = new Map([["1", { scheduled: new Date("2023-01-01T00:00:00Z"), retries: 1 }]]); | ||||
|   const left = [ | ||||
|     [{ id: "1", toString: () => "Job 1", execute: mock(), schedule: {}, maxRetries: 3 }, new Error("Error 1")] | ||||
|   ]; | ||||
|   const right = [[{ id: "2", toString: () => "Job 2", execute: mock(), schedule: {}, maxRetries: 3 }, "Success 2"]]; | ||||
| 
 | ||||
|   const result = formatJobResults(jobsTable, { left, right } as any); | ||||
| 
 | ||||
|   expect(result).toContain("Job 1 | 1 / 3 |  (retry) :/  | Error 1"); | ||||
|   expect(result).toContain("Job 2 | (success) :) | Success 2"); | ||||
| }); | ||||
| 
 | ||||
| test("should update jobsTable and lastPingAck correctly", async () => { | ||||
|   const jobsTable: JobsTable = new Map([["1", { scheduled: new Date("2023-01-01T00:00:00Z"), retries: 1 }]]); | ||||
|   const jobs: Job[] = [ | ||||
|     { | ||||
|       id: "1", | ||||
|       toString: () => "Job 1", | ||||
|       execute: mock(() => TE.right("Result 1") as any), | ||||
|       schedule, | ||||
|       maxRetries: 3 | ||||
|     } | ||||
|   ]; | ||||
|   const publishers: any = []; | ||||
|   const lastPingAck = new Date("2023-01-01T00:00:00Z"); | ||||
| 
 | ||||
|   const result = await perform(jobsTable, jobs, publishers, lastPingAck)(); | ||||
| 
 | ||||
|   expect(result.lastPingAck).not.toEqual(lastPingAck); | ||||
|   expect(result.jobsTable.get("1")).toEqual({ | ||||
|     retries: 0, | ||||
|     scheduled: expect.any(Date) | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| test("should update a job with retry count on failure", async () => { | ||||
|   // Create a mock job that fails the first time but succeeds the second time
 | ||||
|   const job1: Job = { | ||||
|     id: "1", | ||||
|     toString: () => "Job 1", | ||||
|     execute: mock(() => TE.left(new Error("Error 1")) as any), | ||||
|     schedule, | ||||
|     maxRetries: 2 | ||||
|   }; | ||||
| 
 | ||||
|   const jobsTable: JobsTable = new Map([["1", { scheduled: new Date("2023-01-01T00:00:00Z"), retries: 0 }]]); | ||||
|   const jobs: Job[] = [job1]; | ||||
|   const publishers: any = []; | ||||
|   const lastPingAck = new Date("2023-01-01T00:00:00Z"); | ||||
| 
 | ||||
|   const result = await perform(jobsTable, jobs, publishers, lastPingAck, 24 * 60 * 60 * 1000)(); | ||||
| 
 | ||||
|   // Assert the job was retried once and then succeeded
 | ||||
|   expect(job1.execute).toHaveBeenCalled(); | ||||
| 
 | ||||
|   // Check the jobsTable for the updated state
 | ||||
|   expect(result.jobsTable.get("1")).toEqual({ | ||||
|     retries: 1, | ||||
|     scheduled: expect.any(Date) | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| test("should reschedule a job that hits max retries", async () => { | ||||
|   // Create a mock job that fails the first time but succeeds the second time
 | ||||
|   const job1: Job = { | ||||
|     id: "1", | ||||
|     toString: () => "Job 1", | ||||
|     execute: mock().mockReturnValue(TE.left(new Error("Error 1"))), | ||||
|     schedule, | ||||
|     maxRetries: 4 | ||||
|   }; | ||||
| 
 | ||||
|   const jobsTable: JobsTable = new Map([["1", { scheduled: new Date("2023-01-01T00:00:00Z"), retries: 4 }]]); | ||||
|   const jobs: Job[] = [job1]; | ||||
|   const publishers: any = [mock().mockReturnValue(TE.right(200))]; | ||||
| 
 | ||||
|   const lastPingAck = new Date("2023-01-01T00:00:00Z"); | ||||
| 
 | ||||
|   const now = Date.now(); | ||||
|   const result = await perform(jobsTable, jobs, publishers, lastPingAck, 24 * 60 * 60 * 1000)(); | ||||
|   const delta = Date.now() - now; | ||||
| 
 | ||||
|   // Assert the job was retried once and then fail
 | ||||
|   expect(job1.execute).toHaveBeenCalled(); | ||||
| 
 | ||||
|   // Check the jobsTable for the updated state
 | ||||
|   const { retries, scheduled } = result.jobsTable.get("1")!; | ||||
|   expect(retries).toEqual(0); | ||||
|   expect(publishers[0]).toHaveBeenCalled(); | ||||
| 
 | ||||
|   expect(scheduled.getTime()).toBeGreaterThan(now - delta + schedule.every); | ||||
|   expect(scheduled.getTime()).toBeLessThan(now + delta + schedule.every + schedule.jitter); | ||||
| }); | ||||
| 
 | ||||
| test("should not publish only successes when should not ack", async () => { | ||||
|   // Create a mock job that fails the first time but succeeds the second time
 | ||||
|   const job1: Job = { | ||||
|     id: "1", | ||||
|     toString: () => "Job 1", | ||||
|     execute: mock().mockReturnValue(TE.right(new Error("Error 1"))), | ||||
|     schedule, | ||||
|     maxRetries: 4 | ||||
|   }; | ||||
| 
 | ||||
|   const jobsTable: JobsTable = new Map([["1", { scheduled: new Date("2023-01-01T00:00:00Z"), retries: 0 }]]); | ||||
|   const jobs: Job[] = [job1]; | ||||
|   const publishers: any = [mock().mockReturnValue(TE.right(200))]; | ||||
|   const lastPingAck = new Date(); | ||||
| 
 | ||||
|   await perform(jobsTable, jobs, publishers, lastPingAck, 24 * 60 * 60 * 1000)(); | ||||
| 
 | ||||
|   expect(job1.execute).toHaveBeenCalled(); | ||||
|   expect(publishers[0]).toHaveBeenCalledTimes(0); | ||||
| }); | ||||
| 
 | ||||
| test("should publish when should ack", async () => { | ||||
|   // Create a mock job that fails the first time but succeeds the second time
 | ||||
|   const job1: Job = { | ||||
|     id: "1", | ||||
|     toString: () => "Job 1", | ||||
|     execute: mock().mockReturnValue(TE.right(new Error("Error 1"))), | ||||
|     schedule, | ||||
|     maxRetries: 4 | ||||
|   }; | ||||
| 
 | ||||
|   const jobsTable: JobsTable = new Map([["1", { scheduled: new Date("2023-01-01T00:00:00Z"), retries: 0 }]]); | ||||
|   const jobs: Job[] = [job1]; | ||||
|   const publishers: any = [mock().mockReturnValue(TE.right(200))]; | ||||
|   const lastPingAck = new Date("2023-01-01T00:00:00Z"); | ||||
| 
 | ||||
|   await perform(jobsTable, jobs, publishers, lastPingAck, 24 * 60 * 60 * 1000)(); | ||||
| 
 | ||||
|   expect(job1.execute).toHaveBeenCalled(); | ||||
|   expect(publishers[0]).toHaveBeenCalled(); | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue