Jump to content

Deep dive: Linked entities in FetchXML: Difference between revisions

From Resco's Wiki
No edit summary
 
(5 intermediate revisions by the same user not shown)
Line 1: Line 1:
{{Deep TOC}}
The first section explains traditional FetchXML link types, i.e., inner and outer joins. It introduces the concept of reverse lookups (as those representing 1:N relations), emphasizing their crucial role in fetch performance. Additionally, it explores how the system's security model affects fetch performance and addresses specifics of the synchronization sync filters.
The first section explains traditional FetchXML link types, i.e., inner and outer joins. It introduces the concept of reverse lookups (as those representing 1:N relations), emphasizing their crucial role in fetch performance. Additionally, it explores how the system's security model affects fetch performance and addresses specifics of the synchronization sync filters.


Line 207: Line 208:


=== Performance considerations ===
=== Performance considerations ===
<!--
Execution of a simple query (not containing links):
- Worst case: Table scan (testing filter match row by row) - O(N) operations*)
- Some queries allow using indexes which allow faster identification of matching rows - O(log2(N)) ops (if a "good" index is selected)


*) N = count of row tables, O(N) is a math symbol for "directly proportional to N", log2 is binary logarithm, O(log2(N)) is a cost of binary search of a sorted set
The execution of a simple query (not containing links) depends on whether a suitable index is available.


Example
* In the worst case, the database has to do a table scan (testing filter match row by row). The number of operations is directly proportional to the number of records.
Imagine this DB table with 1M entries
* In a case where a good index is available, the number of operations for N records is proportional to log2(N).
CREATE TABLE PhoneDirectory (
Address TEXT,
Name TEXT,
Phone TEXT,
)
and this index:
CREATE INDEX idx_PhoneDirectory_Name ON PhoneDirectory(Name);


SELECT * WHERE (Name='John Smith') // 20 operations (ops)
Example: Imagine the database table PhoneDirectory with one million entries and the index idx_PhoneDirectory_Name.
SELECT * WHERE (Phone='0905 918065') // 500K ops => 25000 x slower


<syntaxhighlight lang="SQL">
CREATE TABLE PhoneDirectory (
Address TEXT,
Name TEXT,
Phone TEXT,
)


ChatGPT says: "Lookup columns in Dataverse are automatically indexed." (I am inclined to believe it. MCRM itself respects this rule - in the local DB, of course.)
CREATE INDEX idx_PhoneDirectory_Name ON PhoneDirectory(Name);
=> Lookups are fast, they require log2(N) ops.
=> Joins over lookups are "fast" as well as they reuire the same number of ops.


Imagine a simple query
SELECT * WHERE (Name='John Smith') // 20 operations (ops)
Give me Table rows such that (something)
SELECT * WHERE (Phone='0905 918065') // 500K ops => 25000 x slower
</syntaxhighlight>


Ex: Get active accounts // Fullsync query: Table scan, returns nearly all visited records. Query is fast, but returns many results.
According to ChatGPT, "Lookup columns in Dataverse are automatically indexed." Resco Mobile CRM respects this rule in the device's local database.
Ex: Get accounts whose versionnumber>? // IncSync query: Highly selective. If versionnumber index exists, the query will be fast. (Index seek)
* Lookups are fast, they require log2(N) ops.
* Joins over lookups are "fast" as well; they require the same number of operations.


Performance of this query (Q1) is between
Imagine a simple query: ''Give me table rows such that (something)''.
- log2(N) if there exists an index suitable for the evaluation of the condition "(something)", and
* Example: Get active accounts (As a fullsync query, this is a table scan; it returns nearly all visited records. The query is fast but returns many results.)
- N ops if no such index is found.
* Example: Get accounts whose versionnumber is larger than x (As an IncSync query, it is highly selective. If a versionnumber index exists, the query will be fast - index seek.)


Note:
The performance of this query (Q1) is between log2(N) and N.
I am simplifying here. What actually matters is the number of visited (tested) rows per returned result.
* log2(N) if there exists an index suitable for the evaluation of the condition "(something)", and
Above estimates might be fair in the worst case of highly selective queries.
* N ops if no such index is found.


We are simplifying here. What actually matters is the number of visited (tested) rows per returned result. The estimates above apply to the worst case of highly selective queries.


Now imagine we have a linked query1  
The next example includes a linked query1:
Give me Table1 rows such that (something1) (Q1)
:Give me table1 rows such that (something1) (Q1)
and table1_row.lookup points to a Table2 row such that ... (something2)
::and table1_row.lookup points to a table2 row such that (something2)
:For example: Get salesorderdetails such that manualdiscountamount>0 AND parent_salesorder.totalamount > 1000


Ex: Get salesorderdetails such that manualdiscountamount>0 AND parent_salesorder.totalamount > 1000
The performance estimate for the first part of the query (Q1) is the same as before: between log2(N1) and N ops. However, with each record satisfying (Q1), we have to do additional steps:
* Find row2 from the Table2 - this requires log2(N2) operations.
* Execute (something2) test.


Performance estimate for the first part of the query (Q1) is the same as before - between log2(N1) and N ops.
Given these numbers, we could estimate overall performance to be between log2(N1)*log2(N2) and N1*log2(N2).
However, with each record satisfying (Q1) we have to do additional steps:
- Find row2 from the Table2 - log2(N2) ops.
- Execute (something2) test.
Given these numbers we could estimate overall performance - Between O(log2(N1)*log2(N2)) and N1*log2(N2) ops.


Note:
Note: The sequence of steps above is called a query plan. Many query plans might lead to the correct results. The SQL server query planner is responsible for selecting the most efficient one.
Above sequence of steps is called query plan.  
There might be many query plans leading to the correct results - it is the responsibility of the SqlServer Query Planner to select the most efficient one.


=== Cost of reverse lookups ===


Cost of reverse lookups
Next, imagine a linked query2 that uses a reverse lookup.
------------------------
:Give me Table1 rows such that (something1) (Q1)
::such that there is a table2 row fulfilling (something2)
:::which has a lookup table2_row.lookup -> table1_row
:For example: Get salesorders with totalamount > 1000 which have child_salesorderdetail with manualdiscountamount>0


