Created
November 5, 2018 18:45
-
-
Save leniency/7681d6547a121de28c8a57d00e53f6a0 to your computer and use it in GitHub Desktop.
Job filter for Hangfire.io that throttles executions with a debounce filter. Multiple calls to the action will be ignored and only a single instance queued after the lock timout.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <summary> | |
/// A debounce rate limitor for Hangfire background jobs. This puts a | |
/// timeout lock on the action when it is first enqueued and schedules it | |
/// for the end of the lockout. Any further calls within the lock period are discarded. | |
/// </summary> | |
/// <remarks> | |
/// General references: | |
/// https://gist.github.com/odinserj/334fdb7d18bd63451f2f34546985f639 | |
/// https://gist.github.com/odinserj/a8332a3f486773baa009 | |
/// </remarks> | |
public sealed class DebounceAttribute : JobFilterAttribute, IElectStateFilter, IClientFilter, IServerFilter | |
{ | |
static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5); | |
readonly int _seconds; | |
TimeSpan _delay => TimeSpan.FromSeconds(_seconds); | |
/// <summary> | |
/// A fingerprint format to apply to each job. The job arguments will be passed to | |
/// this via String.Format to generate a unique name. If left null, the name | |
/// will be automatically generated from the job class, method, and parameters. | |
/// The format should resolve to something 88 chars or less or will be automatically | |
/// truncated. | |
/// </summary> | |
public string FingerPrintFormat { get; set; } | |
/// <summary> | |
/// Debounce the background task, preventing it from being called until the end | |
/// of the lockout period. | |
/// </summary> | |
/// <param name="seconds">The length of the lockout period in seconds.</param> | |
/// <param name="resourceFormat"></param> | |
public DebounceAttribute(int seconds, string resourceFormat = null) | |
{ | |
_seconds = seconds; | |
FingerPrintFormat = resourceFormat; | |
} | |
public void OnCreating(CreatingContext context) | |
{ | |
// If the job is created in anything other than an Enqueued state, check the | |
// debounce lock state. This allows short scheduled versions to pass through. | |
if (!(context.InitialState is EnqueuedState)) | |
{ | |
return; | |
} | |
using (context.Connection.AcquireDistributedLock(GetFingerPrintLockKey(context.Job), LockTimeout)) | |
{ | |
var timestamp = GetTimestamp(context.Connection, context.Job); | |
if (timestamp.HasValue | |
&& DateTimeOffset.UtcNow <= timestamp.Value.Add(_delay)) | |
{ | |
// Actual fingerprint found and still valid, cancel the creation of a new job. | |
context.Canceled = true; | |
} | |
// Set the timestamp - this will add the lock key, or update | |
// and extend the lock. | |
context.Connection.SetRangeInHash(GetFingerPrintKey(context.Job), new Dictionary<string, string> | |
{ | |
{ "Timestamp", DateTimeOffset.UtcNow.ToString("o") } | |
}); | |
} | |
} | |
/// <summary> | |
/// Hangfire filter event called when the action is transitioning from | |
/// one state to another. | |
/// </summary> | |
/// <param name="context"></param> | |
public void OnStateElection(ElectStateContext context) | |
{ | |
if (context.CandidateState is DeletedState) | |
{ | |
// If we're transitioning to delted, also ensure the fingerprint is removed. | |
RemoveFingerPrint(context.Connection, context.BackgroundJob.Job); | |
} | |
else if (!(context.CandidateState is EnqueuedState)) | |
{ | |
// If not tranitioning to an Enqueued state, skip the rest. | |
return; | |
} | |
// Fetch the origin timestamp. | |
// Check if we're still in the lockout period - if so, reschedule | |
// for the end of the lockout. | |
var timestamp = GetTimestamp(context.Connection, context.BackgroundJob.Job); | |
if (timestamp.HasValue) | |
{ | |
// If within the lockout period, reschedule out to the expiration. | |
if (DateTimeOffset.UtcNow <= timestamp.Value.Add(_delay)) | |
{ | |
context.CandidateState = new ScheduledState(timestamp.Value.Add(_delay).DateTime) { Reason = $"Delayed {_seconds} seconds by the debounce filter." }; | |
} | |
} | |
} | |
/// <summary> | |
/// Hangfire filter event called once the action has completed. | |
/// </summary> | |
/// <param name="filterContext"></param> | |
public void OnPerformed(PerformedContext filterContext) | |
{ | |
RemoveFingerPrint(filterContext.Connection, filterContext.BackgroundJob.Job); | |
} | |
/// <summary> | |
/// Fetch the debounce starting timestamp. | |
/// </summary> | |
/// <param name="connection"></param> | |
/// <param name="job"></param> | |
/// <returns></returns> | |
DateTimeOffset? GetTimestamp(IStorageConnection connection, Job job) | |
{ | |
var fingerprint = connection.GetAllEntriesFromHash(GetFingerPrintKey(job)); | |
if (fingerprint != null | |
&& fingerprint.ContainsKey("Timestamp") | |
&& DateTimeOffset.TryParse(fingerprint["Timestamp"], null, DateTimeStyles.RoundtripKind, out DateTimeOffset timestamp)) | |
{ | |
return timestamp; | |
} | |
return null; | |
} | |
/// <summary> | |
/// Remove the fingerprint for the job. | |
/// </summary> | |
/// <param name="connection"></param> | |
/// <param name="job"></param> | |
void RemoveFingerPrint(IStorageConnection connection, Job job) | |
{ | |
using (connection.AcquireDistributedLock(GetFingerPrintLockKey(job), LockTimeout)) | |
using (var transaction = connection.CreateWriteTransaction()) | |
{ | |
transaction.RemoveHash(GetFingerPrintKey(job)); | |
transaction.Commit(); | |
} | |
} | |
/// <summary> | |
/// Build the fingerprint for the given job. The format is: | |
/// {class}.{method}.{params} | |
/// </summary> | |
/// <param name="job"></param> | |
/// <returns></returns> | |
string GetFingerPrint(Job job) | |
{ | |
if (FingerPrintFormat != null) | |
{ | |
return String.Format(FingerPrintFormat, job.Args).Truncate(88); | |
} | |
// Cannot fingerprint anon funcs. | |
if (job.Type == null || job.Method == null) | |
{ | |
return string.Empty; | |
} | |
var parameters = job.Args.ToString("."); | |
// Return the unique key. Truncate it as the Hangfire database | |
// column is only 100 wide. | |
return String.Join(".", job.Type.FullName, job.Method.Name, parameters).Truncate(88); | |
} | |
private string GetFingerPrintLockKey(Job job) | |
{ | |
return String.Format("{0}:lock", GetFingerPrintKey(job)); | |
} | |
private string GetFingerPrintKey(Job job) | |
{ | |
return String.Format("fingerprint:{0}", GetFingerPrint(job)); | |
} | |
void IClientFilter.OnCreated(CreatedContext filterContext) | |
{ } | |
void IServerFilter.OnPerforming(PerformingContext filterContext) | |
{ } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment