Content Modeling and the Performance of Traversing a SQL Hierarchy

Sub Heading Comes Here

The Performance of Traversing a SQL Hierarchy

Skip to the performance graphics near the end if you want the guts of what this is really all about, which in summary can be stated that an ImageNow Content Model design would eventually have a breaking point. Approximately (8-13 Levels Deep) where significant performance degradation would occur performance based on the IN_INSTANCE_ANCESTRY table holding ALL hierarchy data.

The examples below are not a direct representation of the IN_INSTANCE_ANCESTRY but certainly represent the data model in the ImageNow 6.7 data schema and how the ImageNow Server stores Content Model data.

It is well known, and blogged to death, that the way to traverse a hierarchy that is stored in a T-SQL table as an adjacency list, is to use a recursive Common Table Expression (rCTE). But what if there was a faster way of traversing an adjacency list? Would you use it?

The adjacency list approach to saving a hierarchy is quite common and can represent:

  • Reporting levels between managers and their successive employees.

  • A parts hierarchy, particularly the case of parts in a kit with the kit/assembly having multiple levels. Another example is parts succession, where older parts are replaced by newer ones.

  • A Bill of Materials (BOM).

  • Hospital System -> Institution -> EMR Name -> Special Security Allowance -> Patient MRN -> Billing Encounter and/or Clinical Encoutner

Today we’ll look at various ways a hierarchy represented as an adjacency list can be traversed and compare the elapsed times for each method.

Data Setup – A Simple Hierarchy as an Adjacency List

Let’s create an adjacency list (ManagersDirectory) and insert a simple hierarchy into it.

CREATE TABLE dbo.ManagersDirectory (

ManagerID INT

,EmployeeID INT

,CONSTRAINT md_pk1 PRIMARY KEY (ManagerID, EmployeeID)

); GO

INSERT INTO dbo.ManagersDirectory

— Level 1 elements

SELECT 1, 10 UNION ALL SELECT 1, 20 UNION ALL SELECT 1, 30

— Level 2 elements

UNION ALL SELECT 10, 100 UNION ALL SELECT 10, 101 UNION ALL SELECT 10, 102

UNION ALL SELECT 20, 200 UNION ALL SELECT 20, 201 UNION ALL SELECT 20, 202

UNION ALL SELECT 30, 300 UNION ALL SELECT 30, 301 UNION ALL SELECT 30, 302

— Level 3 elements

UNION

ALL

SELECT

100, 1000 UNION ALL SELECT 100, 1001 UNION

ALL SELECT

100, 1002

UNION

ALL

SELECT

200, 2000 UNION ALL SELECT 200, 2001 UNION

ALL SELECT

200, 2002

UNION

ALL

SELECT

300, 3000 UNION ALL SELECT 300, 3001 UNION

ALL SELECT

300, 3002

UNION

ALL

SELECT

101, 1003 UNION ALL SELECT 101, 1004 UNION

ALL SELECT

101, 1005

UNION

ALL

SELECT

201, 2003 UNION ALL SELECT 201, 2004 UNION

ALL SELECT

201, 2005

UNION

ALL

SELECT

301, 3003 UNION ALL SELECT 301, 3004 UNION

ALL SELECT

301, 3005

UNION

ALL

SELECT

102, 1006 UNION ALL SELECT 102, 1007 UNION

ALL SELECT

102, 1008

UNION

ALL

SELECT

202, 2006 UNION ALL SELECT 202, 2007 UNION

ALL SELECT

202, 2008

UNION

ALL

SELECT

302, 3006 UNION ALL SELECT 302, 3007 UNION

ALL SELECT

302, 3008;

SELECT ManagerID, EmployeeID FROM dbo.ManagersDirectory;

The final SELECT returns all 39 elements we’ve created in our adjacency list. We put a primary key on the most common place you’d expect to find one: ManagerID and EmployeeID.

Pictorially, this hierarchy looks like this (three children reporting to each parent):

1980-13a87884-0fa3-417

Traverse the Hierarchy Using a Traditional rCTE

