public static bool PushEnabled => false; // TODO /// <summary> /// Queues the notifications in the database and if the database table was clear it queues them immediately for processing.<br/> /// If the database contains stale notifications, then do not queue the new ones immediately. Instead wait for <see cref="SmsPollingJob"/> /// to pick them out in order and schedule them, this prevent notifications being dispatched grossly out of order. /// </summary> public async Task Enqueue( int tenantId, List <EmailToSend> emails = null, List <SmsToSend> smsMessages = null, List <PushToSend> pushNotifications = null, EmailCommandToSend command = null, CancellationToken cancellation = default) { // (1) Map notifications to Entities and validate them // Email emails ??= new List <EmailToSend>(); if (emails.Count > 0 && !EmailEnabled) { // Developer mistake throw new InvalidOperationException("Attempt to Enqueue emails while email is disabled in this installation."); } var validEmails = new List <EmailToSend>(emails.Count); var emailEntities = new List <EmailForSave>(emails.Count); var blobs = new List <(string name, byte[] content)>(); foreach (var email in emails) { var(emailEntity, emailBlobs) = ToEntity(email); emailEntities.Add(emailEntity); blobs.AddRange(emailBlobs); var error = EmailValidation.Validate(email); if (error != null) { emailEntity.State = EmailState.ValidationFailed; emailEntity.ErrorMessage = error; // The following ensures it will fit in the table emailEntity.To = emailEntity.To?.Truncate(EmailValidation.MaximumEmailAddressLength); emailEntity.Cc = emailEntity.Cc?.Truncate(EmailValidation.MaximumEmailAddressLength); emailEntity.Bcc = emailEntity.Bcc?.Truncate(EmailValidation.MaximumEmailAddressLength); emailEntity.Subject = emailEntity.Subject?.Truncate(EmailValidation.MaximumSubjectLength); foreach (var att in emailEntity.Attachments) { if (string.IsNullOrWhiteSpace(att.Name)) { att.Name = "(Missing Name)"; } else { att.Name = att.Name?.Truncate(EmailValidation.MaximumAttchmentNameLength); } } } else { validEmails.Add(email); } } // SMS smsMessages ??= new List <SmsToSend>(); if (smsMessages.Count > 0 && !SmsEnabled) { // Developer mistake throw new InvalidOperationException("Attempt to Enqueue SMS messages while SMS is disabled in this installation."); } var validSmsMessages = new List <SmsToSend>(smsMessages.Count); var smsEntities = new List <MessageForSave>(smsMessages.Count); foreach (var sms in smsMessages) { var smsEntity = ToEntity(sms); smsEntities.Add(smsEntity); var error = SmsValidation.Validate(sms); if (error != null) { smsEntity.State = MessageState.ValidationFailed; smsEntity.ErrorMessage = error; } else { validSmsMessages.Add(sms); } } // Push pushNotifications ??= new List <PushToSend>(); if (pushNotifications.Count > 0 && !PushEnabled) { // Developer mistake throw new InvalidOperationException("Attempt to Enqueue Push notifications while Push is disabled in this installation."); } var validPushNotifications = new List <PushToSend>(pushNotifications.Count); var pushEntities = new List <PushNotificationForSave>(pushNotifications.Count); // TODO // Start a serializable transaction using var trx = TransactionFactory.Serializable(TransactionScopeOption.RequiresNew); // If the table already contains notifications that are 90% expired, do not queue the new notifications int expiryInSeconds = _options.PendingNotificationExpiryInSeconds * 9 / 10; // (2) Call the stored procedure // Persist the notifications in the database, the returned booleans will tell us which notifications we can queue immediately var repo = _repoFactory.GetRepository(tenantId); var(queueEmails, queueSmsMessages, queuePushNotifications, emailCommandId, messageCommandId) = await repo.Notifications_Enqueue( expiryInSeconds : expiryInSeconds, emails : emailEntities, messages : smsEntities, pushes : pushEntities, templateId : command?.TemplateId, entityId : command?.EntityId, caption : command?.Caption, scheduledTime : command?.ScheduledTime, createdbyId : command?.CreatedById, cancellation : cancellation); await _blobService.SaveBlobsAsync(tenantId, blobs); // (3) Map the Ids back to the DTOs and queue valid notifications // Email if (queueEmails && validEmails.Any()) { // Map the Ids for (int i = 0; i < emailEntities.Count; i++) { var entity = emailEntities[i]; var dto = emails[i]; dto.EmailId = entity.Id; dto.TenantId = tenantId; } // Queue (Emails are queued in bulk unlike SMS and Push) _emailQueue.QueueBackgroundWorkItem(validEmails); } // SMS if (queueSmsMessages && validSmsMessages.Any()) { // Map the Ids for (int i = 0; i < smsEntities.Count; i++) { var smsEntity = smsEntities[i]; var sms = smsMessages[i]; sms.MessageId = smsEntity.Id; sms.TenantId = tenantId; } // Queue _smsQueue.QueueAllBackgroundWorkItems(validSmsMessages); } // Push if (queuePushNotifications && validPushNotifications.Any()) { // Map the Ids for (int i = 0; i < smsEntities.Count; i++) { var entity = pushEntities[i]; var dto = pushNotifications[i]; dto.PushId = entity.Id; dto.TenantId = tenantId; } // Queue _pushQueue.QueueAllBackgroundWorkItems(validPushNotifications); } if (command != null) { command.EmailCommandId = emailCommandId; command.MessageCommandId = messageCommandId; } trx.Complete(); }
protected override async Task ExecuteAsync(CancellationToken cancellation) { _logger.LogInformation(GetType().Name + " Started."); // Enter the background job while (!cancellation.IsCancellationRequested) { try { ///////////// (1) Create and grab the cancellation token that indicates an updated schedule // It is important to create the token before grabbing the schedules, because // the updater job updates the schedules and THEN cancels the token. var signalSchedulesChange = _schedulesCache.CreateCancellationToken(); ///////////// (2) Compute the earliest schedule DateTimeOffset minNext = DateTimeOffset.MaxValue; Dictionary <int, List <ScheduleInfo> > allTemplates = new(); var allSchedules = await _schedulesCache.GetSchedules(cancellation); foreach (var schedule in allSchedules.Where(e => !e.IsError)) { var tenantId = schedule.TenantId; foreach (var cron in schedule.Crons) { var next = cron.GetNextOccurrence(schedule.LastExecuted, TimeZoneInfo.Utc); if (next != null) { if (minNext.UtcTicks > next.Value.UtcTicks) { // If it's an earlier time, create a new dictionary allTemplates = new() { { tenantId, new() { schedule } } }; minNext = next.Value; } else if (minNext.UtcTicks == next.Value.UtcTicks) { if (!allTemplates.TryGetValue(tenantId, out List <ScheduleInfo> list)) { allTemplates.Add(tenantId, list = new()); } list.Add(schedule); } } } } ///////////// (3) Wait the necessary duration if any, or forever if there are no schedules var spanTillNext = minNext - DateTimeOffset.UtcNow; if (spanTillNext.Ticks > 0) { // Combine 1. the token to stop the service with 2. the token to signal schedules changes using var combinedSource = CancellationTokenSource .CreateLinkedTokenSource(cancellation, signalSchedulesChange); // Wait for the time of the notification var milliseconds = Convert.ToInt64(spanTillNext.TotalMilliseconds); await Delay(milliseconds, combinedSource.Token); } ///////////// (4) Run the templates (if they haven't changed) if (!signalSchedulesChange.IsCancellationRequested && !cancellation.IsCancellationRequested) { await Task.WhenAll(allTemplates.Select(async pair => { // A. Load the templates from the DB // B. IF they are outdated, skip all and update the schedules of this company, then leave it to be handled by the next iteration // C. ELSE run the templates in sequence, and update LastExecuted both in DB and in memory // D. If there is an error evaluating the template or sending it, notify the support email of the company var tenantId = pair.Key; var schedules = pair.Value; try // To prevent failure in one tenant to affect other tenants { // Get deployed automatic templates of this tenant var emailTemplateIds = schedules.Where(e => e.Channel == ScheduleChannel.Email).Select(e => e.TemplateId).Distinct(); var messageTemplateIds = schedules.Where(e => e.Channel == ScheduleChannel.Message).Select(e => e.TemplateId).Distinct(); var repo = _repoFactory.GetRepository(tenantId); var output = await repo.Templates__Load(emailTemplateIds, messageTemplateIds, cancellation); // If the schedules version is stale skip running them var isStale = await _schedulesCache.RefreshSchedulesIfStale(tenantId, output.SchedulesVersion, cancellation); if (!isStale) { foreach (var template in output.EmailTemplates) { await _schedulesCache.UpdateEmailTemplateLastExecuted(tenantId, template.Id, minNext, output.SupportEmails, async() => { // (1) Prepare the Email var preview = await _helper.CreateEmailCommandPreview( tenantId: tenantId, userId: 0, // Irrelevant settingsVersion: output.SettingsVersion, userSettingsVersion: null, // Irrelevant template: template, preloadedQuery: null, localVariables: null, fromIndex: 0, toIndex: int.MaxValue, cultureString: "en", // TODO culture? now: minNext, isAnonymous: true, // Bypasses permission check getReadPermissions: null, cancellation: cancellation); // (2) Send Emails if (preview.Emails.Count > 0) { var emailsToSend = preview.Emails.Select(email => new EmailToSend { TenantId = tenantId, To = email.To, Subject = email.Subject, Cc = email.Cc, Bcc = email.Bcc, Body = email.Body, Attachments = email.Attachments.Select(e => new EmailAttachmentToSend { Name = e.DownloadName, Contents = Encoding.UTF8.GetBytes(e.Body) }).ToList() }).ToList(); var command = new EmailCommandToSend(template.Id) { Caption = preview.Caption, ScheduledTime = minNext }; await _notificationsQueue.Enqueue(tenantId, emails: emailsToSend, command: command, cancellation: cancellation); //foreach (var email in emailsToSend) //{ // _logger.LogInformation($"{minNext.LocalDateTime}: {string.Join("; ", email.To)}: {email.Subject}"); //} } }, cancellation); } foreach (var template in output.MessageTemplates) { await _schedulesCache.UpdateMessageTemplateLastExecuted(tenantId, template.Id, minNext, output.SupportEmails, async() => { // (1) Prepare the Message var preview = await _helper.CreateMessageCommandPreview( tenantId: tenantId, userId: 0, // Irrelevant settingsVersion: output.SettingsVersion, userSettingsVersion: null, // Irrelevant template: template, preloadedQuery: null, localVariables: null, cultureString: "en", // TODO culture? now: minNext, isAnonymous: true, // Bypasses permission check getReadPermissions: null, cancellation: cancellation); // (2) Send Messages if (preview.Messages.Count > 0) { var messagesToSend = preview.Messages.Select(msg => new SmsToSend( phoneNumber: msg.PhoneNumber, content: msg.Content) { TenantId = tenantId }).ToList(); var command = new EmailCommandToSend(template.Id) { Caption = preview.Caption, ScheduledTime = minNext }; await _notificationsQueue.Enqueue(tenantId, smsMessages: messagesToSend, command: command, cancellation: cancellation); //foreach (var msg in messagesToSend) //{ // _logger.LogInformation($"{minNext.LocalDateTime}: {msg.PhoneNumber}: {msg.Content}"); //} } }, cancellation); } } } catch (TaskCanceledException) { } catch (OperationCanceledException) { } catch (Exception ex) { _logger.LogError(ex, $"Error in {GetType().Name} while running the templates of tenant Id {tenantId}."); await Task.Delay(30 * 1000, cancellation); // Wait 30 seconds to prevent a tight infinite loop } })); } } catch (TaskCanceledException) { } catch (OperationCanceledException) { } catch (Exception ex) { _logger.LogError(ex, $"Error in {GetType().Name}."); await Task.Delay(60 * 1000, cancellation); // Wait a minute to prevent a tight infinite loop } } _schedulesCache.DisposeCurrentToken(); }