Now imagine linked query2 which uses a reverse lookup
It is difficult to estimate query2 performance. This is caused by a tree-like space of possible solutions:
Give me Table1 rows such that (something1) (Q1)
such that there is a table2 row fulfilling (something2)
which has a lookup table2_row.lookup -> table1_row


Ex: Get salesorders with totalamount > 1000 which have child_salesorderdetail with manualdiscountamount>0
<syntaxhighlight lang="text">
table1_row
<--- table2_row1
<--- table2_row2
<--- table2_row3
...
</syntaxhighlight>


It is difficult to estimate query2 performance, which is caused by tree-like space of possible solutions:
Let's only say that the performance of these queries is lower (sometimes much lower). The details are too difficult to present here.


table1_row
Here is a real-life example coming from field service: Get "MyAccounts".
<--- table2_row1
<syntaxhighlight lang="text">
<--- table2_row2
Get accounts
<--- table2_row3
used as billing account by some workOrder
which has a booking
assigned to me
</syntaxhighlight>
 
Here is the link sequence (notice the two reverse lookups): <code>account <- msdyn_workorder <- bookableresourcebooking -> bookableresource (userid ==  MyUserid)</code>
 
Traversed records:
<syntaxhighlight lang="text">
account
<--- workOrder1
<--- booking1 -> bookableresource1: Test(userid)
<--- booking2 -> bookableresource2: Test(userid)
<--- booking3 -> bookableresource3: Test(userid)
...
<--- workOrder2
<--- booking1 -> bookableresource1: Test(userid)
...
...
</syntaxhighlight>
=== Hidden costs of the security model ===
The security model further complicates things. If you send the query ''get records that fulfill something'', the server actually executes a more complex query: ''Get records that fulfill something AND (you_have_read_access_to_the_record OR the_record_was_shared_to_you)''.


Let's only say that the performance of these queries is lower (sometimes much lower). The details are too difficult to be presented here.
read_access_test
* Problem: Do you or one of your teams have a role with read access to a given record?
* In theory, this means traversing a lot of system tables (team, teammembership, role, systemuser, principal, roleprivileges, systemuserroles).
* In practice, Dataverse maintains a security cache allowing for faster checks.
* At the time of this writing, this cache was represented by a single table called systemuserbusinessunitentitymap.
* The problem is if this table is huge. Some customers report up to 50M records. This consumes 3G+ of server RAM, ~10G with indexes. The cost of search (log2(50M) = 27) is a lesser risk.
* In some situations the access test may be simplified (for example, owningbusinessunitid==?) or even disappear completely (for entities with general read access).


Here is a "real life" example coming from Field Service:
sharing_test
// Get "MyAccounts"
* Testing whether a given record resides in the POA table (the table containing all shares) with at least read permissions for the user issuing the query.
Get accounts
* Possible problems: Large POA table (10M+ records) or many entries for a given entity.
used as billing account by some workOrder
* As before, sharing_test brings some performance penalty, but the highest risk might be increased memory pressure due to this table's caching. For entities with general read access, the sharing test is skipped altogether.
which has a booking
assigned to me


Here is the link sequence (notice 2 reverse lookups):
Security tests are executed on all tables used by the fetch. This example demonstrates the reason: Imagine I have access to the Employee table but can't read the Salary table. If links were not tested for security, I could issue queries such as "Get employees with salary = 2000."
account <- msdyn_workorder <- bookableresourcebooking -> bookableresource (userid == MyUserid)


Traversed records:
Which security model is good? A model that simplifies your fetches as much as possible (while maintaining the level of security you need). Extreme examples:
account
* You define granular business units and let mobile users download all the data they see. (Security replaces sync filters.)
<--- workOrder1
* Entities with general read access: Security tests are skipped completely. (Sync filters are responsible for security, too.)
<--- booking1 -> bookableresource1: Test(userid)
 
<--- booking2 -> bookableresource2: Test(userid)
What are some of the performance risks related to security?
<--- booking3 -> bookableresource3: Test(userid)
* Too complex role-based security
...
* Heavy use of sharing
<--- workOrder2
* Number of links in the query
<--- booking1 -> bookableresource1: Test(userid)
 
...
How does the risk manifest?
* Security typically adds some (usually minor) penalty to the query execution.
* In the worst case, security checks require a lot of memory, which is missing for query execution. Increased disk i/o may then lead to server timeouts.
 
How to mitigate the risks?
* POA maintenance: Don't let it get too large.
* Simplify security: Give overall read access to child entities (resco_question!) or insensitive entities.
* Simplify fetches: Reduce query links.
 
=== Major performance risks ===
 
As a rule of thumb, a bad query is one that uses the hard drive a lot. Here are some of the reasons:
* Size of data being traversed: Total size of all tables involved in the query, including security cache and POA.
* Query complexity: conditions (especially if they force table scan), links (especially reverse), sorting
* Highly selective queries that require table scan (no suitable index exists, many rows visited to produce one result)
* Complex security
* Heavy sharing
 
== New link types (In, Exists) ==
 
Historically, FetchXML supported only inner/outer links. We discussed them above, hence just a brief summary:
* They represent SQL joins.
* The result is a cartesian product of joined rows.
* Fetch specifies which attributes of the joined rows are to be returned. The server always returns the ID of the base record, even if not requested.
* Attributes of the joined entity may or may not be requested.
* In the latter case, joins are used for filtering only, and fetch might return duplicates (for joins using reverse lookups).
* The only difference between inner/outer links: Outer joins also return base entity rows without any joined row. (Joined row attributes are represented as null.)
 
Performance point of view:
* Joins over standard lookups do not represent any risk. Their cost is log2(N), and they enable linear traversing of data.
* Joins over reverse lookup represent 1:N relation and introduce branching, i.e., data needs to be traversed as a tree. Such joins are costly and may produce duplicates.
 
In the next part, we'll be talking about In/Exists link types. Microsoft introduced even more new link types, but:
* They don’t appear to be very useful.
* Some of them (such as the matchfirstrowusingcrossapply link type) are unsupported by SQLite (the database engine used in the Resco Mobile CRM).
 
=== EXISTS/IN link types ===
 
These two link types were introduced to Resco Mobile CRM in the [[Releases/Winter_2025|release 18.0]].
* The links are variants of the INNER link type using different conditions (Exists/In).
* They are implemented via SQL subqueries.
* The query does not return duplicates. Therefore, sync does not need to add DISTINCT.
* The attributes of the linked entity cannot be used outside of <link-entity> node, i.e., in filters or sorting. They cannot be returned by the parent query.
 
