Nuget framework for massive Microsoft Dataverse data operations.
Xrm.DataManager.Framework provides following features :
- Batching patterns : Full, Delta, ...
- Performances : Perf tuning, multi threading, SQL retrieval (TDS endpoint)...
- Errors handling and retry : API Limits catching and waits
- Tracing : Supports file output, App Insights and any Microsoft.Extensions.Logging provider...
- Configuration management : Simplified conf ...
Xrm.DataManager.Framework is designed to simplify dev work by reducing core development and focus to functional implementation.
This project is open to community contributions in order to enrich this tooling and helps everyone to bring more capabilities for Microsoft Dataverse data operations.
Create a new C# console app project (.NET Framework 4.8 or .NET 10) and add Xrm.DataManager.Framework Nuget package.
| Package | Description |
|---|---|
Xrm.DataManager.Framework |
Core framework (only depends on Dataverse ServiceClient) |
Xrm.DataManager.Framework.ApplicationInsights |
Optional : Application Insights logger |
Xrm.DataManager.Framework.Tds |
Optional : retrieve records with SQL (Dataverse TDS endpoint) |
Packages follow semantic versioning (major version changes on breaking changes). Versions start at 2026.0.1 : previous versions were date based (2022.x).
Connections are defined in connectionStrings section of 'app.config' (configSource attribute allows to keep them in a separate file, out of source control) :
<connectionStrings>
<!-- Dataverse instances : ServiceClient format (OAuth, ClientSecret or Certificate) -->
<add name="Dev" connectionString="AuthType=ClientSecret;Url=https://dev.crm4.dynamics.com;ClientId=...;ClientSecret=..." />
<add name="Prod" connectionString="AuthType=ClientSecret;Url=https://prod.crm4.dynamics.com;ClientId=...;ClientSecret=..." />
<!-- Optional : Application Insights (reserved name) -->
<add name="AppInsights" connectionString="InstrumentationKey=...;IngestionEndpoint=https://..." />
</connectionStrings>- Dataverse connection strings use ServiceClient format : OAuth, ClientSecret or Certificate (
Office365/IFDare no longer supported). - Target connection (where jobs run) is resolved in following order :
- First command line argument (
Processor:YourApp.exe Dev Job1,Job2) orTarget.Connection.Namesetting - Single Dataverse entry, or console selection if several entries are defined
- First command line argument (
- Other entries are available in jobs with
Connections.GetProxiesPool("Prod")(opened on first use). - Application Insights uses
AppInsightsentry, orAPPLICATIONINSIGHTS_CONNECTION_STRINGenvironment variable (Azure).
Following parameters can be specified in appSettings section of 'app.config' :
| Parameter name | Default or available values | Description |
|---|---|---|
| Target.Connection.Name | ex: Dev | Name of the connectionStrings entry where jobs run |
| Target.Job.Names | ex: DeleteAccountsJobs, DeleteContactsJob | Jobs to run with Processor (class names separated with ',') |
| Source.Connection.Name | ex: Prod | Name of the connectionStrings entry to read records from (CrossInstanceDataJobBase, SyncDataJobBase, CompareDataJobBase) |
| Sync.ExcludedAttributes | Default: createdon, modifiedon, createdby, modifiedby, createdonbehalfby, modifiedonbehalfby, ownerid, owningbusinessunit, owninguser, owningteam, transactioncurrencyid | Attributes never copied by SyncDataJobBase (separated with ',', replaces default list, empty value : no exclusion) |
| Compare.ExcludedAttributes | Default: same as Sync.ExcludedAttributes + versionnumber, importsequencenumber, overriddencreatedon, timezoneruleversionnumber, utcconversiontimezonecode, exchangerate |
Attributes never compared by CompareDataJobBase (separated with ',', replaces default list, empty value : no exclusion) |
| Process.Duration.MaxHours | Default: 48 | Max job duration, to prevent night batch to run in prod during the day |
| Process.Query.RecordLimit | Default: 2500 | Records count to retrieve for each page (max 5000) |
| Process.Thread.Number | Default: 5 | Specify the number of threads to run (server recommended value is displayed at startup) |
| Process.ConnectionOptimizations.Enabled | Default: true | Apply process wide connection settings (thread pool, .NET Framework connection limit). Disable if your application manages them |
| Crm.AffinityCookie.Enabled | Default: false | Keep all requests on the same Dataverse node. Disabled by default to spread parallel requests on all nodes |
| LogLevel | Verbose = 0, Information = 1, ErrorsAndSuccess = 2, ErrorsOnly = 3 |
Tracing level |
| Logging.Record.Attributes | Default: false | Trace all record columns in telemetry (record logical name and id are always traced). Keep disabled if records contain personal data |
| Tds.Port | Default: 1433 | TDS endpoint port (1433 or 5558, depending on your firewall) |
| Tds.CommandTimeout.Seconds | Default: 300 | SQL query timeout (TDS endpoint limit is 5 minutes, 2 minutes for SELECT * and joins) |
Logger is provided to processor constructor (default : FileLogger, writes {RunId}.log in current directory) :
// Application Insights (Xrm.DataManager.Framework.ApplicationInsights package)
using (var processor = new JobProcessor(settings => new ApplicationInsightLogger(settings)))
{
processor.Execute<MyDataJob>();
}
// Any Microsoft.Extensions.Logging provider (Serilog, NLog, OpenTelemetry...)
using (var processor = new JobProcessor(settings => new ExtensionsLogger(settings, loggerFactory.CreateLogger("DataManager"))))
{
processor.Execute<MyDataJob>();
}7 patterns are available:
PickAndProcessDataJobBase: Query X rows from given QueryExpression and process them then loop as long as data is retrieved.FullProcessDataJobBase: Query all rows from given QueryExpression, load all data in memory and process.SqlFullProcessDataJobBase(Xrm.DataManager.Framework.Tdspackage) : Same asFullProcessDataJobBasewith rows retrieved by a SQL query.CrossInstanceDataJobBase: Query rows on a source instance and process them on the target instance.SyncDataJobBase: Copy rows from a source instance to the target instance.CompareDataJobBase: Compare rows between a source instance and the target instance (only in source, only in target, different, equal).InputFileProcessDataJobBase: Map a csv or txt file to table/columns and process each line.
These patterns provide following overridable methods:
- GetName : Set a display name for job.
- IsEnabled : Specify if job is runnable or not.
- OverrideThreadNumber : Specify thread number for current job (For debug only)
- PreOperation : This method is called before proceeding data, you can use it to put data in cache for example or disable audit or whatever.
- GetQuery : Specify the query expression to retrieve data.
- PrepareData : Useful with
FullProcessDataJobBaseto apply data aggregation like groupings. - ProcessRecord : Apply custom operation for a single row.
- PostOperation : This method is called after all data have been processed, you can use it to send notification email or reactivate audit or whatever.
SqlFullProcessDataJobBase retrieves records with a SQL query on Dataverse TDS endpoint (read only, Enable TDS endpoint environment setting is required). Authentication uses the access token of the Dataverse connection.
public class AnonymizeContactsJob : SqlFullProcessDataJobBase
{
public AnonymizeContactsJob(JobSettings jobSettings, JobProcessParameters parameters) : base(jobSettings, parameters) { }
public override string GetName() => "Anonymize contacts";
// contactid is added automatically when missing
public override string GetSqlQuery() => "SELECT TOP 1000 emailaddress1, createdby, createdbyname FROM contact WHERE emailaddress1 LIKE '%.com'";
public override void ProcessRecord(JobExecutionContext context)
{
var email = context.Record.GetSqlValue<string>("emailaddress1");
var creator = context.Record.GetSqlEntityReference("createdby", "systemuser");
// SQL columns are not in Attributes : only modified columns are sent
context.Record["emailaddress1"] = email.Replace(".com", ".fake");
context.Proxy.Update(context.Record);
}
}- The query is parsed before execution : it must be a single
SELECTstatement. Primary key column of the records table (first table ofFROMclause, orGetSqlEntityName()) is added if missing and used as record id. - Other columns are stored in
Record.FormattedValues(culture invariant strings) and read withGetSqlValue<T>(column):Attributesstay empty so the record can be updated without sending SQL columns back. - Lookups are returned as
{column}/{column}name, choices as{column}/{column}name, dates are UTC. - SQL queries don't trigger RetrieveMultiple plugins, elastic tables aren't supported, avoid
NOLOCKhint. TdsClientcan also be used directly (ex :PreOperation) to retrieve records or open a SQL connection.
CrossInstanceDataJobBase retrieves records from source connection (Source.Connection.Name) and processes them on target connection :
public class DeleteDeactivatedAccountsDataJob : CrossInstanceDataJobBase
{
public DeleteDeactivatedAccountsDataJob(JobSettings jobSettings, JobProcessParameters parameters) : base(jobSettings, parameters) { }
public override string GetName() => "Delete accounts deactivated on source instance";
// Executed on source connection
public override QueryExpression GetQuery(Guid callerId)
{
var query = new QueryExpression("account");
query.Criteria.AddCondition("statecode", ConditionOperator.Equal, 1);
return query;
}
// Record : source record / Proxy : target connection
public override void ProcessRecord(JobExecutionContext context) => context.Proxy.Delete(context.Record.LogicalName, context.Record.Id);
}- Multi threading, progress file (resume), errors handling and logging are the same as
FullProcessDataJobBase. SourceProxiesPoolgives access to the source connection (ex :TdsClientto retrieve source records with SQL inRetrieveRecordsoverride).- Any job can also use other connections with
Connections.GetProxiesPool("Name").
SyncDataJobBase (specialized CrossInstanceDataJobBase) reads records from source connection (Source.Connection.Name) and upserts them on target connection with the same id :
public class SyncAccountsDataJob : SyncDataJobBase
{
public SyncAccountsDataJob(JobSettings jobSettings, JobProcessParameters parameters) : base(jobSettings, parameters) { }
public override string GetName() => "Copy active accounts";
// Executed on source connection
public override QueryExpression GetQuery(Guid callerId)
{
var query = new QueryExpression("account") { ColumnSet = new ColumnSet(true) };
query.Criteria.AddCondition("statecode", ConditionOperator.Equal, 0);
return query;
}
// Job specific exclusions (in addition to Sync.ExcludedAttributes setting or default list)
public override IEnumerable<string> GetExcludedAttributes() => base.GetExcludedAttributes().Concat(new[] { "primarycontactid" });
// Parent accounts are synchronized by the same job : lookups are set in a second pass
protected override bool TwoPassDependencies => true;
}- Copied attributes : retrieved by
GetQuery, not excluded, existing and valid for update on target table (target metadata). - Options (overridable properties) :
IncludeEntityReferences(copy lookups, default false),TwoPassDependencies(records without lookups, then lookups),SyncState(apply statecode / statuscode after upsert),BypassCustomPluginExecution. TransformRecord(sourceRecord, targetRecord)allows to change values before upsert.- Multi threading, progress file (resume), errors handling and logging are the same as
FullProcessDataJobBase.
CompareDataJobBase retrieves records from source connection (Source.Connection.Name) and target connection (simultaneously), then compares them :
public class CompareContactsDataJob : CompareDataJobBase
{
public CompareContactsDataJob(JobSettings jobSettings, JobProcessParameters parameters) : base(jobSettings, parameters) { }
public override string GetName() => "Compare contacts";
// Executed on both connections (override GetTargetQuery for a different target query)
public override QueryExpression GetQuery(Guid callerId)
{
var query = new QueryExpression("contact");
query.ColumnSet.AddColumns("fullname", "emailaddress1", "telephone1", "parentcustomerid");
return query;
}
// Optional : delete on target the contacts missing on source
protected override bool ShouldProcess(DataCompareResult result) => result.Status == DataCompareStatus.OnlyInTarget;
protected override void ProcessResult(JobExecutionContext context, DataCompareResult result) => context.Proxy.Delete("contact", result.TargetRecord.Id);
}- Each record gets a status :
OnlyInSource,OnlyInTarget,Different(with different attributes) orEqual. A summary is logged and a CSV report is written ({JobClassName}_{RunId}.csv, equal records excluded by default :ReportEqualRecords). - Records are matched by id (instances synchronized with
SyncDataJobBasekeep the same ids) : overrideGetMatchingKey(record)to use another key (ex : account number). - Compared attributes : retrieved by the queries, not excluded (
Compare.ExcludedAttributes). Lookups are compared by id, choices and money by value, empty string equals null : overrideAreEqual(attribute, sourceValue, targetValue)to change it. - Read only by default :
ShouldProcess/ProcessResultallow to process selected results on target connection (multi threaded, same errors handling and logging as other patterns). - Both tables are loaded in memory : restrict columns and rows in queries.