tltoulson
Kilo Sage

Via: Code Creative

In a troubleshooting conversation with a couple of my teammates, I realized that I take GlideRecord (and its partner GlideAggregate) for granted.   When you make that simple gr.query() call, what happens?   What SQL does it run?   What does the real underlying table structure look like?   Are there indexes?   Are additional queries run when you dot walk?   Does any of this hit a cache?   How does the cache work?   Where are the optimizations under the hood?

The truth is, most of us have no idea what happens and most of the time that is really awesome!   If the queries run fast and returns accurate data we usually don't care how ServiceNow made it happen.

But when performance issues start to pile up on that scripted REST endpoint and all you're doing is generating JSON from GlideRecords... well, what you don't know could be killing your instance.   The Nested GlideRecord silently drags down your app's response times, killing usability and the first step to fix it is to learn how to identify it.

The Basic Pattern

A Nested GlideRecord is when you run a GlideRecord query within the while loop of another GlideRecord query, like this:

var gr1 = new GlideRecord('any_table');
gr1.query();
while (gr1.next()) {
  var gr2 = new GlideRecord('any_table');
  gr2.query();
  while (gr2.next()) {
    // Do something here
  }
}

Now, take a look at that script.   How many times does that script query the database?   The correct answer:   I have no idea and neither do you.   We don't know how GlideRecord works exactly.   What we do know is that in the worst case it will run one query for gr1.query() and an additional query for each record in gr1 (gr2.query() inside the while loop).

The defining feature of this performance killer is using many smaller queries when one larger query could retrieve the same data.

A Crude Scenario

So lets take a look at a somewhat real world script to get an idea of why this is such a problem.   Lets try to find and output a list of duplicate user records.   We'll keep it simple and assume the first record found is the original.   To do this, we need to:

  1. Get a list of usernames with a count greater than 0
  2. Find all user records with those usernames
  3. Update all but the first with a duplicate flag

For this test, I will compare 2 approaches:   Nested GlideRecord and an Array Flattened GlideRecord.

Here is a crude Nested GlideRecord approach:

var count = new GlideAggregate('sys_user');
count.addQuery('email', '!=', '');
count.groupBy('email');
count.addAggregate('COUNT');
count.query();
while (count.next()) {
  if ((count.getAggregate('COUNT') * 1) > 0) {
    var user = new GlideRecord('sys_user');
  user.addQuery('email', count.email + '');
  user.query();
  user.next(); // skip the first
    while (user.next()) {
  gs.print(user.sys_id);
    }
  }
}

And the Array Flattened GlideRecord:

var users = [];

var count = new GlideAggregate('sys_user');
count.addQuery('email', '!=', '');
count.groupBy('email');
count.addAggregate('COUNT');
count.query();
while (count.next()) {
  if ((count.getAggregate('COUNT') * 1) > 0) {
  users.push(count.email + '');
  }
}

var user = new GlideRecord('sys_user');
var prevUser = '';
user.addQuery('email', 'IN', users.join(','));
user.orderBy('email');
user.query();
while (user.next()) {
  if (prevUser == user.email + '') {
  gs.print(user.email);
  }
  // Else, skip the original user
  prevUser = user.email + '';
}

Running these two scripts in the background, I was able to achieve some poor man's stats after a few manual runs with 1144 user records and 572 duplicates:

  • Nested GlideRecord: ~0.795 seconds to execute
  • Array Flattened GlideRecord: ~0.145 seconds to execute

Now this is a pretty extreme example that will more likely be executed as background script or scheduled job rather than a user facing transaction.   But that performance difference between the nested and flattened scripts starts to add up when multiplied by the number of users hitting your site and the depth and frequency of nested GlideRecords in your scripts.   Pretty soon, you might find your database running a query every 60 seconds (yep, that actually happened on a different client).

Dot Walking Has The Same Results