In the existing projects, which sync filter links could be potentially replaced by In/Exists?
* Inner links whose attributes (of the linked entity) are not used outside of those links.
* Inner links over lookups: While In/Exists could formally work (if the first point is fulfilled), we would gain nothing. (Probably a slower query.) Only inner links representing 1:N conditions make sense (reverse lookups, LinkedEntity.From is not Primary Key).


Performance gains:
* Exists/In might return less data than Inner/Outer (because they skip duplicates)
* They might do less work in the server database because the loop testing multiple matching rows is interrupted at the first match.


Hidden costs of the security model
The meaning of In/Exists link types is defined by their SQL translation.
-----------------------------------


General idea: If you send this query:
EXISTS Returns TRUE if the subquery returns any results. An example follows:
Get records which fulfill something


the server in fact executes a more complex query:
<syntaxhighlight lang="XML">
Get records which fulfill something AND (
// Fetch using Exists link type
you_have_read_access_to_the_record
// Get contacts which act as a primary contact for some account
OR
<fetch>
the_record_was_shared_to_you
  <entity name='contact'>
  <attribute name='fullname' />
  <link-entity name='account' from='primarycontactid' to='contactid' link-type='exists'>
<filter type='and'>
<condition attribute='statecode' operator='eq' value='1' />
</filter>
  </link-entity>
  </entity>
</fetch>
 
// SQL translation (2nd row represents the subquery)
SELECT fullname FROM contact AS C WHERE EXISTS (
SELECT 1 FROM account AS A WHERE A.statecode=1 AND C.contactid=A.primarycontactid
)
)
</syntaxhighlight>
IN Returns TRUE if the value of the tested expression ("to" value) is equal to any value returned by the subquery. An example follows:
<syntaxhighlight lang="XML">
// Fetch using In link type
// Same meaning as above fetch, although a different SQL translation is produced.
<fetch>
  <entity name='contact'>
  <attribute name='fullname' />
  <link-entity name='account' from='primarycontactid' to='contactid' link-type='in'>
<filter type='and'>
<condition attribute='statecode' operator='eq' value='1' />
</filter>
  </link-entity>
  </entity>
</fetch>
// SQL translation
SELECT fullname FROM contact AS C WHERE C.contactid IN (
SELECT A.primarycontactid FROM Account as A WHERE A.statecode=1
)
</syntaxhighlight>
Although we have not discussed it so far, inner/outer links correspond to SQL joins. (They are translated as SQL joins, too.) A natural question arises - which one of the two techniques is better?
=== SQL dilemma: Join vs subquery ===
'''Subqueries''' (also called inner queries or nested queries) involve a SELECT statement embedded in another SELECT statement, typically in its WHERE clause.
'''Joins''' are used to combine rows from two or more tables based on a related column.
According to SQL Server documentation:
:"Many T-SQL statements with subqueries can be alternatively formulated as joins. There's usually no performance difference between a statement using a subquery and a semantically equivalent version that doesn't. However, in some cases where existence must be checked, a join yields better performance."
=== In/Exists usage - Summary ===
A link can be replaced by In/Exists if:
* It is an inner link.
* LinkEntity has no attributes and no sort order.
* Linked entity columns are not used outside of LinkEntity. (One way to force this is to use a null alias.)
Effects of In/Exists links:
* Fetch->SQL translation uses a subquery instead of a join.
* Sync fetches don't use DISTINCT keyword. (There is no need to do so.)
Recommendation:
* Use In/Exists for 1:N links only, i.e., for links over a reverse lookup. Intuitively, this might be the only case where it's less work for the SQL server. (Because the traversal of multiple branches is stopped when the first result is found.)
* In general, there are not too many sync filter candidates for using IN links, but all of them belong to fetches that might have performance problems.
* Note, however, that the final decision on which of the two competing technologies (join or subquery) is selected remains at the SQL server, more precisely at its query planner.
=== Real-life example ===
This was a real-life example that proved the usefulness of IN links. The customer showed that the following fetch (used in IncSync with Incremental Sync Filter) results in server timeout if the booking link is INNER, but executes very fast for link-type IN.
<syntaxhighlight lang="XML">
// Slightly simplified FetchXml syntax
<fetch distinct="true">
<entity name="msdyn_workorderincident"> // 5.7M recs, 17 incidents/order
<condition attribute="statuscode" operator="in" values="Active, Complete" />
<condition attribute="versionnumber" operator="ge" value="331647639" /> // since the last sync


read_access_test
<link-entity name="msdyn_workorder" to="msdyn_workorder" from="msdyn_workorderid"> // 330K recs, 1:1, 457 WOs/user
Problem: Do you or one_of_your_teams have a role with read access to given record?
<condition attribute="statecode" operator="eq" value="0" />
This in theory means traversing a lot of system tables (team, teammembership, role, systemuser, principal, roleprivileges, systemuserroles).
In praxis Dataverse maintains security cache allowing for faster checks.
<link-entity name="bookableresourcebooking" from="msdyn_workorder"  to="msdyn_workorderid" link-type="IN"> // 440K recs, 1:N
At the time of this writing this cache was represented by single table called systemuserbusinessunitentitymap.
<condition attribute="starttime" operator="on-or-after" value="@@TODAY-30" />
The problem is if this table is huge. (50M records for EDPR. This consumes 3G+ of server RAM, ~10G with indexes. Cost of search (log2(50M) = 27) is lesser risk.)
In some situations the access test may be simplified (f.e. owningbusinessunitid==?) or even disappear completely (for entities with general read access).
<link-entity name="bookableresource" from="bookableresourceid" to="resource"> // 722 recs
<condition attribute="userid" operator="eq" value="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</link-entity>
</link-entity>
</link-entity>
</entity>
</fetch>
</syntaxhighlight>
 
If you don't feel like parsing the example, here's a human-readable description. We only get records that have changed since the last sync (version number >= 331647639) and that match the sync filter.
And the sync filter is, "get (active/complete) incidents related to active work orders where I have recent booking (= MyBookings)".
 
