DHI.Services.Jobs.Automations — Internal Developer Guide¶
This is a concise guide to help DHI developers use, extend, and integrate DHI.Services.Jobs.Automations. The module ships on NuGet and is safe to consume without access to the private source.
What the module does¶
An Automation represents “when X happens, run task Y with parameters Z”.
- Automation (
Automation<TTaskId>): named, grouped unit that targets a task id and carries task parameters. - Triggers (
ITrigger): conditions that decide if an automation is met (ready to run). - TriggerCondition: either “implicit AND” (all enabled triggers must pass) or a boolean expression over trigger ids, e.g.
A AND (B OR C). - Executor (
AutomationExecutor): evaluates triggers, merges/filters parameters, logs, and returns anAutomationResult. - Repositories/Services: JSON-backed stores for automations + a MEF-based TriggerRepository that exposes trigger parameter metadata for Web/API UIs.
The important types¶
Automation<TTaskId>&AutomationTaskId,TaskParameters,HostGroup,Priority,Tag,IsEnabled,TriggerCondition,LastJob,IsMet.
TriggerConditionTriggers : List<ITrigger>,Conditional : string?(boolean expression using trigger Ids).
ITrigger/BaseTriggerId,Description,IsEnabled,Type,Extra(JSON extension data),Execute(ILogger, IDictionary<string,string>?).
- Built-in triggers (namespace
...Automations.Triggers):ScheduledTrigger(time-based),JobCompletedTrigger(checks last job status),SqlTrigger(runs 1..N SQL statements sequentially, passing row params).
AutomationExecutor- Evaluates triggers, writes Scalars: per-trigger Is Met, plus Last Log and Last Met Log.
- Repos/Services
- Automation:
AutomationRepository(single JSON file),DirectoryAutomationRepository(one file per automation),TempDirectoryAutomationRepository(scratch space; doesn’t touch version). - Trigger metadata (for Web/API UI generation):
TriggerRepository(MEF composition) andTriggerService.
- Automation:
Parameter flow (how values are built)¶
When the executor runs a trigger it builds execution parameters as:
-
Automation.TaskParameters→ 2) Public readable properties on the trigger instance (converted to invariant strings)
→ 3)
BaseTrigger.Extra(JSON-injected per-trigger params)→ 4) Scheduled-only overlay for keys
utcNow,utcPrev,toleranceSecondscoming fromrunParams(if provided)
Special keys recognized anywhere in the bag:
TriggerNow(bool-ish): if true, bypasses the real condition and returnsMet()for all built-in triggers. Also causes executor to ignoreIsEnabledflags.- Scheduled overlays:
utcNow,utcPrev: UTC timestamps to evaluate boundaries deterministically (useful for workers)toleranceSeconds: acceptable drift window (default 30s)
Trimming: the executor returns only the subset of parameters whose keys exist in Automation.TaskParameters (to avoid accidental leaks).
Built-in triggers (how to use)¶
1) ScheduledTrigger¶
- Ctor:
(id, description, startTimeUtc, interval)(interval ≥ 1 minute) Executelogic:- If
TriggerNowtrue → met. - Otherwise: met when
nowcrosses a boundary ofstart + k*interval. - First-ever evaluation (no
utcPrev) uses a tolerance window (default 30s, override withtoleranceSeconds).
- If
- Optional run params:
utcNow,utcPrev,toleranceSeconds(seconds, non-negative).
2) JobCompletedTrigger¶
- Generic form checks last job for
(TJobId,TTaskId)via anIJobRepository. - Concrete provided:
JobCompletedTrigger : JobCompletedTrigger<Guid,string> - Ctor:
(id, taskId, repositoryType, connectionString)repositoryTypemust implementIJobRepository<Guid,string>and have a ctor(string connectionString).connectionStringsupports:[env:VARIABLE]→ replaced by environment variable.[AppData]→ replaced withAppDomain.CurrentDomain.DataDirectory(must be set at app startup).
3) SqlTrigger¶
- Ctor:
(connectionString, dbmsType, queries[], id, description)(or internal ctor with an existingIDbConnection). - Supported DBMS:
Postgres,SqlServer/AzureSql,SqLite,MySQL. (Undefinedthrows.) - Behavior:
- Runs
Queries[0]withAutomationParameters→ must return ≥1 row. - For each row, runs
Queries[1]with merged params{row + automationParams}, collects rows, and so on. - If last query returns rows, returns
Met(finalRowAsDictionary); otherwiseNotMet.
- Runs
Trigger metadata → Web/API UI¶
To make triggers discoverable in Web/API and generate forms, implement parameter classes exported via MEF:
- Implement marker
ITriggerParameters(must have aDescriptionprop). - Decorate with:
[Export(typeof(ITriggerParameters))][ExportMetadata("Id", nameof(YourTriggerClass))]
- Use
[TriggerParameter(required: bool, title: "...", description: "...", format: "...")]on public settable properties you want exposed. - The
TriggerRepositoryreflects these classes and builds a JSON-schema-likeTriggerParametersobject (with types, enums, arrays, nested objects, required list, etc.).
Repositories you’ll actually use¶
Pick one persistence style:
- Single file JSON:
var repo = new AutomationRepository(pathToJsonFile); - Directory (1 file per automation):
var repo = new DirectoryAutomationRepository(rootDir); - Temp directory (ephemeral, pre-sets
TriggerNow=trueand__UpdateToScalars=trueif missing):var repo = new TempDirectoryAutomationRepository(rootDir);
And then the service:
var automationSvc = new AutomationService(
repository: repo,
scalarRepository: scalarRepo, // IScalarRepository<string,int>
jobRepository: jobRepo // IJobRepository<Guid,string>
);
Repos update a
version.txt(except temp) so a separate host can detect changes.
Quickstart: define, store, evaluate¶
using DHI.Services.Jobs.Automations;
using DHI.Services.Jobs.Automations.Triggers;
// 1) Define your automation
var auto = new Automation(
name: "Nightly Analytics",
group: "analytics/batch",
taskId: "Analytics.RunNightly")
{
HostGroup = "prod",
Priority = 5,
Tag = "nightly",
TaskParameters = new Dictionary<string, string>
{
// only keys you want surfaced to the task
["Region"] = "EU",
["OutputBucket"] = "analytics-output"
// "TriggerNow" intentionally omitted for scheduled runs
},
TriggerCondition = new TriggerCondition(new List<ITrigger>
{
new ScheduledTrigger(
id: "EveryMidnightUTC",
description: "Fire daily at midnight UTC",
startTimeUtc: DateTime.Parse("2025-01-01T00:00:00Z"),
interval: TimeSpan.FromDays(1))
})
};
// 2) Persist it
var repo = new DirectoryAutomationRepository("/var/app/automations");
var svc = new AutomationService(repo, scalarRepo, jobRepo);
svc.Add(auto);
// 3) Evaluate (e.g., from a worker)
var executor = new AutomationExecutor(logger, scalarService, rootGroup: "Job Automator Log");
var result = executor.Execute(auto /*, runParams: optional overlays */);
if (result.IsMet)
{
// Merge result.TaskParameters into your job request and enqueue
}
Boolean expressions: give triggers stable ids and use them in TriggerCondition.Conditional:
var cond = new TriggerCondition(
new List<ITrigger> {
new ScheduledTrigger("T1", "hourly", DateTime.UtcNow, TimeSpan.FromHours(1)),
new JobCompletedTrigger("T2", "ETL-Done", typeof(MyJobRepo), "[env:CONN]"),
new SqlTrigger("[env:SQL]", DbmsType.SqlServer,
new[] { "select count(*) C from dbo.Ready where Flag=1" },
"T3", "SQL gate")
},
conditional: "T1 AND (T2 OR T3)"
);
auto.TriggerCondition = cond;
One-off manual run: set TriggerNow=true either in auto.TaskParameters or when executing:
var result = executor.Execute(auto, runParams: new Dictionary<string,string> {
["TriggerNow"] = "true"
});
Example: build your own trigger¶
You generally need two things:
- The runtime trigger (derive from
BaseTrigger) - The parameters export (MEF) so UI/Web knows how to build a form
1) Runtime trigger¶
using DHI.Services.Jobs.Automations;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.IO;
namespace DHI.Services.Jobs.Automations.Triggers
{
[Serializable]
public class FileExistsTrigger : BaseTrigger
{
public string Path { get; }
public bool CaseSensitive { get; }
public FileExistsTrigger(string id, string description, string path, bool caseSensitive = false)
: base(id, description)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
CaseSensitive = caseSensitive;
}
public override AutomationResult Execute(ILogger logger, IDictionary<string,string> parameters = null)
{
// Honor the global bypass
if (parameters.IsTriggerNow())
{
logger?.LogInformation("FileExistsTrigger {Id}: TriggerNow=true → met", Id);
return AutomationResult.Met();
}
var exists = File.Exists(Path);
if (!exists && !CaseSensitive)
{
var dir = Path.GetDirectoryName(Path) ?? ".";
var name = System.IO.Path.GetFileName(Path);
if (Directory.Exists(dir))
{
foreach (var f in Directory.GetFiles(dir))
{
if (string.Equals(System.IO.Path.GetFileName(f), name, StringComparison.OrdinalIgnoreCase))
{ exists = true; break; }
}
}
}
return exists ? AutomationResult.Met(new Dictionary<string,string>
{
["TriggeredBy"] = "FileExists",
["FilePath"] = Path
})
: AutomationResult.NotMet();
}
}
}
2) Parameters export (for Web/API form generation)¶
using System.ComponentModel.Composition;
using DHI.Services.Jobs.Automations.TriggerParametersExport;
namespace DHI.Services.Jobs.Automations.TriggerParametersExport
{
[Export(typeof(ITriggerParameters))]
[ExportMetadata("Id", nameof(DHI.Services.Jobs.Automations.Triggers.FileExistsTrigger))]
public sealed class FileExistsTriggerParameters : ITriggerParameters
{
[TriggerParameter(true, title: "Path", description: "Absolute or relative file path")]
public string Path { get; set; }
[TriggerParameter(false, title: "Case Sensitive", description: "Match filename case")]
public bool CaseSensitive { get; set; }
[TriggerParameter(true, title: "Description")]
public string Description { get; set; }
// Required by ITriggerParameters
public string Description { get; set; }
}
}
Drop these classes in an assembly that your TriggerRepository scans (MEF). The
Idmetadata must equalnameof(YourTriggerClass)so UI can connect the schema to the runtime type.
Using the trigger metadata¶
To list all available trigger types and build UIs:
var triggerRepo = new TriggerRepository(searchDirectory: AppContext.BaseDirectory);
foreach (var tp in triggerRepo.GetAll())
{
// tp.Id, tp.Properties, tp.Required, tp.AssemblyQualifiedTypeName, etc.
}
Connection strings & environment¶
"[env:XYZ]"is replaced withEnvironment.GetEnvironmentVariable("XYZ")."[AppData]"requiresAppDomain.CurrentDomain.SetData("DataDirectory", path).- Bad/missing env vars throw clear
ArgumentExceptions.
Operational tips¶
- Intervals: keep
ScheduledTrigger.Interval >= 1 minute. UsetoleranceSeconds(e.g., 10–60s) to accommodate clock skew / queueing. - Ids: choose simple alpha/num
Ids—used in expressions and scalar keys. - Logging/Scalars: the executor captures all logs into Last Log and, on success, Last Met Log. Per-trigger
Is Metscalars are written underrootGroup/automationId/hostGroup/(triggerId)/Is Met. - Security: avoid embedding secrets directly—prefer
[env:SECRET_NAME]. - Temp repo: great for ad-hoc runs and tests; it defaults
TriggerNow=trueunless you override.
Minimal end-to-end sample (multiple triggers + expression)¶
var a = new Automation("Weekly Report", "reports", "Reports.Generate")
{
HostGroup = "ops",
TaskParameters = new()
{
["Recipient"] = "team@dhi.example",
["Format"] = "pdf"
},
TriggerCondition = new TriggerCondition(
new List<ITrigger>
{
new ScheduledTrigger("SCHED", "Monday 07:00 UTC", new DateTime(2025,1,6,7,0,0, DateTimeKind.Utc), TimeSpan.FromDays(7)),
new JobCompletedTrigger("ETL_DONE", "ETL.Daily", typeof(MyJobRepo), "[env:ETL_DB]"),
},
conditional: "SCHED AND ETL_DONE"
)
};
svc.AddOrUpdate(a);
// Worker evaluation:
var res = executor.Execute(a);
if (res.IsMet)
{
// enqueue job with res.TaskParameters
}
Troubleshooting¶
- My schedule never fires
Check that
StartTimeUtc≤ current UTC,Interval >= 00:01:00, and your worker passes consistentutcNow/utcPrevacross polls. - JobCompletedTrigger throws
Ensure the
RepositoryTypeimplementsIJobRepository<Guid,string>and has(string connectionString)ctor. Verify env/DataDirectory substitutions. - SqlTrigger returns NotMet
Confirm the first query returns rows; inspect debug logs for serialized parameters; remember the last query must return at least one row for
Met.