@cloudflare/actors
Version:
An easier way to build with Cloudflare Durable Objects
248 lines • 9.24 kB
JavaScript
import { parseCronExpression } from "cron-schedule";
import { nanoid } from "nanoid";
function getNextCronTime(cron) {
const interval = parseCronExpression(cron);
return interval.getNextDate();
}
export class Alarms {
constructor(ctx, parent) {
this.alarm = async (alarmInfo) => {
const now = Math.floor(Date.now() / 1000);
// Get all schedules that should be executed now
const result = this.sql `
SELECT * FROM _actor_alarms WHERE time <= ${now}
`;
for (const row of result || []) {
const callback = this.parent[row.callback];
if (!callback) {
console.error(`callback ${row.callback} not found`);
continue;
}
// await agentContext.run(
// { agent: this, connection: undefined, request: undefined },
// async () => {
try {
await callback.bind(this.parent)(JSON.parse(row.payload), row);
}
catch (e) {
console.error(`error executing callback "${row.callback}"`, e);
}
// }
// );
if (row.type === "cron") {
// Update next execution time for cron schedules
const nextExecutionTime = getNextCronTime(row.cron);
const nextTimestamp = Math.floor(nextExecutionTime.getTime() / 1000);
this.sql `
UPDATE _actor_alarms SET time = ${nextTimestamp} WHERE id = ${row.id}
`;
}
else {
// Delete one-time schedules after execution
this.sql `
DELETE FROM _actor_alarms WHERE id = ${row.id}
`;
}
}
// Schedule the next alarm
await this._scheduleNextAlarm();
};
this.storage = ctx?.storage;
this.parent = parent;
void ctx?.blockConcurrencyWhile(async () => {
return this._tryCatch(async () => {
// Create alarms table if it doesn't exist
ctx?.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS _actor_alarms (
id TEXT PRIMARY KEY NOT NULL DEFAULT (randomblob(9)),
callback TEXT,
payload TEXT,
type TEXT NOT NULL CHECK(type IN ('scheduled', 'delayed', 'cron')),
time INTEGER,
delayInSeconds INTEGER,
cron TEXT,
created_at INTEGER DEFAULT (unixepoch())
)
`);
// Execute any pending alarms and schedule the next alarm
await this.alarm();
});
});
}
/**
* Schedule a task to be executed in the future
* @template T Type of the payload data
* @param when When to execute the task (Date, seconds delay, or cron expression)
* @param callback Name of the method to call
* @param payload Data to pass to the callback
* @returns Schedule object representing the scheduled task
*/
async schedule(when, callback, payload) {
const id = nanoid(9);
if (typeof callback !== "string") {
throw new Error("Callback must be a string");
}
if (typeof this.parent[callback] !== "function") {
throw new Error(`this.parent.${callback} is not a function`);
}
if (when instanceof Date) {
const timestamp = Math.floor(when.getTime() / 1000);
this.sql `
INSERT OR REPLACE INTO _actor_alarms (id, callback, payload, type, time)
VALUES (${id}, ${callback}, ${JSON.stringify(payload)}, 'scheduled', ${timestamp})
`;
await this._scheduleNextAlarm();
return {
id,
callback: callback,
payload: payload,
time: timestamp,
type: "scheduled",
};
}
if (typeof when === "number") {
const time = new Date(Date.now() + when * 1000);
const timestamp = Math.floor(time.getTime() / 1000);
this.sql `
INSERT OR REPLACE INTO _actor_alarms (id, callback, payload, type, delayInSeconds, time)
VALUES (${id}, ${callback}, ${JSON.stringify(payload)}, 'delayed', ${when}, ${timestamp})
`;
await this._scheduleNextAlarm();
return {
id,
callback: callback,
payload: payload,
delayInSeconds: when,
time: timestamp,
type: "delayed",
};
}
if (typeof when === "string") {
const nextExecutionTime = getNextCronTime(when);
const timestamp = Math.floor(nextExecutionTime.getTime() / 1000);
this.sql `
INSERT OR REPLACE INTO _actor_alarms (id, callback, payload, type, cron, time)
VALUES (${id}, ${callback}, ${JSON.stringify(payload)}, 'cron', ${when}, ${timestamp})
`;
await this._scheduleNextAlarm();
return {
id,
callback: callback,
payload: payload,
cron: when,
time: timestamp,
type: "cron",
};
}
throw new Error("Invalid schedule type");
}
/**
* Get a scheduled task by ID
* @template T Type of the payload data
* @param id ID of the scheduled task
* @returns The Schedule object or undefined if not found
*/
async getSchedule(id) {
const result = this.sql `
SELECT * FROM _actor_alarms WHERE id = ${id}
`;
if (!result) {
console.error(`schedule ${id} not found`);
return undefined;
}
return { ...result[0], payload: JSON.parse(result[0].payload) };
}
/**
* Get scheduled tasks matching the given criteria
* @template T Type of the payload data
* @param criteria Criteria to filter schedules
* @returns Array of matching Schedule objects
*/
getSchedules(criteria = {}) {
let query = "SELECT * FROM _actor_alarms WHERE 1=1";
const params = [];
if (criteria.id) {
query += " AND id = ?";
params.push(criteria.id);
}
if (criteria.type) {
query += " AND type = ?";
params.push(criteria.type);
}
if (criteria.timeRange) {
query += " AND time >= ? AND time <= ?";
const start = criteria.timeRange.start || new Date(0);
const end = criteria.timeRange.end || new Date(999999999999999);
params.push(Math.floor(start.getTime() / 1000), Math.floor(end.getTime() / 1000));
}
if (!this.storage?.sql) {
// Or throw an error, depending on desired behavior
return [];
}
const result = this.storage.sql
.exec(query, ...params)
.toArray()
.map((row) => ({
...row,
payload: JSON.parse(row.payload),
}));
return result;
}
/**
* Cancel a scheduled task
* @param id ID of the task to cancel
* @returns true if the task was cancelled, false otherwise
*/
async cancelSchedule(id) {
this.sql `DELETE FROM _actor_alarms WHERE id = ${id}`;
await this._scheduleNextAlarm();
return true;
}
async _scheduleNextAlarm() {
// Find the next schedule that needs to be executed
const result = this.sql `
SELECT time FROM _actor_alarms
WHERE time > ${Math.floor(Date.now() / 1000)}
ORDER BY time ASC
LIMIT 1
`;
if (!result || !this.storage)
return;
if (result.length > 0 && "time" in result[0]) {
const nextTime = result[0].time * 1000;
await this.storage.setAlarm(nextTime);
}
}
/**
* Execute SQL queries against the Agent's database
* @template T Type of the returned rows
* @param strings SQL query template strings
* @param values Values to be inserted into the query
* @returns Array of query results
*/
sql(strings, ...values) {
let query = "";
try {
// Construct the SQL query with placeholders
query = strings.reduce((acc, str, i) => acc + str + (i < values.length ? "?" : ""), "");
if (!this.storage) {
throw new Error("Storage not initialized");
}
// Execute the SQL query with the provided values
return [...this.storage.sql.exec(query, ...values)];
}
catch (e) {
console.error(`failed to execute sql query: ${query}`, e);
throw e;
}
}
async _tryCatch(fn) {
try {
return await fn();
}
catch (e) {
throw e;
}
}
}
//# sourceMappingURL=index.js.map