The fetch returns 7430 results. Here is the sequence of links involved together with entity cardinalities:
:msdyn_workorderincident(5.7M recs) -> msdyn_workorder(330K) <- bookableresourcebooking(440K) -> bookableresource(722)
 
Reverse lookup (2nd link) introduces branching into data traversal. (Which requires DISTINCT fetches if inner links are used.)
 
The point to note is that the MyBookings subquery reduces the number of records from 440K to a few hundreds. (One technician has at most 100s of relevant bookings.) So introducing IN link-type (which is based on MyBookings subquery) changes the query this way:
:msdyn_workorderincident(5.7M) -> msdyn_workorder(330K) WHERE msdyn_workorderid is referred by MyBookings(O(100))
 
This greatly reduces the amount of data traversed during query execution and increases thus query performance.
 
=== Generalization for field service ===
 
The fetch above comes from a field service app built around a concept that seems to be particularly suited for applying IN links. Let's start by explaining this (MyBookings-centric) concept. The main idea is that the client (mobile user) needs his jobs (bookableresourcebooking's) + their related records (work order, accounts, ...).
 
Here is a typical example:
 
<syntaxhighlight lang="text">
MyBookings: My active bookings (bookableresourcebooking) scheduled within some period
 
- MyWorkOrders: workOrders (msdyn_workorder) related to MyBookings
-- MyServiceTasks: serviceTasks (msdyn_workorderservicetask) belonging to MyWorkOrders
-- MyIncidents: relevant incidents (msdyn_workorderincident) related to MyWorkOrders
--- MyAssets: assets (msdyn_customerasset) where MyIncidents were reported
 
-- MyAccounts: accounts related to MyWorkOrders
...
</syntaxhighlight>
 
Different customizations may use different entities, but the principle stays the same. In a typical business, all tables involved will be large (or even huge), but the number of bookings per user is usually in the range of hundreds, and there's a similar number of related work orders, accounts, incidents, etc. The database schema projection is small and dynamic: bookings change every day.
 
Sync fetches are very complex. ("Get assets related to incidents related to work orders related to bookings which are assigned to me during next 2 weeks" - 4+ links.) But, all of them use the MyBookings link that has a small result set and uses a reverse link. So the bookings link really looks like a situation when link-type IN should excel.
 
(Work order link is another candidate for IN link-type, but in this case, we would not get rid of the reverse lookup.)
 
These considerations might be valid for many Field Service fetches - all those based on MyBookings. (MyVisits, MyAppointments...)
 
Again, the final verdict is on whether the SQL server query optimizer discovers the optimal query plan. (Our usage of the IN link type is nothing more than a recommendation.)
 
=== SQLite tests ===


sharing_test
(This is for techies who want to understand the impact on the database level.)
Testing whether given record resides in POA table (table containing all shares) with at least read permissions for the user issuing the query.
Possible problems: Large POA table (10M+ records) or many entries for given entity.
As before, sharing_test brings some perf penalty, but the highest risk might be increased memory pressure due to caching of this table.
For entities with general read access the sharing test is skipped altogether.


Security tests are executed on all tables used by the fetch
Here is a test comparing the performance of join vs subselect. You can subsequently see
This example demonstrates the reason:
* SQL command
Imagine I have access to Employee table, but can't read Salary table. If links would not be tested for security, I could issue queries such as:
* SQLite query plan
Get employees such that their salary = 2000
* performance


Which security model is good?
<syntaxhighlight lang="text">
Model that simplifies your fetches as much as possible (while maintaining level of security you need).
SELECT fullname FROM contact inner JOIN account AS A ON (contact.pe_accountid = A.accountid AND (A.statuscode = ?) )
Extreme examples:
0 | 0 | 1 | SCAN TABLE account AS A
- You define granular BU's and let mobile users download all data they see. (Security replaces sync filters.)
0 | 1 | 0 | SEARCH TABLE contact USING INDEX idx_contact_pe_accountid (pe_accountid=?)
- Entities with general read access: Security tests are skipped completely. (Sync filters are responsible for security, too.)
INNER LINK: 451947 recs in 21945ms (2nd trial 18264ms)
</syntaxhighlight>


Performance risks related to security:
<syntaxhighlight lang="text">
- Too complex role-based security
SELECT fullname FROM contact WHERE pe_accountid IN (SELECT A.accountid FROM account AS A WHERE A.statuscode=1)
- Heavy use of sharing
0 | 0 | 0 | SEARCH TABLE contact USING INDEX idx_contact_pe_accountid (pe_accountid=?)
- Number of links in the query
0 | 0 | 0 | EXECUTE LIST SUBQUERY 1
1 | 0 | 0 | SCAN TABLE account AS A
IN LINK: 451947 recs in 19431ms (2nd trial 19900ms)
</syntaxhighlight>


Risk manifestation:
As you see, in this case, both queries showed about the same performance, although they used different query plans. We had the same experience with several other queries.
- Security typically adds some (usually minor) penalty to the query execution.
- Worst case: Security checks require a lot of memory, which is missing for query execution. Increased disk i/o may then lead to server timeouts.


Risk mitigation:
Another interesting observation: As we increased the database cache size from 80M to 1.6G, the performance changed dramatically:
- POA maintenance
* INNER LINK: 451947 recs in 2708ms
- Simplify security: Give overall read access to child entities (resco_question!) or insensitive entities.
* IN LINK: 451947 recs in 3920ms
- Simplify fetches (reduce query links)


For yet another query, INNER performance was
:a) the same as IN performance for small DB cache,
:b) much worse for medium database cache,
:c) again, the same for large database cache. The performance in this case was much higher than in case a), of course.


Major performance risks
This suggests that
------------------------
* Disk i/o plays a dominant role in the performance.
* Queries containing INNER LINK need more memory and are thus more sensitive towards memory stress.


As a rule of thumb, a bad query is one that uses the hard-drive a lot. Here are some of the reasons:
We just saw that despite being equivalent, INNER/IN queries lead to different query plans, which might perform better or worse depending on the database configuration.
- Size of data being traversed: Total size of all tables involved in the query incl. security cache and POA.
- Query complexity (conditions - especially if they force table scan, links - especially reverse, sorting)
- Highly selective queries which must use table scan (no suitable index exists, many rows visited to produce 1 result)
- Complex security
- Heavy sharing

Latest revision as of 13:37, 14 November 2025

