@darlean/timers-suite
Version:
Timers Suite that provides persistent timer functionality
195 lines (194 loc) • 8.1 kB
JavaScript
;
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TimerActor = exports.TIMER_MOMENT_INDEX = void 0;
const base_1 = require("@darlean/base");
const utils_1 = require("@darlean/utils");
exports.TIMER_MOMENT_INDEX = 'by-moment';
class TimerActor {
constructor(persistence, time, timer, portal) {
this.persistence = persistence;
this.time = time;
this.timer = timer;
this.portal = portal;
}
async activate() {
this.timerHandle = this.timer.repeat(this.step, 60 * 1000, 0);
}
async deactivate() {
this.timerHandle?.cancel();
}
async schedule(options) {
const newState = {
id: options.id,
nextMoment: 0,
remainingRepeatCount: options.triggers[0].repeatCount ?? 1,
remainingTriggers: options.triggers.map((x) => ({ ...x })),
callbackActorType: options.callbackActorType,
callbackActorId: options.callbackActorId,
callbackActionName: options.callbackActionName,
callbackActionArgs: [...(options.callbackActionArgs ?? [])]
};
const newMoment = this.updateNextMoment(newState);
if (!newMoment) {
throw new Error('Unable to schedule timer');
}
const item = await this.persistence.load([newState.id]);
item.change(newState);
await item.persist();
if (this.nextTime !== undefined) {
if (newState.nextMoment <= this.nextTime) {
this.nextTime = newState.nextMoment;
this.trigger();
}
}
else {
this.nextTime = newState.nextMoment;
this.trigger();
}
}
// When cancelling and a step is performed meanwhile, the cancel can delete the record and the step restore it.
// To prevent this, make cancel exclusive. We may implement a better (smarter) solution, but for now it is reasonable.
async cancel(options) {
const item = await this.persistence.load([options.id]);
if (item.hasValue()) {
item.clear();
await item.persist();
}
}
async touch() {
//
}
async step() {
const now = this.time.machineTime();
const options = {
index: exports.TIMER_MOMENT_INDEX,
keys: [{ operator: 'lte', value: (0, utils_1.encodeNumber)(now) }]
};
for await (const chunk of this.persistence.searchChunks(options)) {
// We are the only actor that is processing the item (we are a singleton).
// No need to "reserve" the item; when we fail/crash, another actor will retry
// (but we are then dead anyways)
// TODO: Process items in parallel
for (const item of chunk.items) {
const currentItem = await this.persistence.load(item.id);
const currentTimer = currentItem.tryGetValue();
if (!currentTimer) {
continue;
}
let breaking = false;
const trigger = currentTimer.remainingTriggers[0];
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actor = this.portal.retrieve(currentTimer.callbackActorType, currentTimer.callbackActorId);
await actor[currentTimer.callbackActionName]();
if ((trigger?.success ?? 'continue') === 'break') {
breaking = true;
}
}
catch (e) {
if ((trigger?.error ?? 'continue') === 'break') {
breaking = true;
}
}
finally {
if (!breaking) {
const newMoment = this.updateNextMoment(currentTimer);
if (newMoment === undefined) {
breaking = true;
}
}
if (breaking) {
currentItem.clear();
}
await currentItem.persist('always');
}
}
if (chunk.items.length > 0) {
// When we did process some items, do not continue with next chunk (because new items may have come in between)
// but redo the entire step.
if (chunk.continuationToken) {
this.timerHandle?.resume(0);
return;
}
break;
}
}
this.nextTime = undefined;
// Note: In between, parallel requests to 'schedule' may adjust nextTime
const options2 = {
index: exports.TIMER_MOMENT_INDEX,
maxChunkItems: 1
};
for await (const item of this.persistence.searchItems(options2)) {
if (item.keys?.[0]) {
const time = (0, utils_1.decodeNumber)(item.keys[0]);
if (this.nextTime === undefined || time < this.nextTime) {
this.nextTime = time;
this.trigger();
}
}
break;
}
}
trigger() {
if (this.nextTime) {
const now = this.time.machineTime();
const remaining = Math.max(0, this.nextTime - now);
this.timerHandle?.resume(Math.min(60 * 1000, remaining));
}
}
updateNextMoment(state) {
let trigger = state.remainingTriggers[0];
const now = this.time.machineTime();
if (trigger) {
if (trigger.repeatCount === 0) {
state.nextMoment = trigger.moment ?? now + (trigger.interval ?? 0);
}
else if (state.remainingRepeatCount > 0) {
state.remainingRepeatCount--;
state.nextMoment = trigger.moment ?? now + (trigger.interval ?? 0);
}
else {
// No more remaining repeats, move to next trigger
state.remainingTriggers = state.remainingTriggers.slice(1);
trigger = state.remainingTriggers[0];
if (trigger) {
state.remainingRepeatCount = (trigger.repeatCount ?? 1) - 1;
state.nextMoment = trigger.moment ?? now + (trigger.interval ?? 0);
}
else {
state.nextMoment = 0;
return undefined;
}
}
if (trigger.jitter ?? 0 > 0) {
const offset = Math.random() * (trigger.jitter ?? 0);
state.nextMoment += offset;
}
return state.nextMoment;
}
else {
state.nextMoment = 0;
return undefined;
}
}
}
__decorate([
(0, base_1.action)({ locking: 'shared' })
], TimerActor.prototype, "schedule", null);
__decorate([
(0, base_1.action)({ locking: 'exclusive' })
], TimerActor.prototype, "cancel", null);
__decorate([
(0, base_1.action)()
], TimerActor.prototype, "touch", null);
__decorate([
(0, base_1.timer)({ locking: 'shared' })
], TimerActor.prototype, "step", null);
exports.TimerActor = TimerActor;