Dataverse Change Tracking and Delta Synchronisation
Dataverse Change Tracking and Delta Synchronisation are foundational capabilities for building high-performance, scalable integrations between Microsoft Dataverse and external enterprise data stores (such as SQL Server, Azure Cosmos DB, or custom ERP systems). This chapter provides an exhaustive, production-grade guide to enabling, implementing, securing, and deploying change tracking and delta synchronisation architectures.
1. Conceptual Foundation
Under-the-Hood Architecture
Dataverse Change Tracking is built directly upon SQL Server's native Change Tracking engine. When Change Tracking is enabled on a Dataverse table, the platform instructs the underlying SQL database to track changes made to the table's rows.
Unlike Change Data Capture (CDC), which records the historical progression of every change by writing to transaction log tables, SQL Server Change Tracking is a synchronous, lightweight mechanism. It records only the fact that a row has changed (inserted, updated, or deleted) and associates that change with a monotonically increasing version number (a database-level transaction counter). It does not store the historical values of the modified columns.
When a client queries for changes, the engine performs a high-performance join between the base table and the internal change-tracking tables using the client's last-known version stamp. This design minimizes DML (Data Manipulation Language) overhead during standard transactional operations, ensuring that enabling change tracking does not degrade the performance of standard model-driven apps or API integrations.
Delta Tokens and Version Stamps
The synchronization lifecycle is governed by Delta Tokens (also referred to as version stamps). A delta token is an opaque, service-generated string that represents a specific point-in-time state of the database transaction log.
[Initial Sync: Token = Null] ──> Returns All Records ──> Generates Token "V1"
│
[Data Changes Occur in Dataverse] <────────────────────────────┘
│
[Delta Sync: Token = "V1"] ────> Returns Only Changes ──> Generates Token "V2"
- Initial Sync (Baseline): The client executes a change retrieval request without providing a delta token (or passing
null). Dataverse treats this as a request for the system minimum version. The server executes a standard query and returns the entire active dataset. The response includes aDataToken(e.g.,919042!08/22/2024). - Token Storage: The client processes the baseline dataset and persists the returned
DataTokenin a secure, transactional metadata store alongside the synchronized data. - Incremental Sync (Delta): For subsequent synchronizations, the client sends the stored
DataTokenback to Dataverse. The platform uses this token to filter the internal change-tracking tables, returning only the records that have been created, updated, or deleted since that specific version stamp was generated. - New Token Generation: The delta response contains a new
DataTokenrepresenting the current state. The client updates its metadata store with this new token for the next execution cycle.
Token Expiration and Retention Lifecycles
Delta tokens are not permanent. Dataverse maintains change-tracking history for a limited duration to prevent database bloat. This retention period is controlled by the expirechangetrackingindays column in the Organization table.
- Default Retention: 7 days.
- Configurable Range: 0 to 365 days.
- Metadata vs. Data Expiration: It is critical to distinguish between data change tracking (
expirechangetrackingindays, default 7 days) and metadata change tracking (expiresubscriptionsindays, default 90 days).
If a client attempts to synchronize using a delta token older than the configured retention period, the underlying change-tracking cleanup job will have already purged the historical change records. In this scenario, Dataverse cannot guarantee data integrity and will throw an ExpiredVersionStamp exception (Error Code: 0x80044352).
Bidirectional Synchronisation and Loopback Prevention
In a bidirectional synchronization architecture, Dataverse and an external system both act as read/write sources. This introduces the risk of infinite-loop conflicts (loopbacks):
[System A (Dataverse)] ──(Update)──> [Integration Engine] ──(Update)──> [System B (External)]
▲ │
│ ▼
(Triggers Sync) (Triggers Sync)
│ │
└───────────────(Update)◄── [Integration Engine] ◄──(Update)──────────┘
To prevent this loopback, the integration architecture must identify the origin of each write operation. There are four primary patterns to achieve this:
1. Integration User Context
The integration engine connects to Dataverse using a dedicated Microsoft Entra ID App Registration mapped to an application user (e.g., svc-integration-sync). In Dataverse plug-ins or Power Automate flows, the system inspects the InitiatingUserId or CallerId. If the user matches the integration service account, the execution is bypassed.
2. Bypass Custom Business Logic (BypassBusinessLogicExecution)
When the integration engine writes data back to Dataverse, it passes the optional parameter BypassBusinessLogicExecution with the value CustomSync or CustomSync,CustomAsync. This instructs Dataverse to bypass all custom plug-ins and Power Automate flows for that specific transaction, preventing the generation of outbound sync events. This requires the calling user to possess the prvBypassCustomBusinessLogic privilege.
3. Custom Tracking Fields
Each table is extended with an origin tracking column (e.g., new_lastsyncsource) and a version tracking column (e.g., new_externalsyncversion). When the integration engine updates a record in Dataverse, it populates new_lastsyncsource with "ExternalSystem". Plug-ins or flows triggering outbound syncs evaluate this field; if it equals "ExternalSystem", they suppress the outbound message and clear the field (or set it back to "Dataverse").
4. SharedVariables (Intra-Transaction)
For scenarios where changes are made within the same execution context (e.g., a plug-in calling an external API which immediately writes back to Dataverse), the plug-in utilizes the IPluginExecutionContext.SharedVariables collection. By setting a flag (e.g., context.SharedVariables["IsSyncing"] = true), subsequent plug-in executions in the same pipeline transaction can detect the flag and terminate early.
2. Architecture & Decision Matrix
When designing a synchronization pipeline, developers must select the appropriate technology stack based on volume, latency, and complexity.
Comparison of Implementation Approaches
| Feature / Metric | Power Automate | Azure Functions (C# SDK) | Dataverse Plug-ins | Azure Data Factory / Synapse Link |
|---|---|---|---|---|
| Complexity | Low (No-code/Low-code) | Medium (Pro-code C#) | High (Pro-code C# / Sandbox) | Low to Medium |
| Scalability | Low to Medium (Throttled by API limits) | Extremely High (Serverless scaling) | Medium (Bound by 2-minute execution limit) | High (Optimized for bulk data) |
| Execution Context | Asynchronous (Event-driven) | Asynchronous (Polling or Webhook) | Synchronous or Asynchronous | Scheduled Batch / Near Real-Time |
| Offline Support | No | Yes (Can cache tokens locally) | No | No |
| Licensing | Power Automate Per-User/Per-Flow | Azure Consumption (Pay-as-you-go) | Included in Power Apps/Dataverse | Azure Subscription |
| PL-400 Exam Relevance | High (Triggers, Actions) | High (Integration patterns) | Critical (Pipeline, Sandbox, Bypass) | Medium (Data export concepts) |
Architectural Recommendation
- Use Azure Synapse Link / Azure Data Factory for one-way, high-volume analytical replication (Dataverse to Data Lake/Warehouse).
- Use Azure Functions with the .NET Dataverse SDK (
ServiceClient) for complex, bidirectional, near-real-time transactional synchronization with external APIs. - Use Power Automate only for low-volume, simple, one-way notifications or lightweight integrations where latency is not critical.
- Use Dataverse Plug-ins strictly for real-time validation, intra-transaction calculations, or immediate loopback prevention using
SharedVariables.
3. Step-by-Step Implementation Guide
This guide details how to configure and execute a complete delta synchronization pipeline between Dataverse and an external system.
Step 1: Enable Change Tracking on the Table
Change tracking must be enabled at the table metadata level. This can be done via the Power Apps Maker Portal, solution XML customization, or programmatically.
Via Power Apps Maker Portal
- Navigate to the Power Apps Maker Portal.
- Select your Environment in the top-right corner.
- Expand Dataverse and select Tables.
- Select the target table (e.g., Account).
- On the command bar, select Properties (or click the gear icon next to the table name).
- Expand Advanced options.
- Scroll down to the For this table section and check the Track changes checkbox.
- Click Save.
Programmatically via C# SDK
To enable change tracking programmatically (useful for dynamic deployment scripts or ISV solutions), execute an UpdateEntityRequest:
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Messages;
using Microsoft.Xrm.Sdk.Metadata;
public static void EnableChangeTracking(IOrganizationService service, string tableLogicalName)
{
// Retrieve current metadata
RetrieveEntityRequest retrieveRequest = new()
{
LogicalName = tableLogicalName,
EntityFilters = EntityFilters.Entity
};
var retrieveResponse = (RetrieveEntityResponse)service.Execute(retrieveRequest);
EntityMetadata metadata = retrieveResponse.EntityMetadata;
// Set ChangeTrackingEnabled to true
metadata.ChangeTrackingEnabled = true;
UpdateEntityRequest updateRequest = new()
{
Entity = metadata
};
service.Execute(updateRequest);
}
Step 2: Configure the External Metadata Store
The external system must maintain a metadata table to track the synchronization state. Create a table in your external database (e.g., SQL Server) to store the last successful delta token:
CREATE TABLE DataverseSyncMetadata (
TableLogicalName VARCHAR(100) PRIMARY KEY,
LastDeltaToken VARCHAR(250) NULL,
LastSyncDateTime DATETIME2 DEFAULT GETUTCDATE(),
SyncStatus VARCHAR(50) NOT NULL DEFAULT 'PENDING'
);
-- Initialize the row for the account table
INSERT INTO DataverseSyncMetadata (TableLogicalName, LastDeltaToken, SyncStatus)
VALUES ('account', NULL, 'INITIAL_REQUIRED');
Step 3: Execute the Initial Synchronization (Baseline)
When LastDeltaToken is NULL, the integration engine must perform a full synchronization.
- Instantiate a
RetrieveEntityChangesRequest. - Set
EntityNameto"account". - Define the
Columnsto retrieve. - Set
DataVersiontonull(or omit it). - Execute the request and process the returned records as
NewOrUpdatedItemtypes. - Extract the
DataTokenfrom the response and save it toDataverseSyncMetadata.
Step 4: Execute the Incremental Synchronization (Delta)
For subsequent runs, retrieve the stored token from the database and pass it to the request.
- Query
DataverseSyncMetadatato retrieve theLastDeltaToken. - Instantiate
RetrieveEntityChangesRequest. - Set
DataVersionto the retrieved token. - Execute the request.
- Loop through the
Changescollection:- If the item is of type
NewOrUpdatedItem, upsert the record in the external database. - If the item is of type
RemovedOrDeletedItem, delete the record from the external database using theIdfrom theRemovedItemreference.
- If the item is of type
- Save the new
DataTokenreturned in the response back toDataverseSyncMetadata.
Step 5: Handle Paging
If the number of changes exceeds the page size (default and maximum is 5,000 records for standard tables), the response will have MoreRecords set to true. You must page through the results using the PagingCookie.
- Check
response.EntityChanges.MoreRecords. - If
true, extractresponse.EntityChanges.PagingCookie. - Assign the
PagingCookieto thePageInfo.PagingCookieproperty of the next request. - Increment
PageInfo.PageNumber. - Execute the request and repeat until
MoreRecordsisfalse.
4. Complete Code Reference
The following production-grade C# class implements the complete delta synchronization lifecycle, including paging, token expiration handling, and loopback prevention.
// <summary>
// Production-grade service to handle Dataverse Change Tracking and Delta Synchronization.
// Implements full sync, incremental sync, paging, and robust error handling for token expiry.
// </summary>
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
using System.ServiceModel;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Messages;
using Microsoft.Xrm.Sdk.Query;
namespace DataverseSyncEngine
{
public class DataverseDeltaSyncService
{
private readonly IOrganizationService _service;
private readonly string _connectionString; // External DB Connection String
private const int PageSize = 5000;
public DataverseDeltaSyncService(IOrganizationService service, string dbConnectionString)
{
_service = service ?? throw new ArgumentNullException(nameof(service));
_connectionString = dbConnectionString ?? throw new ArgumentNullException(nameof(dbConnectionString));
}
/// <summary>
/// Orchestrates the synchronization process for a specific table.
/// </summary>
public void SynchronizeTable(string entityName, ColumnSet columns)
{
string lastToken = GetLastDeltaToken(entityName);
bool isFullSync = string.IsNullOrEmpty(lastToken);
try
{
ExecuteSync(entityName, columns, lastToken);
}
catch (FaultException<OrganizationServiceFault> ex) when (ex.Detail.ErrorCode == -2147204270) // 0x80044352 - ExpiredVersionStamp
{
// Log warning: Token expired. Initiating full resynchronization.
Console.WriteLine($"[WARNING] Delta token expired for {entityName}. Initiating full resync.");
ResetSyncMetadata(entityName);
ExecuteSync(entityName, columns, null);
}
catch (Exception ex)
{
// Log critical error
Console.WriteLine($"[ERROR] Synchronization failed for {entityName}: {ex.Message}");
throw;
}
}
private void ExecuteSync(string entityName, ColumnSet columns, string deltaToken)
{
RetrieveEntityChangesRequest request = new RetrieveEntityChangesRequest
{
EntityName = entityName,
Columns = columns,
DataVersion = deltaToken,
PageInfo = new PagingInfo
{
Count = PageSize,
PageNumber = 1,
ReturnTotalRecordCount = false,
PagingCookie = null
}
};
string nextToken = null;
bool hasMoreRecords = true;
while (hasMoreRecords)
{
RetrieveEntityChangesResponse response;
try
{
response = (RetrieveEntityChangesResponse)_service.Execute(request);
}
catch (FaultException<OrganizationServiceFault> ex)
{
throw; // Caught by the orchestrator to handle ExpiredVersionStamp
}
if (response.EntityChanges == null)
{
break;
}
ProcessChanges(response.EntityChanges.Changes);
nextToken = response.EntityChanges.DataToken;
hasMoreRecords = response.EntityChanges.MoreRecords;
if (hasMoreRecords)
{
request.PageInfo.PagingCookie = response.EntityChanges.PagingCookie;
request.PageInfo.PageNumber++;
}
}
if (!string.IsNullOrEmpty(nextToken))
{
UpdateSyncMetadata(entityName, nextToken, "SUCCESS");
}
}
private void ProcessChanges(BusinessEntityChangesCollection changes)
{
var upsertList = new List<Entity>();
var deleteList = new List<Guid>();
foreach (IChangedItem change in changes)
{
switch (change.Type)
{
case ChangeType.NewOrUpdated:
if (change is NewOrUpdatedItem newItem)
{
upsertList.Add(newItem.NewOrUpdatedEntity);
}
break;
case ChangeType.RemoveOrDeleted:
if (change is RemovedOrDeletedItem deletedItem)
{
deleteList.Add(deletedItem.RemovedItem.Id);
}
break;
}
}
// Execute bulk operations in the external database
BulkUpsertToExternalDatabase(upsertList);
BulkDeleteFromExternalDatabase(deleteList);
}
/// <summary>
/// Updates a record in Dataverse while bypassing custom business logic to prevent loopbacks.
/// </summary>
public void UpdateRecordWithBypass(Entity targetEntity)
{
UpdateRequest request = new UpdateRequest
{
Target = targetEntity
};
// Bypass custom plug-ins and workflows
request.Parameters.Add("BypassBusinessLogicExecution", "CustomSync,CustomAsync");
// Suppress Power Automate flows
request.Parameters.Add("SuppressCallbackRegistrationExpanderJob", true);
_service.Execute(request);
}
#region Database Metadata Helpers
private string GetLastDeltaToken(string entityName)
{
using (SqlConnection conn = new SqlConnection(_connectionString))
{
conn.Open();
string query = "SELECT LastDeltaToken FROM DataverseSyncMetadata WHERE TableLogicalName = @TableName";
using (SqlCommand cmd = new SqlCommand(query, conn))
{
cmd.Parameters.AddWithValue("@TableName", entityName);
object result = cmd.ExecuteScalar();
return result == DBNull.Value ? null : (string)result;
}
}
}
private void UpdateSyncMetadata(string entityName, string token, string status)
{
using (SqlConnection conn = new SqlConnection(_connectionString))
{
conn.Open();
string query = @"
MERGE INTO DataverseSyncMetadata AS Target
USING (SELECT @TableName AS TableLogicalName) AS Source
ON Target.TableLogicalName = Source.TableLogicalName
WHEN MATCHED THEN
UPDATE SET LastDeltaToken = @Token, LastSyncDateTime = GETUTCDATE(), SyncStatus = @Status
WHEN NOT MATCHED THEN
INSERT (TableLogicalName, LastDeltaToken, LastSyncDateTime, SyncStatus)
VALUES (@TableName, @Token, GETUTCDATE(), @Status);";
using (SqlCommand cmd = new SqlCommand(query, conn))
{
cmd.Parameters.AddWithValue("@TableName", entityName);
cmd.Parameters.AddWithValue("@Token", (object)token ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Status", status);
cmd.ExecuteNonQuery();
}
}
}
private void ResetSyncMetadata(string entityName)
{
UpdateSyncMetadata(entityName, null, "FULL_SYNC_REQUIRED");
}
private void BulkUpsertToExternalDatabase(List<Entity> entities)
{
if (!entities.Any()) return;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
conn.Open();
// Example implementation of a SQL Merge statement for bulk upsert
foreach (var entity in entities)
{
string query = @"
MERGE INTO ExternalAccounts AS Target
USING (SELECT @Id AS AccountId) AS Source
ON Target.AccountId = Source.AccountId
WHEN MATCHED THEN
UPDATE SET Name = @Name, ModifiedOn = @ModifiedOn
WHEN NOT MATCHED THEN
INSERT (AccountId, Name, ModifiedOn)
VALUES (@Id, @Name, @ModifiedOn);";
using (SqlCommand cmd = new SqlCommand(query, conn))
{
cmd.Parameters.AddWithValue("@Id", entity.Id);
cmd.Parameters.AddWithValue("@Name", entity.Contains("name") ? entity["name"] : DBNull.Value);
cmd.Parameters.AddWithValue("@ModifiedOn", entity.Contains("modifiedon") ? entity["modifiedon"] : DateTime.UtcNow);
cmd.ExecuteNonQuery();
}
}
}
}
private void BulkDeleteFromExternalDatabase(List<Guid> ids)
{
if (!ids.Any()) return;
using (SqlConnection conn = new SqlConnection(_connectionString))
{
conn.Open();
string query = "DELETE FROM ExternalAccounts WHERE AccountId = @Id";
foreach (var id in ids)
{
using (SqlCommand cmd = new SqlCommand(query, conn))
{
cmd.Parameters.AddWithValue("@Id", id);
cmd.ExecuteNonQuery();
}
}
}
}
#endregion
}
}
Web API Equivalent Requests
For non-.NET clients, the same operations can be performed using the Dataverse Web API.
Initial Request (Baseline)
To initiate change tracking, append the Prefer: odata.track-changes header to your query.
GET [Organization URI]/api/data/v9.2/accounts?$select=name,accountnumber,telephone1 HTTP/1.1
Prefer: odata.track-changes
OData-Version: 4.0
Content-Type: application/json
Response:
The response contains the records and an @odata.deltaLink at the end of the payload.
{
"@odata.context": "[Organization URI]/api/data/v9.2/$metadata#accounts(name,accountnumber,telephone1)",
"value": [
{
"@odata.etag": "W/\"5010321\"",
"name": "Contoso Pharmaceuticals",
"accountnumber": "CNT-001",
"telephone1": "555-0199",
"accountid": "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6"
}
],
"@odata.deltaLink": "[Organization URI]/api/data/v9.2/accounts?$select=name,accountnumber,telephone1&$deltatoken=919042%2108%2f22%2f2024%2014%3a22%3a10"
}
Subsequent Request (Delta Sync)
Extract the $deltatoken value from the @odata.deltaLink and pass it in the next request.
GET [Organization URI]/api/data/v9.2/accounts?$select=name,accountnumber,telephone1&$deltatoken=919042%2108%2f22%2f2024%2014%3a22%3a10 HTTP/1.1
OData-Version: 4.0
Content-Type: application/json
Response (with changes):
The response returns new/updated items, deleted items (marked with @odata.removed), and a new delta link.
{
"@odata.context": "[Organization URI]/api/data/v9.2/$metadata#accounts(name,accountnumber,telephone1)/$delta",
"value": [
{
"@odata.context": "[Organization URI]/api/data/v9.2/$metadata#accounts/$entity",
"@odata.etag": "W/\"5020444\"",
"name": "Contoso Pharmaceuticals Ltd",
"accountnumber": "CNT-001",
"telephone1": "555-0199",
"accountid": "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6"
},
{
"@odata.context": "[Organization URI]/api/data/v9.2/$metadata#accounts/$deletedEntity",
"id": "f9e8d7c6-b5a4-f3e2-d1c0-b9a8f7e6d5c4",
"reason": "deleted"
}
],
"@odata.deltaLink": "[Organization URI]/api/data/v9.2/accounts?$select=name,accountnumber,telephone1&$deltatoken=920115%2108%2f23%2f2024%2009%3a15%3a30"
}
Web API Bypass Custom Business Logic
When writing back to Dataverse, pass the MSCRM.BypassBusinessLogicExecution and MSCRM.SuppressCallbackRegistrationExpanderJob headers.
PATCH [Organization URI]/api/data/v9.2/accounts(a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6) HTTP/1.1
Content-Type: application/json
MSCRM.BypassBusinessLogicExecution: CustomSync,CustomAsync
MSCRM.SuppressCallbackRegistrationExpanderJob: true
{
"name": "Updated Name from External System"
}
5. Configuration & Environment Setup
To deploy the synchronization engine securely, configuration parameters must be externalized using Dataverse Environment Variables and Azure Key Vault.
Environment Variable Schema Definitions
Create a solution containing the following environment variables to manage the synchronization endpoints and database connection strings.
new_SyncDatabaseConnectionString (Secret Type)
- Logical Name:
new_SyncDatabaseConnectionString - Display Name: Sync Database Connection String
- Type: Secret
- Secret Store: Azure Key Vault
- Value Format:
{"keyVaultReference":{"keyVaultName":"kv-prod-sync","secretName":"SqlConnString"}}
new_SyncBatchSize (Decimal/Integer Type)
- Logical Name:
new_SyncBatchSize - Display Name: Sync Batch Size
- Type: Number (Integer)
- Default Value:
5000
Azure Resource Configuration (Bicep)
The following Bicep template deploys an Azure Function App configured with a System-Assigned Managed Identity, an Azure Key Vault, and the necessary App Settings to securely connect to Dataverse and the SQL Database.
param location string = resourceGroup().location
param prefix string = 'sync'
param keyVaultName string = '`${prefix}`-kv-`${uniqueString(resourceGroup().id)}`'
param functionAppName string = '`${prefix}`-func-`${uniqueString(resourceGroup().id)}`'
param storageAccountName string = '`${prefix}`store`${uniqueString(resourceGroup().id)}`'
// Storage Account for Azure Function
resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = {
name: storageAccountName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
}
// Key Vault for Secrets
resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = {
name: keyVaultName
location: location
properties: {
sku: {
family: 'A'
name: 'standard'
}
tenantId: subscription().tenantId
enableRbacAuthorization: true
}
}
// Azure Function App Service Plan
resource hostingPlan 'Microsoft.Web/serverfarms@2022-09-01' = {
name: '`${prefix}`-plan'
location: location
sku: {
name: 'Y1'
tier: 'Dynamic'
}
}
// Azure Function App with Managed Identity
resource functionApp 'Microsoft.Web/sites@2022-09-01' = {
name: functionAppName
location: location
kind: 'functionapp'
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: hostingPlan.id
siteConfig: {
appSettings: [
{
name: 'AzureWebJobsStorage'
value: 'DefaultEndpointsProtocol=https;AccountName=`${storageAccount.name}`;EndpointSuffix=`${environment().suffixes.storage}`;AccountKey=`${storageAccount.listKeys().keys[0].value}`'
}
{
name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
value: 'DefaultEndpointsProtocol=https;AccountName=`${storageAccount.name}`;EndpointSuffix=`${environment().suffixes.storage}`;AccountKey=`${storageAccount.listKeys().keys[0].value}`'
}
{
name: 'FUNCTIONS_EXTENSION_VERSION'
value: '~4'
}
{
name: 'FUNCTIONS_WORKER_RUNTIME'
value: 'dotnet'
}
{
name: 'DataverseUrl'
value: 'https://org-prod.crm.dynamics.com'
}
{
name: 'SqlConnectionString'
value: '@Microsoft.KeyVault(VaultName=`${keyVaultName}`;SecretName=SqlConnString)'
}
]
}
}
}
// Grant Key Vault Secrets User role to Function App Managed Identity
resource kvRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(keyVault.id, functionApp.id, '46330157-13e1-40c8-87d0-b1e39790773d') // Key Vault Secrets User Role ID
scope: keyVault
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '46330157-13e1-40c8-87d0-b1e39790773d')
principalId: functionApp.identity.principalId
principalType: 'ServicePrincipal'
}
}
6. Security & Permission Matrix
To execute change tracking and bypass business logic, the integration service principal must be granted specific privileges within Dataverse and Azure.
Security Role Configuration
Create a custom Dataverse Security Role named Integration Sync Engine with the following minimum privileges:
| Privilege Name | Table / Component | Scope | Reason |
|---|---|---|---|
prvReadAccount | Account | Organization | Required to execute RetrieveEntityChangesRequest on the Account table. |
prvWriteAccount | Account | Organization | Required to write updates back to Dataverse. |
prvBypassCustomBusinessLogic | System | Global | Required to use the BypassBusinessLogicExecution parameter. |
prvBypassCustomPlugins | System | Global | Required to use the legacy BypassCustomPluginExecution parameter. |
prvReadOrganization | Organization | Organization | Required to read change tracking expiration settings. |
Azure & OAuth Scope Matrix
| Principal | Target Resource | Permission / Scope | Reason |
|---|---|---|---|
| Function App Managed Identity | Dataverse API | user_impersonation | Allows the Function App to call the Dataverse Web API/SDK. |
| Function App Managed Identity | Azure Key Vault | Key Vault Secrets User | Allows retrieval of the SQL connection string. |
| Function App Managed Identity | Azure SQL Database | db_datareader, db_datawriter | Allows reading/writing sync metadata and business data. |
7. Pipeline Execution Internals
Understanding the internal execution pipeline of Dataverse is critical when designing synchronization logic, especially when bypassing custom logic.
[Client Request]
│
▼
┌────────────────────────────────────────────────────────┐
│ 1. Pre-Validation Stage (Stage 10) │
│ - Security checks, basic validation │
│ - NOT bypassed by BypassBusinessLogicExecution │
└──────────────────────┬─────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 2. Pre-Operation Stage (Stage 20) │
│ - Database transaction starts │
│ - Custom plug-ins run here │
│ - BYPASSED if BypassBusinessLogicExecution is set │
└──────────────────────┬─────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 3. Main Operation Stage (Stage 30) │
│ - Core SQL operation (Insert/Update/Delete) │
│ - System plug-ins execute (cannot be bypassed) │
└──────────────────────┬─────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 4. Post-Operation Stage (Stage 40) │
│ - Custom plug-ins run here │
│ - BYPASSED if BypassBusinessLogicExecution is set │
└──────────────────────┬─────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ 5. Async Event Queue (Stage 50) │
│ - Power Automate flows, async plug-ins queued │
│ - BYPASSED if SuppressCallbackRegistration... is set│
└────────────────────────────────────────────────────────┘
Execution Pipeline Stages and Bypass Behavior
When a request is sent to Dataverse with BypassBusinessLogicExecution set to CustomSync,CustomAsync, the platform alters the standard execution pipeline:
- Pre-Validation (Stage 10): Custom plug-ins registered in this stage are bypassed. System validation plug-ins still execute.
- Pre-Operation (Stage 20): All custom plug-ins registered in this stage are bypassed. The database transaction starts.
- Main Operation (Stage 30): The core SQL operation executes. System plug-ins (such as those maintaining internal platform integrity) execute and cannot be bypassed.
- Post-Operation (Stage 40): All custom plug-ins registered in this stage are bypassed.
- Asynchronous Queueing (Stage 50): Custom asynchronous plug-ins and workflows are bypassed. Power Automate flows are not bypassed by this parameter; they must be bypassed using the
SuppressCallbackRegistrationExpanderJobparameter.
Transaction Boundaries and Sandbox Restrictions
- Sandbox Isolation: All custom plug-ins run within the Dataverse Sandbox isolation mode. Sandbox plug-ins cannot access local system resources and are restricted to outbound network calls via HTTP/HTTPS (ports 80 and 443).
- Execution Timeout: Plug-ins have a strict 2-minute (120-second) execution limit. If a plug-in exceeds this limit, the platform terminates the thread and rolls back the entire database transaction.
- Depth/Loop-Guard Limits: Dataverse maintains a depth counter in the execution context. If a chain of plug-ins triggers updates that cause the pipeline to execute recursively, the platform throws an exception when the depth exceeds 16. Using
BypassBusinessLogicExecutionis the primary mechanism to reset or avoid depth accumulation during synchronization.
8. Error Handling & Retry Patterns
Robust synchronization engines must handle transient network failures, API throttling, and token expiration gracefully.
Common Failure Modes and Error Codes
| Error Code (Hex) | Error Code (Decimal) | Error Name | Root Cause | Remediation |
|---|---|---|---|---|
0x80044352 | -2147204270 | ExpiredVersionStamp | The delta token is older than the configured retention period (default 7 days). | Catch the exception, clear the stored token, and execute a full resynchronization. |
0x8004E023 | -2147164125 | AggregateQueryRecordLimitExceeded | The query attempted to aggregate or retrieve more than 50,000 records without paging. | Implement proper paging using PagingCookie and MoreRecords. |
0x80040224 | -2147220956 | PrivilegeDenied | The calling user lacks organization-level read access to the table. | Assign a security role with global Read privileges for the target table. |
0x80044415 | -2147204075 | ExportAttributeMapException | Schema mismatch between environments during solution import. | Ensure target environment schema matches source before running sync. |
429 | 429 | TooManyRequests | Service Protection Limits exceeded (concurrent connections, API requests, or execution time). | Implement exponential back-off retry logic using the Retry-After header. |
C# Exponential Back-off Retry Implementation
The following code demonstrates how to implement a resilient execution wrapper using exponential back-off, specifically handling Dataverse Service Protection limits.
using System;
using System.ServiceModel;
using System.Threading;
using Microsoft.Xrm.Sdk;
namespace DataverseSyncEngine
{
public static class DataverseRetryPolicy
{
private const int MaxRetries = 5;
private const int BaseDelaySeconds = 2;
public static TResult ExecuteWithRetry<TResult>(Func<TResult> operation)
{
int retryCount = 0;
while (true)
{
try
{
return operation();
}
catch (FaultException<OrganizationServiceFault> ex) when (IsTransientError(ex.Detail.ErrorCode))
{
retryCount++;
if (retryCount > MaxRetries)
{
throw new Exception("Maximum retry attempts exceeded for transient Dataverse error.", ex);
}
int delay = CalculateDelay(retryCount, ex.Detail);
Console.WriteLine($"[RETRY] Transient error detected ({ex.Detail.ErrorCode}). Retrying in {delay} seconds (Attempt {retryCount}/{MaxRetries})...");
Thread.Sleep(delay * 1000);
}
catch (Exception ex)
{
// Non-transient exception, rethrow immediately
throw;
}
}
}
private static bool IsTransientError(int errorCode)
{
// Service Protection Limit Error Codes:
// -2147015902: Number of requests exceeded limit
// -2147015903: Execution time exceeded limit
// -2147015901: Concurrent connections exceeded limit
return errorCode == -2147015902 || errorCode == -2147015903 || errorCode == -2147015901;
}
private static int CalculateDelay(int retryCount, OrganizationServiceFault fault)
{
// If the platform provides a specific retry-after duration in the fault details, use it
if (fault.ErrorDetails.ContainsKey("Retry-After"))
{
if (fault.ErrorDetails["Retry-After"] is TimeSpan retryAfter)
{
return (int)Math.Ceiling(retryAfter.TotalSeconds);
}
}
// Otherwise, calculate exponential back-off: BaseDelay * 2^(retryCount - 1)
return (int)Math.Pow(2, retryCount - 1) * BaseDelaySeconds;
}
}
}
9. Performance Optimisation & Limits
To maintain high throughput and avoid hitting platform boundaries, synchronization architectures must respect Dataverse API limits.
Platform Limits and Throttling Caps
- Service Protection Limits: Evaluated in a sliding 5-minute window per user account per web server instance:
- Requests Count: Max 6,000 requests per user.
- Execution Time: Max 20 minutes (1,200 seconds) of combined execution time.
- Concurrent Requests: Max 52 concurrent requests.
- API Entitlement Limits: Evaluated in a 24-hour window based on user licenses (e.g., 40,000 to 100,000 requests per day).
- Payload Size Limit: The maximum request size for any single API call is 104,857,600 bytes (100 MB).
Optimization Strategies
1. Parallel Request Execution
To maximize throughput, execute requests in parallel. The optimal Degree of Parallelism (DOP) depends on the number of web servers allocated to your environment. A safe starting point is DOP = 16 to 32.
using System.Threading.Tasks;
public void ParallelUpsert(List<Entity> entities, IOrganizationService service)
{
Parallel.ForEach(entities, new ParallelOptions { MaxDegreeOfParallelism = 16 }, entity =>
{
DataverseRetryPolicy.ExecuteWithRetry(() =>
{
service.Update(entity);
return true;
});
});
}
2. Batching with ExecuteMultipleRequest
When parallel execution is not viable (e.g., in low-concurrency environments), batch requests using ExecuteMultipleRequest.
- Optimal Batch Size: 200 to 500 records per batch.
- Platform Limit: Dataverse enforces a maximum of 1,000 requests per
ExecuteMultipleRequestand a maximum of 2 concurrentExecuteMultipleRequestexecutions per organization to prevent database lock contention.
3. Column Filtering (ColumnSet)
Never retrieve all columns. Only include the specific columns required by the external system in your ColumnSet. This reduces database read times, network payload sizes, and serialization overhead.
10. ALM & Deployment Checklist
Deploying change-tracking configurations across environments (Development, Test, Production) must be automated using Power Platform Build Tools and Azure DevOps or GitHub Actions.
Solution Packaging Rules
- Table Customizations: Ensure the table metadata (with
ChangeTrackingEnabled = true) is included in your managed solution. - Environment Variables: Include the Environment Variable definitions in the solution, but do not include the Environment Variable Values (Current Value) in the solution package. This prevents development values from overwriting production values.
- Connection References: Package Connection References within the solution. Map them to the target environment's connections during deployment.
GitHub Actions Deployment Pipeline YAML
The following YAML snippet demonstrates how to automate the export, unpack, and deployment of a solution containing change-tracking configurations using GitHub Actions.
name: Deploy Sync Solution to Production
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
DATAVERSE_URL: 'https://org-prod.crm.dynamics.com'
CLIENT_ID: '00000000-0000-0000-0000-000000000000'
TENANT_ID: '00000000-0000-0000-0000-000000000000'
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Install Power Platform CLI
uses: microsoft/powerplatform-actions/actions-install@v1
- name: Authenticate to Production Environment
uses: microsoft/powerplatform-actions/authenticate@v1
with:
environment-url: `${{ env.DATAVERSE_URL }`}
app-id: `${{ env.CLIENT_ID }`}
client-secret: `${{ secrets.DATAVERSE_CLIENT_SECRET }`}
tenant-id: `${{ env.TENANT_ID }`}
- name: Pack Solution
uses: microsoft/powerplatform-actions/pack-solution@v1
with:
solution-folder: 'solutions/SyncEngineSolution'
solution-file: 'out/SyncEngineSolution.zip'
solution-type: 'Managed'
- name: Import Solution to Production
uses: microsoft/powerplatform-actions/import-solution@v1
with:
environment-url: `${{ env.DATAVERSE_URL }`}
solution-file: 'out/SyncEngineSolution.zip'
force-overwrite: true
publish-changes: true
run-asynchronously: true
# Inject environment variable values and connection references
deployment-settings-file: 'config/prod-deployment-settings.json'
Deployment Settings JSON (prod-deployment-settings.json)
This file maps the environment variables and connection references to production-specific values during the import process.
{
"EnvironmentVariables": [
{
"SchemaName": "new_SyncDatabaseConnectionString",
"Value": "{\"keyVaultReference\":{\"keyVaultName\":\"kv-prod-sync\",\"secretName\":\"SqlConnString\"}}"
},
{
"SchemaName": "new_SyncBatchSize",
"Value": "5000"
}
],
"ConnectionReferences": [
{
"LogicalName": "new_sharedsql_connectionref",
"ConnectionId": "prod-sql-connection-guid"
}
]
}
11. Common Pitfalls & Troubleshooting Guide
1. The "Ghost Deleted Items" Phenomenon
- Symptom: The synchronization engine receives delete notifications (
RemovedOrDeletedItem) for records that the client application has never seen or synchronized. - Root Cause: If a record is created and subsequently deleted within the interval between two synchronization runs, Dataverse records both events. When the client queries for changes using the previous delta token, the engine returns the deleted item.
- Diagnostic Steps: Inspect the
Changescollection. Note that theRemovedItemID does not exist in the external database. - Fix: Ensure the external database deletion logic uses a safe
DELETE WHERE AccountId = @Idstatement that executes successfully regardless of whether the record exists (idempotent deletion).
2. Infinite Loopback in Bidirectional Sync
- Symptom: CPU usage spikes on both Dataverse and the external database. The same record is updated repeatedly, incrementing version numbers indefinitely.
- Root Cause: System A updates System B, which triggers System B to update System A, which triggers System A to update System B.
- Diagnostic Steps: Check the
modifiedbyfield in Dataverse. If it alternates between the integration user and standard users rapidly, a loopback is active. - Fix: Implement the
BypassBusinessLogicExecutionparameter on all write operations initiated by the integration engine.
3. Missing Deletes for N:N Relationship Tables
- Symptom: Changes to standard tables are tracked, but associations/disassociations in N:N relationships are missed.
- Root Cause: Change tracking cannot be enabled on native N:N intersect tables.
- Diagnostic Steps: Attempt to enable change tracking on an intersect table via code; the platform will throw an error indicating the table is not eligible.
- Fix: Convert the native N:N relationship into a custom intersect table (a manual entity with two 1:N relationships). Enable change tracking on this custom table.
4. Expired Token Crash on Day 8
- Symptom: The integration runs successfully for a week, then crashes on the eighth day with error code
0x80044352. - Root Cause: The integration was offline or failed to run over the weekend, exceeding the default 7-day retention period of the change-tracking engine.
- Diagnostic Steps: Check the last run timestamp in
DataverseSyncMetadata. If it is > 7 days ago, the token is expired. - Fix: Implement a try-catch block specifically for
ExpiredVersionStamp(error code-2147204270). In the catch block, clear the token and trigger a full resynchronization.
5. Schema Drift Silent Failures
- Symptom: The synchronization runs without errors, but new columns added to Dataverse are not appearing in the external database.
- Root Cause: Change tracking only tracks the fact of a change. If the client's
ColumnSetor the external database schema is not updated to include the new columns, the data is ignored. - Diagnostic Steps: Compare the
ColumnSetdefined in the integration code with the active metadata in Dataverse. - Fix: Implement a schema validation step in the integration startup that queries the Dataverse metadata and alerts administrators if schema drift is detected.
6. Service Protection Throttling (HTTP 429)
- Symptom: The integration fails during high-volume periods with
Too Many RequestsorOrganizationServiceFaultindicating limits exceeded. - Root Cause: The integration is executing too many concurrent requests or consuming too much database CPU.
- Diagnostic Steps: Inspect the exception details for the presence of the
Retry-Afterkey. - Fix: Implement exponential back-off retry logic that respects the
Retry-Afterheader returned by Dataverse.
7. SharedVariables Loss Across Pipeline Stages
- Symptom: Loopback prevention using
SharedVariablesfails when the update moves from a synchronous plug-in to an asynchronous plug-in. - Root Cause:
SharedVariablesare only maintained within the same execution context. Asynchronous steps run in a separate system job context, losing the variables. - Diagnostic Steps: Log the contents of
context.SharedVariablesin both the synchronous and asynchronous plug-ins. - Fix: For asynchronous steps, persist the bypass state in a physical column on the record (e.g.,
new_bypasssync) instead of relying onSharedVariables.
8. Paging Cookie Serialization Errors
- Symptom: Paging fails on large datasets with XML parsing errors.
- Root Cause: The paging cookie returned by Dataverse contains special characters that must be properly encoded before being passed back in subsequent XML/SDK requests.
- Diagnostic Steps: Inspect the raw string value of the
PagingCookiereturned in the response. - Fix: Ensure the SDK handles the cookie directly without manual string manipulation, or use
System.Net.WebUtility.HtmlEncodeif constructing raw XML.
9. Privilege Check Failures for Non-Admin Integration Users
- Symptom: The integration runs successfully for System Administrators but fails for custom integration service accounts with a privilege check error.
- Root Cause: The integration user lacks organization-level Read privileges on the target table, which is a strict requirement for executing
RetrieveEntityChangesRequest. - Diagnostic Steps: Run the integration under the service account context and capture the
FaultException. Look forPrincipal user is missing prvRead[Table] privilege. - Fix: Assign a security role to the service account that grants global (Organization) Read access to the table being synchronized.
10. Change Tracking Disabled Automatically After Solution Import
- Symptom: Change tracking was enabled in Development and Production, but after importing an updated solution, change tracking is disabled in Production.
- Root Cause: The solution imported was an unmanaged solution that did not have the
ChangeTrackingEnabledproperty set totruein the table metadata, overwriting the target environment's settings. - Diagnostic Steps: Query the
EntityDefinitionsendpoint in Production and check ifChangeTrackingEnabledisfalse. - Fix: Always use Managed Solutions for downstream environments. Ensure that the source solution explicitly has change tracking enabled before export.
12. Exam Focus: Key Facts & Edge Cases
For candidates preparing for the PL-400: Microsoft Power Platform Developer exam, the following technical details and edge cases are highly testable:
- Underlying Technology: Dataverse Change Tracking is built on SQL Server Change Tracking, not Change Data Capture (CDC). It is synchronous and lightweight.
- Retention Period: The default retention period for data change tracking is 7 days. This is controlled by the
expirechangetrackingindayscolumn in theOrganizationtable. The maximum value is 365 days. - Metadata Expiration: Metadata change tracking expiration is controlled by
expiresubscriptionsindaysand defaults to 90 days. - Error Code for Expired Token: The exact error code for an expired delta token is
0x80044352(ExpiredVersionStamp). The remediation is always a full resynchronization. - Initial Sync Trigger: To trigger a full sync (baseline), pass
nullor an empty string to theDataVersionproperty ofRetrieveEntityChangesRequest. - Paging Limits: The maximum page size for retrieving changes is 5,000 records for standard tables (500 for elastic tables). If more records exist,
MoreRecordsistrue, and you must use thePagingCookie. - Bypass Privilege: To bypass custom business logic using
BypassBusinessLogicExecution, the calling user must possess theprvBypassCustomBusinessLogicprivilege (ID:0ea552b0-a491-4470-9a1b-82068deccf66). - Bypass Parameter Values: The valid values for the
BypassBusinessLogicExecutionparameter areCustomSync,CustomAsync, orCustomSync,CustomAsync. - Flow Suppression: To prevent Power Automate flows from triggering during an integration write, you must pass the
SuppressCallbackRegistrationExpanderJobparameter set totrue. This does not require a special privilege. - Unsupported Query Options: When using the
Prefer: odata.track-changesheader in Web API requests, the system query options$filter,$orderby,$expand, and$topare not supported and will cause the request to fail.