The first section explains traditional FetchXML link types, i.e., inner and outer joins. It introduces the concept of reverse lookups (as those representing 1:N relations), emphasizing their crucial role in fetch performance. Additionally, it explores how the system's security model affects fetch performance and addresses specifics of the synchronization sync filters.

The second section explores the applicability of In/Exists links:

  • These links can replace inner links when the linked entity’s attributes are neither referenced outside the <link-entity> nor included in the fetch results.
  • They are effective when the performance of an SQL sub-query surpasses that of a join.
  • This is particularly relevant for highly selective fetches (those examining large datasets to yield a small number of results) and their inner links based on reverse lookups.

The key takeaway is that while In/Exists links may not generally improve fetch performance (and can sometimes degrade it), there are specific contexts — particularly in complex Field Service queries — where they can offer significant performance improvements and help avoid potential server timeouts.

A typical example is msdyn_workorder<-bookableresourcebooking link in the MyBookings-centric Field Service client data model.

Linked entities in FetchXML

Links allow you to join related entities (tables) in a single query. For example, you might want to get information for an account which includes contacts related to that account. (We'll discuss this example later.)

Formally, links combine two entities - BaseEntity with LinkedEntity, whereby the latter is described by XML syntax <link-entity name="LinkedEntity" from="fieldFrom" to="fieldTo" alias="..." link-type="...">

In the example

  • LinkedEntity.fieldFrom should match BaseEntity.fieldTo.
    This typically means a lookup BaseEntity.fieldTo pointing to LinkedEntity primary key (fieldFrom), but there are more possibilities. (See below.)
  • Alias defines an (optional) new name for the linked entity.
  • Link-type specifies various ways of joins - traditionally inner (default) and outer.

Besides this, the linked entity may contain attributes and conditions just like the base entity.

Link examples

This fetch outputs a list of contacts (represented by their full name):

<fetch>
	<entity name="contact">
		<attribute name="fullname"/>
	</entity>
</fetch>

If we want to add information about the parent account, we can make use of the lookup contact.parentcustomerid -> account.

For example, the following fetch returns two columns from two different entities: (contact.fullname, account.name).

<fetch>
	<entity name="contact">
		<attribute name="fullname"/>
		<link-entity name='account' from='accountid' to='parentcustomerid' link-type='inner'>
			<attribute name="name"/>
		</link-entity>
	</entity>
</fetch>

The link is inner, which means that standalone contacts (those without a parent account) are not returned. If we also want those contacts, we can use the outer link:

<fetch>
	<entity name="contact">
		<attribute name="fullname"/>
		<link-entity name='account' from='accountid' to='parentcustomerid' link-type='outer'>
			<attribute name="name"/>
		</link-entity>
	</entity>
</fetch>

This time, we get:

  • previous results (contact.fullname, account.name)
  • new results (contact.fullname, null) representing standalone (disconnected, unassociated) contacts.

The above cases are lookup-based, i.e., they use existing relation contact->account. (Relational theory uses the term N:1 relation contact:account as 1 account has N contacts.)

However, we may pose a reverse problem: Get accounts + their contacts. We have to use the same relation as above, but in reverse order: account:1 <- contact:N. We refer to this situation as a reverse lookup. We'll subsequently discuss the two possible fetches - inner and outer.

<fetch>
	<entity name="account">
		<attribute name="name"/>
		<link-entity name='contact' from='parentcustomerid' to='accountid' link-type='inner'>
			<attribute name="fullname"/>
		</link-entity>
	</entity>
</fetch>

The results look like this:

	(account1.name, contact11.fullname)
	(account1.name, contact12.fullname)
	(account1.name, contact13.fullname)
	...
	(account2.name, contact21.fullname)
	(account2.name, contact22.fullname)
	...

We see that one account is returned N times - once per each account's contact. Note that accounts not having any contact at all are not returned. If we want those as well, we have to use the outer link:

<fetch>
	<entity name="account">
		<attribute name="name"/>
		<link-entity name='contact' from='parentcustomerid' to='accountid' link-type='outer'>
			<attribute name="name"/>
		</link-entity>
	</entity>
</fetch>

Then we also get "orphaned" accounts (accounts with no contacts):

	(account3.name, null)

Let's show another example demonstrating a bit unorthodox use of outer link ("accounts without contacts"):

<fetch>
   <entity name='account'>
	  <attribute name='name' />
	  <link-entity name='contact' from='parentcustomerid' to='accountid' link-type='outer' alias='C'/>
	  <filter type='and'>
		 <condition entityname='C' attribute='parentcustomerid' operator='null'/>
	  </filter>
   </entity>
</fetch>

Links over non-lookup fields

Tables are typically linked over lookup fields (i.e., using existing relations), but this is just a convention. Links over non-lookup fields (fields not taking part in any defined relationship) are possible as well, provided some "natural" conditions are met:

  • Joined fields should have the same type. (Otherwise, type conversion may have a performance impact.)
  • These column types cannot be used in from/to attributes: File, Image, MultiSelect Field, PartyList. (Reason: specific column implementation.)
  • Some columns have poor performance: Long texts, calculated fields, and logical (external) columns.

Here is a bit artificial example:

<fetch>
	<entity name='account'>
		<attribute name='name' />
		<attribute name='websiteurl' />
		<link-entity name='contact' from='fullname' to='name' link-type='inner'>
			<attribute name='jobtitle' />
		</link-entity>
	</entity>
</fetch>

One remark about performance: Unlike lookups (which are indexed on Dataverse), evaluating these criteria requires a table scan and might cause performance issues—at least on large tables.

Fetch results

In the case of simple fetches (not containing links), the results are base entity rows matching fetch conditions. Each row is represented by the values of requested attributes, e.g.:

<fetch>
	<entity name='account'>
		<attribute name='name' />
		<attribute name='websiteurl' />
		<filter type="and">
			<condition attribute="statuscode" operator="ne" value="2"/>
		</filter>
	</entity>
</fetch>

The ID column (primary key) of the base entity is always returned - even if not requested.

If the fetch contains links, the fetch returns a cartesian product of

  • Base entity rows (those matching the fetch)
  • Linked entity rows (those matching base entity row)

Again, the linked entity columns (attributes) returned are those specified in the fetch. Notice that the linked entity's ID is not added automatically.

Reverse links

If the linked entity is connected over base entity lookup (i.e., LinkEntity.From points to the Primary Key), we have a 1:1 link, and the linked entity can be considered an extension of the base entity. Results always differ in this case (because of included base entity ID).

If, however, LinkEntity.From points to a field that is not a Primary Key, we have a 1:N link, and the results may have duplicates—depending on which attributes of the linked entity are requested. (Although they ALWAYS differ on the server due to the ID of the linked row.)

A typical example is a parent entity linking child records, such as

<fetch>
	<attribute name="name/>"
		<entity name="queue">
		<link-entity name="queueitem" from="queueid" to="queueid"/>
	</entity>
</fetch>

The above fetch would return each queue record multiple times, once for each of its associated queueitem child records. To prevent returning equal results, you may use the fetch with the DISTINCT keyword: <fetch DISTINCT='true'>.... Fetching distinct records has the following impacts:

  • Decreased web transfer
  • Decreased client work
  • Increased (sometimes substantially) server work

Sync Filter specifics

Sync Filter represents the conditions that need to be matched for given server records to be downloaded to the client. It is expressed as a fetch. If the sync filter is undefined, then the default filter is used: "Get all records".

Sync Filter does not have attributes. Resco Mobile CRM automatically supplies all attributes of the base entity as defined in the client customization. The ID attribute assures that two base entity records always differ.

Sync Filter may have linked entities. Their attributes are never returned. Hence, links serve only for match testing (filtering).

If the sync filter contains a reverse link, the SyncEngine automatically generates a DISTINCT fetch to avoid potential duplicates. This can negatively affect performance. (Reverse link is a link based on 1:N relation, i.e., on a reverse lookup.)

Performance considerations

The execution of a simple query (not containing links) depends on whether a suitable index is available.

  • In the worst case, the database has to do a table scan (testing filter match row by row). The number of operations is directly proportional to the number of records.
  • In a case where a good index is available, the number of operations for N records is proportional to log2(N).

Example: Imagine the database table PhoneDirectory with one million entries and the index idx_PhoneDirectory_Name.

CREATE TABLE PhoneDirectory (
	Address TEXT,
	Name TEXT,
	Phone TEXT,
)

CREATE INDEX idx_PhoneDirectory_Name ON PhoneDirectory(Name);

SELECT * WHERE (Name='John Smith')			// 20 operations (ops)
SELECT * WHERE (Phone='0905 918065')		// 500K ops => 25000 x slower

According to ChatGPT, "Lookup columns in Dataverse are automatically indexed." Resco Mobile CRM respects this rule in the device's local database.

  • Lookups are fast, they require log2(N) ops.
  • Joins over lookups are "fast" as well; they require the same number of operations.

Imagine a simple query: Give me table rows such that (something).

  • Example: Get active accounts (As a fullsync query, this is a table scan; it returns nearly all visited records. The query is fast but returns many results.)
  • Example: Get accounts whose versionnumber is larger than x (As an IncSync query, it is highly selective. If a versionnumber index exists, the query will be fast - index seek.)

The performance of this query (Q1) is between log2(N) and N.

  • log2(N) if there exists an index suitable for the evaluation of the condition "(something)", and
  • N ops if no such index is found.

We are simplifying here. What actually matters is the number of visited (tested) rows per returned result. The estimates above apply to the worst case of highly selective queries.

The next example includes a linked query1:

Give me table1 rows such that (something1) (Q1)
and table1_row.lookup points to a table2 row such that (something2)
For example: Get salesorderdetails such that manualdiscountamount>0 AND parent_salesorder.totalamount > 1000

The performance estimate for the first part of the query (Q1) is the same as before: between log2(N1) and N ops. However, with each record satisfying (Q1), we have to do additional steps:

  • Find row2 from the Table2 - this requires log2(N2) operations.
  • Execute (something2) test.

Given these numbers, we could estimate overall performance to be between log2(N1)*log2(N2) and N1*log2(N2).

Note: The sequence of steps above is called a query plan. Many query plans might lead to the correct results. The SQL server query planner is responsible for selecting the most efficient one.

Cost of reverse lookups

Next, imagine a linked query2 that uses a reverse lookup.

Give me Table1 rows such that (something1) (Q1)
such that there is a table2 row fulfilling (something2)
which has a lookup table2_row.lookup -> table1_row
For example: Get salesorders with totalamount > 1000 which have child_salesorderdetail with manualdiscountamount>0

It is difficult to estimate query2 performance. This is caused by a tree-like space of possible solutions:

table1_row
	<--- table2_row1
	<--- table2_row2
	<--- table2_row3
	...

Let's only say that the performance of these queries is lower (sometimes much lower). The details are too difficult to present here.

Here is a real-life example coming from field service: Get "MyAccounts".

Get accounts
	used as billing account by some workOrder
		which has a booking
			assigned to me

Here is the link sequence (notice the two reverse lookups): account <- msdyn_workorder <- bookableresourcebooking -> bookableresource (userid == MyUserid)

Traversed records:

account
	<--- workOrder1
		<--- booking1 -> bookableresource1: Test(userid)
		<--- booking2 -> bookableresource2: Test(userid)
		<--- booking3 -> bookableresource3: Test(userid)
		...
	<--- workOrder2
		<--- booking1 -> bookableresource1: Test(userid)
		...

Hidden costs of the security model

The security model further complicates things. If you send the query get records that fulfill something, the server actually executes a more complex query: Get records that fulfill something AND (you_have_read_access_to_the_record OR the_record_was_shared_to_you).

read_access_test

  • Problem: Do you or one of your teams have a role with read access to a given record?
  • In theory, this means traversing a lot of system tables (team, teammembership, role, systemuser, principal, roleprivileges, systemuserroles).
  • In practice, Dataverse maintains a security cache allowing for faster checks.
  • At the time of this writing, this cache was represented by a single table called systemuserbusinessunitentitymap.
  • The problem is if this table is huge. Some customers report up to 50M records. This consumes 3G+ of server RAM, ~10G with indexes. The cost of search (log2(50M) = 27) is a lesser risk.
  • In some situations the access test may be simplified (for example, owningbusinessunitid==?) or even disappear completely (for entities with general read access).

sharing_test

  • Testing whether a given record resides in the POA table (the table containing all shares) with at least read permissions for the user issuing the query.
  • Possible problems: Large POA table (10M+ records) or many entries for a given entity.
  • As before, sharing_test brings some performance penalty, but the highest risk might be increased memory pressure due to this table's caching. For entities with general read access, the sharing test is skipped altogether.

Security tests are executed on all tables used by the fetch. This example demonstrates the reason: Imagine I have access to the Employee table but can't read the Salary table. If links were not tested for security, I could issue queries such as "Get employees with salary = 2000."

Which security model is good? A model that simplifies your fetches as much as possible (while maintaining the level of security you need). Extreme examples:

  • You define granular business units and let mobile users download all the data they see. (Security replaces sync filters.)
  • Entities with general read access: Security tests are skipped completely. (Sync filters are responsible for security, too.)

What are some of the performance risks related to security?

  • Too complex role-based security
  • Heavy use of sharing
  • Number of links in the query

How does the risk manifest?

  • Security typically adds some (usually minor) penalty to the query execution.
  • In the worst case, security checks require a lot of memory, which is missing for query execution. Increased disk i/o may then lead to server timeouts.

How to mitigate the risks?

  • POA maintenance: Don't let it get too large.
  • Simplify security: Give overall read access to child entities (resco_question!) or insensitive entities.
  • Simplify fetches: Reduce query links.

Major performance risks

As a rule of thumb, a bad query is one that uses the hard drive a lot. Here are some of the reasons:

  • Size of data being traversed: Total size of all tables involved in the query, including security cache and POA.
  • Query complexity: conditions (especially if they force table scan), links (especially reverse), sorting
  • Highly selective queries that require table scan (no suitable index exists, many rows visited to produce one result)
  • Complex security
  • Heavy sharing

New link types (In, Exists)

Historically, FetchXML supported only inner/outer links. We discussed them above, hence just a brief summary:

  • They represent SQL joins.
  • The result is a cartesian product of joined rows.
  • Fetch specifies which attributes of the joined rows are to be returned. The server always returns the ID of the base record, even if not requested.
  • Attributes of the joined entity may or may not be requested.
  • In the latter case, joins are used for filtering only, and fetch might return duplicates (for joins using reverse lookups).
  • The only difference between inner/outer links: Outer joins also return base entity rows without any joined row. (Joined row attributes are represented as null.)

Performance point of view:

  • Joins over standard lookups do not represent any risk. Their cost is log2(N), and they enable linear traversing of data.
  • Joins over reverse lookup represent 1:N relation and introduce branching, i.e., data needs to be traversed as a tree. Such joins are costly and may produce duplicates.

In the next part, we'll be talking about In/Exists link types. Microsoft introduced even more new link types, but:

  • They don’t appear to be very useful.
  • Some of them (such as the matchfirstrowusingcrossapply link type) are unsupported by SQLite (the database engine used in the Resco Mobile CRM).

EXISTS/IN link types

These two link types were introduced to Resco Mobile CRM in the release 18.0.

  • The links are variants of the INNER link type using different conditions (Exists/In).
  • They are implemented via SQL subqueries.
  • The query does not return duplicates. Therefore, sync does not need to add DISTINCT.
  • The attributes of the linked entity cannot be used outside of <link-entity> node, i.e., in filters or sorting. They cannot be returned by the parent query.

In the existing projects, which sync filter links could be potentially replaced by In/Exists?

  • Inner links whose attributes (of the linked entity) are not used outside of those links.
  • Inner links over lookups: While In/Exists could formally work (if the first point is fulfilled), we would gain nothing. (Probably a slower query.) Only inner links representing 1:N conditions make sense (reverse lookups, LinkedEntity.From is not Primary Key).

Performance gains:

  • Exists/In might return less data than Inner/Outer (because they skip duplicates)
  • They might do less work in the server database because the loop testing multiple matching rows is interrupted at the first match.

The meaning of In/Exists link types is defined by their SQL translation.

EXISTS Returns TRUE if the subquery returns any results. An example follows:

// Fetch using Exists link type
// Get contacts which act as a primary contact for some account
<fetch>
   <entity name='contact'>
	  <attribute name='fullname' />
	  <link-entity name='account' from='primarycontactid' to='contactid' link-type='exists'>
		 <filter type='and'>
			<condition attribute='statecode' operator='eq' value='1' />
		 </filter>
	  </link-entity>
   </entity>
</fetch>

// SQL translation (2nd row represents the subquery)
SELECT fullname FROM contact AS C WHERE EXISTS (
	SELECT 1 FROM account AS A WHERE A.statecode=1 AND C.contactid=A.primarycontactid
	)

IN Returns TRUE if the value of the tested expression ("to" value) is equal to any value returned by the subquery. An example follows:

// Fetch using In link type
// Same meaning as above fetch, although a different SQL translation is produced.
<fetch>
   <entity name='contact'>
	  <attribute name='fullname' />
	  <link-entity name='account' from='primarycontactid' to='contactid' link-type='in'>
		 <filter type='and'>
			<condition attribute='statecode' operator='eq' value='1' />
		 </filter>
	  </link-entity>
   </entity>
</fetch>

// SQL translation
SELECT fullname FROM contact AS C WHERE C.contactid IN (
	SELECT A.primarycontactid FROM Account as A WHERE A.statecode=1
)

Although we have not discussed it so far, inner/outer links correspond to SQL joins. (They are translated as SQL joins, too.) A natural question arises - which one of the two techniques is better?

SQL dilemma: Join vs subquery

Subqueries (also called inner queries or nested queries) involve a SELECT statement embedded in another SELECT statement, typically in its WHERE clause.

Joins are used to combine rows from two or more tables based on a related column.

According to SQL Server documentation:

"Many T-SQL statements with subqueries can be alternatively formulated as joins. There's usually no performance difference between a statement using a subquery and a semantically equivalent version that doesn't. However, in some cases where existence must be checked, a join yields better performance."

In/Exists usage - Summary

A link can be replaced by In/Exists if:

  • It is an inner link.
  • LinkEntity has no attributes and no sort order.
  • Linked entity columns are not used outside of LinkEntity. (One way to force this is to use a null alias.)

Effects of In/Exists links:

  • Fetch->SQL translation uses a subquery instead of a join.
  • Sync fetches don't use DISTINCT keyword. (There is no need to do so.)

Recommendation:

  • Use In/Exists for 1:N links only, i.e., for links over a reverse lookup. Intuitively, this might be the only case where it's less work for the SQL server. (Because the traversal of multiple branches is stopped when the first result is found.)
  • In general, there are not too many sync filter candidates for using IN links, but all of them belong to fetches that might have performance problems.
  • Note, however, that the final decision on which of the two competing technologies (join or subquery) is selected remains at the SQL server, more precisely at its query planner.

Real-life example

This was a real-life example that proved the usefulness of IN links. The customer showed that the following fetch (used in IncSync with Incremental Sync Filter) results in server timeout if the booking link is INNER, but executes very fast for link-type IN.

// Slightly simplified FetchXml syntax
<fetch distinct="true">
	<entity name="msdyn_workorderincident">		// 5.7M recs, 17 incidents/order
		<condition attribute="statuscode" operator="in" values="Active, Complete" />
		<condition attribute="versionnumber" operator="ge" value="331647639" />		// since the last sync

		<link-entity name="msdyn_workorder" to="msdyn_workorder" from="msdyn_workorderid">		// 330K recs, 1:1, 457 WOs/user
			<condition attribute="statecode" operator="eq" value="0" />
			
			<link-entity name="bookableresourcebooking" from="msdyn_workorder"  to="msdyn_workorderid" link-type="IN">		// 440K recs, 1:N
				<condition attribute="starttime" operator="on-or-after" value="@@TODAY-30" />
				
				<link-entity name="bookableresource" from="bookableresourceid" to="resource">	// 722 recs
					<condition attribute="userid" operator="eq" value="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
				</link-entity>
			</link-entity>
		</link-entity>
	</entity>
</fetch>

If you don't feel like parsing the example, here's a human-readable description. We only get records that have changed since the last sync (version number >= 331647639) and that match the sync filter. And the sync filter is, "get (active/complete) incidents related to active work orders where I have recent booking (= MyBookings)".

The fetch returns 7430 results. Here is the sequence of links involved together with entity cardinalities:

msdyn_workorderincident(5.7M recs) -> msdyn_workorder(330K) <- bookableresourcebooking(440K) -> bookableresource(722)

Reverse lookup (2nd link) introduces branching into data traversal. (Which requires DISTINCT fetches if inner links are used.)

The point to note is that the MyBookings subquery reduces the number of records from 440K to a few hundreds. (One technician has at most 100s of relevant bookings.) So introducing IN link-type (which is based on MyBookings subquery) changes the query this way:

msdyn_workorderincident(5.7M) -> msdyn_workorder(330K) WHERE msdyn_workorderid is referred by MyBookings(O(100))

This greatly reduces the amount of data traversed during query execution and increases thus query performance.

Generalization for field service

The fetch above comes from a field service app built around a concept that seems to be particularly suited for applying IN links. Let's start by explaining this (MyBookings-centric) concept. The main idea is that the client (mobile user) needs his jobs (bookableresourcebooking's) + their related records (work order, accounts, ...).