The scary part about this pattern is that it easily hides itself.   Dot walking, for example, works like magic to retrieve related data.   Under the hood, it runs a query at least once to retrieve the reference (I assume the reference is cached for subsequent calls in a given script if not the entire transaction but I'm not sure).

So the following script could inadvertently cause the same issue as the previous example (and in the originally mentioned REST script, I believe this was a contributing factor):

var gr = new GlideRecord('sys_user');
gr.query();
while (gr.next()) {
  var department = gr.department.name; // Reference Field!
}

That Department is a reference field, so ServiceNow has to run a separate GlideRecord query behind the scenes to retrieve the data for that department.   All magic comes at a price, deary!

Or How About ACL's

Running a GlideRecord query in an ACL yields the same effect.   In this case, its subtle but still there.   If you run a query on the incident table for example, an ACL will be applied to (and the script run for) each record in the resulting set.   For each and every record in your resulting set, a narrowly defined GlideRecord query will execute to find out if the user can see the record!   (This by the way was one of the primary causes behind the 60 second queries).

Bottom Line

Here's the too long didn't read:   Avoid using 20 narrowly defined GlideRecords when with a little work you can combine it into a single broader GlideRecord.   Watch out for the same pattern in ACLs, dot walking, and other ServiceNow scripts.   This pattern likes to hide itself.   Another time, we'll take a look at some strategies to fix this.

9 Comments
Brad Tilton
ServiceNow Employee

Great post and bottom line!


karthik73
Mega Guru

An eye opener on the Glide Record usage and performance!


danielkahle
Tera Contributor

Great 

Thank for the eye-opener

jonGriff
Tera Expert

"Another time, we'll take a look at some strategies to fix this." - Part 2? 🙂

mikedeng
ServiceNow Employee

Dot walking does not have the same result as nested query as today, the dot walking sample code now issues two queries like

SELECT sys_user0.`country`, sys_user0.`calendar_integration`, sys_user0.`last_position_update`, sys_user0.`user_password`, sys_user0.`last_login_time`, sys_user0.`last_login_device`, sys_user0.`source`, sys_user0.`sys_updated_on`, sys_user0.`building`, sys_user0.`web_service_access_only`, sys_user0.`notification`, sys_user0.`sys_updated_by`, sys_user0.`enable_multifactor_authn`, sys_user0.`sys_created_on`, sys_user0.`sys_domain`, sys_user0.`agent_status`, sys_user0.`state`, sys_user0.`vip`, sys_user0.`sys_created_by`, sys_user0.`longitude`, sys_user0.`zip`, sys_user0.`home_phone`, sys_user0.`time_format`, sys_user0.`accumulated_roles`, sys_user0.`last_login`, sys_user0.`default_perspective`, sys_user0.`geolocation_tracked`, sys_user0.`active`, sys_user0.`time_sheet_policy`, sys_user0.`last_password`, sys_user0.`sys_domain_path`, sys_user0.`phone`, sys_user0.`cost_center`, sys_user0.`name`, sys_user0.`employee_number`, sys_user0.`password_needs_reset`, sys_user0.`gender`, sys_user0.`city`, sys_user0.`user_name`, sys_user0.`failed_attempts`, sys_user0.`edu_status`, sys_user0.`latitude`, sys_user0.`title`, sys_user0.`sys_class_name`, sys_user0.`sys_id`, sys_user0.`internal_integration_user`, sys_user0.`ldap_server`, sys_user0.`mobile_phone`, sys_user0.`street`, sys_user0.`company`, sys_user0.`department`, sys_user0.`first_name`, sys_user0.`preferred_language`, sys_user0.`introduction`, sys_user0.`email`, sys_user0.`manager`, sys_user0.`locked_out`, sys_user0.`sys_mod_count`, sys_user0.`last_name`, sys_user0.`photo`, sys_user0.`avatar`, sys_user0.`middle_name`, sys_user0.`time_zone`, sys_user0.`schedule`, sys_user0.`on_schedule`, sys_user0.`date_format`, sys_user0.`location` FROM sys_user sys_user0 /* giraffe, gs:D6CDB2D173F1001051FA56F77BF6A7FF, tx:31ce76517331001051fa56f77bf6a712 */

SELECT cmn_department0.`parent`, cmn_department0.`code`, cmn_department0.`sys_mod_count`, cmn_department0.`description`, cmn_department0.`head_count`, cmn_department0.`sys_updated_on`, cmn_department0.`business_unit`, cmn_department0.`sys_id`, cmn_department0.`dept_head`, cmn_department0.`sys_updated_by`, cmn_department0.`cost_center`, cmn_department0.`sys_created_on`, cmn_department0.`name`, cmn_department0.`company`, cmn_department0.`id`, cmn_department0.`primary_contact`, cmn_department0.`sys_created_by` FROM cmn_department cmn_department0 WHERE cmn_department0.`sys_id` IN ('5d7f17f03710200044e0bfc8bcbe5d43' , '9a7ed3f03710200044e0bfc8bcbe5db7' , '221db0edc611228401760aec06c9d929' , '221f3db5c6112284009f4becd3039cc9' , '93b25282c0a8000b0b55c8ab34e2f1e6' , '221f79b7c6112284005d646b76ab978c' , 'a581ab703710200044e0bfc8bcbe5de8') /* giraffe, gs:D6CDB2D173F1001051FA56F77BF6A7FF, tx:31ce76517331001051fa56f77bf6a712 */
 

 

Max Nowak
Kilo Sage

Thanks for this post, I was just wondering the same. I frequently use what you described as "Array flattened GlideRecord", but always wondered if it was really more effective, or if I could just nest GR queries.

Max_MS
Tera Contributor
vvla7
Tera Explorer

I'm not convinced.

Your approach forced a massively higher amount of queries in the nested approach, because you query when count is > 0 but throw away the first result, if you only query when count is > 1 you reduce the amount of queries massively for the same end result.

if ((count.getAggregate('COUNT') * 1) > 0) 
..........
   user.next(); // skip the first


Secondly, dotwalking does not seem to exhibit the same behavior, I quickly tried testing this on my PDI with about 2000 users and 900 duplicates.

Flat Queries AVG execution time over 10 runs: 88.4ms
Dot Walked AVG execution time over 10 runs: 72.5ms
Nested Queries AVG execution time over 10 runs: 404.6ms


The simplest dotwalked query was consistently the best performing, feel free to try.

var out = [];
var runs = 10;

function measure(func, name, runs) {
	runs = runs || 1;
	var runtimes = [];
	for (var i = 0; i < runs; i++) {
		var start = Date.now();
		func();
		runtimes.push(Date.now() - start);
	}
	var sum = runtimes.reduce(function(acc, val) {
		return acc + val;
	}, 0) / runs;

	out.push(name + ' AVG execution time over ' + runs + ' runs: ' + sum + 'ms');
}

measure(usersFlat, 'Flat Queries', runs);
measure(usersDotWalked, 'Dot Walked', runs);
measure(usersNested, 'Nested Queries', runs);

function usersFlat() {
	var users = [];
	var _out = [];
	_out.push('UsersFlat\n');

	var count = new GlideAggregate('sys_user');
	count.addQuery('email', '!=', '');
	count.groupBy('email');
	count.addAggregate('COUNT');
	count.query();
	while (count.next()) {
		if ((count.getAggregate('COUNT') * 1) > 1) {
			users.push(count.email + '');
		}
	}

	var user = new GlideRecord('sys_user');
	var prevUser = '';
	user.addQuery('email', 'IN', users.join(','));
	user.orderBy('email');
	user.query();
	while (user.next()) {
		if (prevUser == user.getValue('email')) {
			_out.push(user.getValue('email'));
		}
	// Else, skip the original user
		prevUser = user.getValue('email');
	}

	out.push('Total duplicate users:' + _out.length);
}

function usersDotWalked() {
	var _out = [];
	_out.push('UsersDotWalked\n');

	var user = new GlideRecord('sys_user');
	user.addNotNullQuery('email');
	user.orderBy('email');
	user.query();
	var prevUser = '';
	while (user.next()) {
		if (prevUser == user.getValue('email')) {
			_out.push(user.getValue('email'));
		}
	// Else, skip the original user
		prevUser = user.getValue('email');
	}

	out.push('Total duplicate users:' + _out.length);
}

function usersNested() {
	var _out = [];
	_out.push('UsersNested\n');
	var count = new GlideAggregate('sys_user');
	count.addNotNullQuery('email');
	count.groupBy('email');
	count.addAggregate('COUNT');
	count.query();
	while (count.next()) {
		if ((count.getAggregate('COUNT') * 1) > 1) {
			var user = new GlideRecord('sys_user');
			user.addQuery('email', count.getValue('email'));
			user.query();
			user.next(); // skip the first
			while (user.next()) {
				_out.push(user.getValue('email'));
			}
		}
		}
	
	out.push('Total duplicate users:' + _out.length);
	}

function createDuplicateUsers(count) {
	for (var i = 0; i < count; i++) {
		var duplicates = Math.floor(Math.random() * 7);
		var email = i + 'test@email.test'
		for (var j = 0; j < duplicates; j++) {
			var gr = new GlideRecord('sys_user');
			gr.initialize();
			gr.user_name = 'duplicate_user_' + Date.now().toString().slice(-9) + j;
			gr.first_name = 'test';
			gr.last_name = 'test';
			gr.email = email;
			gr.insert();
		}
		i += (j - 1);
	}
}


gs.info('\n' + out.join('\n'));



tltoulson
Kilo Sage

@vvla7
You are correct that your solution to the specific example provided would be expected to perform better.  It uses fewer queries to achieve the same goal, which is the point of the article.  You may however come across use cases where your dot-walked approach will not generalize (querying a set of records and associating it with a summary list of related record aggregates for example).  At that point, please remember the array flattened approach because as your test shows, the array flattened approach will perform better than Nested GlideRecords and it generalizes well to other use cases.

 

Also, please keep in mind that ServiceNow may cache the results of GlideRecord queries.  So, if you use a for loop to execute multiple iterations of a performance test as you have done, the first iteration might be the only one hitting the database and you may be testing the performance of ServiceNow's caching layer.  So, if you are specifically trying to test the performance of a first pass query, you may need to manually execute your iterations with a call to cache.do in between.