When we generate the traversal, we add a level counter and a string that represents the manager-to-employee reporting relationship for this hierarchy. It uses the same rCTE that you’ve seen many times before. You’ll have to excuse me for complaining about everyone rehashing this over and over, while I’m doing the same, but hopefully in the end I’ll add a little value.

DECLARE @TopLevel INT = 1 — Top level manager (node)

,@NumLevels INT = 3; — Depth of the hiearchy to traverse

WITH HierarchyTraaversal AS (

— rCTE anchor: retrieve the top level node SELECT [Level]=1, ManagerID, EmployeeID

,MgrEmp=CAST(ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory WHERE ManagerID = @TopLevel

UNION ALL

— rCTE recursion: retrieve the following nodes SELECT [Level]+1, a.ManagerID, a.EmployeeID

,MgrEmp=MgrEmp + ‘/’ + CAST(a.EmployeeID AS VARCHAR(8000)) FROM dbo.ManagersDirectory a

JOIN HierarchyTraaversal b ON b.EmployeeID = a.ManagerID WHERE [Level] < @NumLevels –+ 1

)

SELECT [Level], ManagerID, EmployeeID, MgrEmp FROM HierarchyTraaversal

ORDER BY [Level], ManagerID, EmployeeID;

This produces the fairly familiar results (39 rows), abbreviated below:

Level

ManagerID

EmployeeID

MgrEmp

1

1

10

1/10

1

1

20

1/20

1

1

30

1/30

2

10

100

1/10/100

2

10

101

1/10/101

<snip>

3

301

3005

1/30/301/3005

3

302

3006

1/30/302/3006

3

302

3007

1/30/302/3007

3

302

3008

1/30/302/3008

A Non-traditional Hierarchy Traversal in T-SQL

It has been said that T-SQL only supports recursion through a recursive CTE. Or does it? Let’s see if we can persuade SQL Server to let us create a recursive FUNCTION:

CREATE FUNCTION dbo.HierarchyTraversal (

@Levels INT

,@TopLevel INT

)

RETURNS @H TABLE (

[Level] INT

,ManagerID INT

,EmployeeID INT

,MgrEmp VARCHAR(8000)

)

AS BEGIN

IF @Levels > 0 INSERT INTO @H

— The top level manager

SELECT 1, ManagerID, EmployeeID

,CAST(ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory WHERE ManagerID = @TopLevel

UNION ALL

END

— The remainder of the hierarchy

SELECT [Level]+1, a.ManagerID, a.EmployeeID

,MgrEmp + ‘/’ + CAST(a.EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory a

JOIN dbo.HierarchyTraversal(@Levels1, @TopLevel) b ON b.EmployeeID = a.ManagerID; RETURN

Note where we reference dbo.HierarchyTraversal (the FUNCTION we are creating) in the JOIN. Thanks to the miracle of deferred name resolution we gleefully receive the message back from SQL Server Management Studio (SSMS) that our “Command(s) completed successfully.”

When we run this FUNCTION to traverse our adjacency list:

CREATE FUNCTION dbo.HierarchyTraversal (

@Levels INT

,@TopLevel INT

)

RETURNS @H TABLE (

[Level] INT

,ManagerID INT

,EmployeeID INT

,MgrEmp VARCHAR(8000)

)

AS BEGIN

IF @Levels > 0 INSERT INTO @H

— The top level manager

SELECT 1, ManagerID, EmployeeID

,CAST(ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory WHERE ManagerID = @TopLevel

UNION ALL

END

— The remainder of the hierarchy

SELECT [Level]+1, a.ManagerID, a.EmployeeID

,MgrEmp + ‘/’ + CAST(a.EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory a

JOIN dbo.HierarchyTraversal(@Levels1, @TopLevel) b ON b.EmployeeID = a.ManagerID; RETURN

We behold with wonder that the returned results are identical to those returned by the rCTE!

Note that the FUNCTION cannot be given the property WITH SCHEMABINDING, nor can it be an in-line Table Valued FUNCTION (TVF). It only works with a multi-line TVF. Another limitation is that if you ever exceed 32 levels, this function is going to throw an error.

The Method of Repeating JOINs – The standard ImageNow Server API for Looping Over Docs in a View

Another way to do this is with a series of repeating JOINs, using UNION ALL operators to combine each successive level. For our tiny hierarchy, this can be achieved as follows:

— Manager level node and its children SELECT [Level]=1, ManagerID, EmployeeID

,MgrEmp=CAST(ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory a1 WHERE a1.ManagerID = @TopLevel UNION ALL

— Children of the manager level node plus those children SELECT [Level]=2, a2.ManagerID, a2.EmployeeID

,MgrEmp=CAST(a1.ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(a2.ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(a2.EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory a1

JOIN dbo.ManagersDirectory a2 ON a1.EmployeeID = a2.ManagerID WHERE a1.ManagerID = @TopLevel

UNION ALL

— Final levels

SELECT [Level]=3, a3.ManagerID, a3.EmployeeID

,MgrEmp=CAST(a1.ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(a2.ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(a3.ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(a3.EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory a1

JOIN dbo.ManagersDirectory a2 ON a1.EmployeeID = a2.ManagerID JOIN dbo.ManagersDirectory a3 ON a2.EmployeeID = a3.ManagerID WHERE a1.ManagerID = @TopLevel;

This too returns the same results as our prior attempts. Of course, it only works if you reconstruct the query based on the number of levels you want to traverse, unless you construct the query with dynamic SQL.

DECLARE @SQL NVARCHAR(MAX) = N”

,@SQLParms NVARCHAR(MAX) = N’@TopLevel INT’

,@J INT = 1

,@J1 VARCHAR(2)

,@RC INT;

WHILE @J <= @NumLevels + 1 BEGIN

— This character string version of @J is used frequently in later string concatenations SELECT @J1 = CAST(@J AS VARCHAR);

— The Tally table must return enough numbers to handle the number of desired levels.

— @NumLevels was set earlier to 3.

WITH Tally (n) AS (

SELECT 1+ROW_NUMBER() OVER (ORDER BY (SELECT NULL))

FROM (VALUES(0),(0),(0),(0),(0),(0),(0),(0),(0),(0),(0),(0),(0),(0),(0),(0)) a(n)

)

SELECT @SQL = @SQL + ‘SELECT [Level]=’ + @J1 + ‘, a’ + @J1 + ‘.ManagerID, a’ + @J1 +

‘.EmployeeID’ + CHAR(10) +

‘ ,MgrEmp=CAST(a1.ManagerID AS VARCHAR(8000)) + ”/” + ‘ + CHAR(10) + ISNULL((

SELECT ‘ CAST(a’ + CAST(n AS VARCHAR) + ‘.ManagerID AS VARCHAR(8000)) + ”/” +’ + CHAR(10)

FROM Tally

WHERE n BETWEEN 2 AND @J FOR XML PATH(), TYPE

).value(‘.’, ‘NVARCHAR(4000)’), ) +

‘ CAST(a’ + @J1 + ‘.EmployeeID AS VARCHAR(8000))’ + CHAR(10) +

— Used in our performance test harness to suppress display of the final results

— CASE @J WHEN 1 THEN ‘INTO #Temp3’ + CHAR(10) ELSE ” END +

‘FROM dbo.ManagersDirectory a1’ + CHAR(10) + ISNULL((

SELECT ‘JOIN dbo.ManagersDirectory a’ + CAST(n AS VARCHAR) + ‘ ON a’ + CAST(N1 AS VARCHAR) + ‘.EmployeeID = a’ + CAST(N AS VARCHAR) + ‘.ManagerID’ + CHAR(10)

FROM Tally

WHERE n BETWEEN 2 AND @J FOR XML PATH, TYPE

).value(‘.’, ‘NVARCHAR(4000)’), ) +

‘WHERE a1.ManagerID = @TopLevel ‘ +

CASE WHEN @J < @NumLevels + 1 THEN CHAR(10) + ‘UNION ALL’ + CHAR(10) ELSE END;

END

SELECT @J = @J + 1;

PRINT @SQL;

— EXEC sp_executesql @SQL, @SQLParms, @TopLevel = @TopLevel;

A look at the printed SQL query should show that it is identical to the first but will grow in size and complexity depending on the number of levels (@NumLevels) you’ve set. Note the line we commented out in the middle, which we’ll use in our test harness later to dump the results to a temporary table when we do our timings.

The Set-based Loop

Since any recursive CTE can always be rewritten as a set-based loop, even though I don’t particularly like loops, we’ll give it a go nonetheless.

— The first select is equivalent to the anchor leg of the rCTE.

— It creates the temporary table we’ll store results for each iteration of the loop. SELECT [Level]=ISNULL(1, 0)

,ManagerID=ISNULL(ManagerID, 0)

,EmployeeID=ISNULL(EmployeeID, 0)

,MgrEmp=CAST(ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(EmployeeID AS VARCHAR(8000))

INTO #Temp1

FROM dbo.ManagersDirectory a1 WHERE a1.ManagerID = @TopLevel

— Add a PRIMARY KEY to hopefully make the later JOIN a bit more efficient

ALTER TABLE #Temp1 ADD CONSTRAINT t_pk PRIMARY KEY ([Level], ManagerID, EmployeeID); DECLARE @I INT = 2;

WHILE @I < @NumLevels + 1 BEGIN

— Insert each successive level into the temporary table. INSERT INTO #Temp1

SELECT @I, b.ManagerID, b.EmployeeID

,MgrEmp=MgrEmp + ‘/’ + CAST(b.EmployeeID AS VARCHAR(8000)) FROM #Temp1 a

JOIN dbo.ManagersDirectory b ON a.EmployeeID = b.ManagerID WHERE [Level] = @I 1

— This is explained below. OPTION (RECOMPILE);

END

SELECT @I = @I + 1;

SELECT [Level], ManagerID, EmployeeID, MgrEmp FROM #Temp1

ORDER BY [Level], ManagerID, EmployeeID;

Once again, a check against our expected results shows they match. Note that, since we’re using #Temp1 to JOIN back to the ManagersDirectory table, having an index (the primary key) on the temporary table should improve the performance over the case where we have only a heap.

You may have noticed the OPTION (RECOMPILE) on the query within the loop. We were concerned about parameter sniffing impacting the performance of the query. So, of course, we tested that using 02 Loop With and Without RECOMPILE.sql and concluded that the query generally runs faster with the OPTION than without it. You can try it yourself at various levels to confirm our conclusion.

The WHILE Loop Revisited

Since we’ve taken our WHILE loop on a test drive, let’s drive it all the way to Memphis and see if we can meet

Elvis. As perversely absurd as that sounds, the question that follows may seem even more so. Is it possible that our WHILE loop hierarchy traversal may be impacted by the Halloween Problem? I first learned about this and the SQL Optimizer’s Halloween protection in SQL MVP Itzik Ben-Gan’s article: Identifying a Subsequence in a Sequence, Part1, where he implemented a method of avoiding SQL’s Halloween protection in his third solution. I found the problem interesting, so I was testing that solution and was frankly amazed at the lightning speed that it processed his ten million rows of test data, literally trouncing the first set-based solutions that I could come up with. The similarity of what he was doing (INSERTs into an indexed temporary table within the loop) got me to thinking that it may be applicable here. So I read the outstanding articles he recommended by SQL MVP Paul White, who clearly showed that Halloween protection can impact INSERTs (and DELETEs and MERGEs) in addition to UPDATEs. More reading led me to another article by Itzik Ben-Gan: Divide and Conquer Halloween, where he addressed the hierarchy traversal problem directly.

His solution modified to our requirements is shown below with two changes: 1) I added

the OPTION(RECOMPILE)because I did a little pre-performance testing and it seemed to be marginally better and 2) his solution always traverses the entire hierarchy while I’d like mine to only go as deep as I tell it to

(using @NumLevels).

First we must create two indexed temporary tables.

CREATE TABLE #Temp3 (

[Level] INT

,ManagerID INT

,EmployeeID INT

,MgrEmp VARCHAR(8000)

,CONSTRAINT t3_pk PRIMARY KEY([Level], ManagerID, EmployeeID)

);

CREATE TABLE #Temp4 (

[Level] INT

,ManagerID INT

,EmployeeID INT

,MgrEmp VARCHAR(8000)

,CONSTRAINT t4_pk PRIMARY KEY([Level], ManagerID, EmployeeID)

);

We then populate the two temporary tables, avoiding the SQL Optimizer’s need to provide Halloween protection by alternating our INSERTs between the two temporary tables.

— Insert the parent level INSERT INTO #Temp3

SELECT [Level]=ISNULL(1,0), ManagerID=ISNULL(ManagerID,0), EmployeeID=ISNULL(EmployeeID,0)

,MgrEmp=CAST(ManagerID AS VARCHAR(8000)) + ‘/’ + CAST(EmployeeID AS VARCHAR(8000))

FROM dbo.ManagersDirectory2 a1 WHERE a1.ManagerID = @TopLevel;

WHILE @I < @NumLevels + 1 BEGIN

— Insert each successive level into the temporary table. IF @I % 2 = 0

— Even iterations go into #Temp4 INSERT INTO #Temp4

SELECT @I, b.ManagerID, b.EmployeeID

,MgrEmp=MgrEmp + ‘/’ + CAST(b.EmployeeID AS VARCHAR(8000)) FROM #Temp3 a

JOIN dbo.ManagersDirectory2 b ON a.EmployeeID = b.ManagerID WHERE [Level] = @I 1

OPTION (RECOMPILE); ELSE

— Odd iterations go into #Temp3 INSERT INTO #Temp3

SELECT @I, b.ManagerID, b.EmployeeID

,MgrEmp=MgrEmp + ‘/’ + CAST(b.EmployeeID AS VARCHAR(8000)) FROM #Temp4 a

JOIN dbo.ManagersDirectory2 b ON a.EmployeeID = b.ManagerID WHERE [Level] = @I 1

OPTION (RECOMPILE);

END

SELECT @I = @I + 1;

Now we just need to combine the results stored into our two temporary tables to obtain our hierarchy traversal.

— Combine the results to get the hierarchy traversal SELECT [Level], ManagerID, EmployeeID, MgrEmp

FROM #Temp3 UNION ALL

SELECT [Level], ManagerID, EmployeeID, MgrEmp FROM #Temp4

ORDER BY [Level], ManagerID, EmployeeID;

Since Itzik Ben-Gan and Paul White are both so much more eloquent and technically skilled than me, we’ll leave the explanation of detecting, and why avoiding, Halloween protection is a good thing.

A Bigger Test Harness

After learning rather quickly that generating a large test harness for an adjacency list can be quite annoying, we finally managed to do so as follows. This extends the hierarchy adding three nodes to each prior node for each iteration of the WHILE loop.

— Remove our small hierarchy table and add a column we’ll use to generate more levels TRUNCATE TABLE dbo.ManagersDirectory;

ALTER TABLE dbo.ManagersDirectory ADD [Level] INT; GO

— The first entry is the top level parent plus three children INSERT INTO dbo.ManagersDirectory

SELECT 1, 2, 1 UNION ALL SELECT 1, 3, 1 UNION ALL SELECT 1, 4, 1; DECLARE @Level INT = 1;

WHILE @Level <= 13 BEGIN

INSERT INTO dbo.ManagersDirectory (ManagerID, EmployeeID, [Level]) SELECT EmployeeID, ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) +

— Next employee numbers will just be integers from ROW_NUMBER() above added to this. (SELECT MAX(EmployeeID) FROM dbo.ManagersDirectory)

,@Level + 1

FROM dbo.ManagersDirectory a

— This small tally table generates 3 nodes for each previous level’s entry CROSS JOIN (SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3) b(n)

WHERE [Level] = @Level;

END

SELECT @Level = @Level + 1;

— Remove the column we added to facilitate creation of the test harness. ALTER TABLE dbo.ManagersDirectory DROP COLUMN [Level];

SELECT COUNT(*)

FROM dbo.ManagersDirectory;

Be patient when you run the above script because it does take a little longer to run that the prior ones (about 3.5 minutes). To construct our hierarchy, we needed to add a Level column which we later drop off to restore the adjacency list to its original structure. The final SELECT tells us that our table now contains 7,174,452 rows.

To test the performance of this new method, we’ll run through traversals of the hierarchy from 5 to 13 levels using a batch that we’ll execute 9 times. The #Results table will capture the results for each execution of the batch.

The#Sequence table is used to simulate a SQL 2012 SEQUENCE object, since we’re running our test in SQL Server 2008 R2.

CREATE TABLE #Results (

Levels INT

,Reccount INT

,Method VARCHAR(10)

,ElapsedMS INT

); GO

CREATE TABLE #Sequence (

[Counter] INT

);

INSERT INTO #Sequence SELECT 4;

GO

The batch will start at level five, because below that all of the methods are pretty swift. The rest of the test harness is rather lengthy so we won’t include it here, however you can find it in the attached resources as: 03Performance Test Harness.sql

After the final run of the timing batch (takes about 16 minutes), the results from our #Results table (reformatted) are shown below for elapsed time in milliseconds. Red font indicates the worst performing solution at the level

while green font (often tied) represents the fastest solution.

 

Elapsed (ms)

rCTE

rFCN

dSQL

sbLP

sbLPwR

LAHP

LAHPwR

5 Levels
1092 Rows

16

63

63

30

76

46

16

6 Levels
3279 Rows

46

33

30

123

80

60

46

7 Levels
9840 Rows

160

93

123

123

96

110

93

8 Levels
29523 Rows

503

313

170

296

230

296

200

9 Levels
88572 Rows

1280

983

593

576

563

596

563

10 Levels
265719 Rows

3780

3113

1763

1853

1763

1843

1400

11 Levels
797160 Rows

11443

9630

5520

7913

6660

4573

4616

12 Levels
2391483 Rows

35300

29790

17510

17020

17336

21740

15646

13 Levels
7174452 Rows

108113

104420

57950

52563

81153

50156

52643

 

Each of our methods are abbreviated as:

  • rCTE– The traditional recursive CTE approach

  • rFCN – The non-traditional recursive FUNCTION approach

  • dSQL – The dynamic SQL constructed from repeated JOINs and UNION ALL

  • sbLP– The set-based WHILE loop

  • sbLPwR– The set-based WHILE loop with RECOMPILE option

  • LAHP– The set-based WHILE loop avoiding Halloween protection

  • LAHPwR– The set-based WHILE loop avoiding Halloween protection with RECOMPILE option

Note that you can probably drop the OPTION(RECOMPILE) whenever your intention is to traverse the entire hierarchy to its full depth.

The results are clearly illustrated when you graph them as a percentage of the worst performing solution, which is usually the rCTE.

1980-9da4e504-a22c-46c4

Conclusions

When traversing the hierarchy we constructed between eight and thirteen levels deep, the set-based WHILE loop avoiding Halloween protection with RECOMPILE option (LAHPwR) is most often the elapsed time winner. Perhaps surprisingly, the non-traditional recursive function seems to do just a little better than the rCTE between six and twelve levels. It is exactly tied with the set-based loop at seven levels. The repeated JOINs method seems contra- indicated across the board (except at six levels) because there’s always a faster performing solution available.

We present these results with a caveat. This is that, depending on your hierarchy one or the other results may be faster for you. Our enlarged hierarchy was exactly balanced; specifically each node had exactly three nodes growing from it. You may find that when only a few of your branches extend out to the higher depths, one of the other approaches may be more suitable for traversal. They also show that sometimes a well-constructed, set-based loop can out-perform the traditional recursive CTE approach.

In summary, this example illustrates where the ImageNow 6.7 (and beyond) schema will begin to fail. And while this is an incredibly powerful new technology for “data deduplication” and “data inheritance” there are certainly limitations in the current db schema and ImageNow Server 6.7(+) API are capable of rendering, and if your Content Model design begins to approach these levels it is highly advised to engage Shamrock Solutions to assist in making sure you’re leveraging the system at optimal performance.

 

 

 

 

Leave a Reply

Your email address will not be published. Required fields are marked *

WE CALL YOU BACK

Request A Callback

Our team is composed of senior-level public relations consultants with editorial and creative skills that have been built over years of delivering PR services to top.