Here is a typical example:

MyBookings: My active bookings (bookableresourcebooking) scheduled within some period

	- MyWorkOrders: workOrders (msdyn_workorder) related to MyBookings
		-- MyServiceTasks: serviceTasks (msdyn_workorderservicetask) belonging to MyWorkOrders
		-- MyIncidents: relevant incidents (msdyn_workorderincident) related to MyWorkOrders
			--- MyAssets: assets (msdyn_customerasset) where MyIncidents were reported

		-- MyAccounts: accounts related to MyWorkOrders
		...

Different customizations may use different entities, but the principle stays the same. In a typical business, all tables involved will be large (or even huge), but the number of bookings per user is usually in the range of hundreds, and there's a similar number of related work orders, accounts, incidents, etc. The database schema projection is small and dynamic: bookings change every day.

Sync fetches are very complex. ("Get assets related to incidents related to work orders related to bookings which are assigned to me during next 2 weeks" - 4+ links.) But, all of them use the MyBookings link that has a small result set and uses a reverse link. So the bookings link really looks like a situation when link-type IN should excel.

(Work order link is another candidate for IN link-type, but in this case, we would not get rid of the reverse lookup.)

These considerations might be valid for many Field Service fetches - all those based on MyBookings. (MyVisits, MyAppointments...)

Again, the final verdict is on whether the SQL server query optimizer discovers the optimal query plan. (Our usage of the IN link type is nothing more than a recommendation.)

SQLite tests

(This is for techies who want to understand the impact on the database level.)

Here is a test comparing the performance of join vs subselect. You can subsequently see

  • SQL command
  • SQLite query plan
  • performance
SELECT fullname FROM contact inner JOIN account AS A ON (contact.pe_accountid = A.accountid AND (A.statuscode = ?) )
0 | 0 | 1 | SCAN TABLE account AS A
0 | 1 | 0 | SEARCH TABLE contact USING INDEX idx_contact_pe_accountid (pe_accountid=?)
INNER LINK: 451947 recs in 21945ms (2nd trial 18264ms)
SELECT fullname FROM contact WHERE pe_accountid IN (SELECT A.accountid FROM account AS A WHERE A.statuscode=1)
0 | 0 | 0 | SEARCH TABLE contact USING INDEX idx_contact_pe_accountid (pe_accountid=?)
0 | 0 | 0 | EXECUTE LIST SUBQUERY 1
1 | 0 | 0 | SCAN TABLE account AS A
IN LINK: 451947 recs in 19431ms (2nd trial 19900ms)

As you see, in this case, both queries showed about the same performance, although they used different query plans. We had the same experience with several other queries.

Another interesting observation: As we increased the database cache size from 80M to 1.6G, the performance changed dramatically:

  • INNER LINK: 451947 recs in 2708ms
  • IN LINK: 451947 recs in 3920ms

For yet another query, INNER performance was

a) the same as IN performance for small DB cache,
b) much worse for medium database cache,
c) again, the same for large database cache. The performance in this case was much higher than in case a), of course.

This suggests that

  • Disk i/o plays a dominant role in the performance.
  • Queries containing INNER LINK need more memory and are thus more sensitive towards memory stress.

We just saw that despite being equivalent, INNER/IN queries lead to different query plans, which might perform better or worse depending on the database configuration.