I am working on a utility to manage shared content libraries for Work from Home scenarios. Basically I have libraries of thousands of Autodesk Revit family files, and I want to host those files on AWS S3, and then manage a local copy of the library based on what is hosted on S3. To that end I have developed an XML "index" file that lists all the folders and files in a given library, along with size, last modified date and file hash for the files. The idea being I can compare size and date in the index with size and date of file on disk and recalculate the hash only on files that need it, to ensure the "local index" represents the current state of the local library. Then I can download the "master index" and quickly compare them to determine which local files and folders to delete because they are no longer in the "master index", and more importantly which files to download because they have changed, as indicated by the different file hash. Files can change either because the user modified a local file which then need replaced with the master file again, or because the master file got revisions and the old local file needs replaced.
An index file looks like this, but a real file would have as many as 10,000 items.
<?xml version="1.0"?>
<rootDirectory>
<item>OOTB</item>
<item>OOTB\Data</item>
<item>OOTB\Data\IES</item>
<item size="583" date="03/22/2019 22:09:40" hash="B4A02121565CCADAA601C7092BD598F5BA0A9DED">OOTB\Data\IES\1x4 3Lamp.ies</item>
<item size="582" date="06/21/2020 06:50:36" hash="CF3B1F5E8F072DE2722E940EECDE90157F3BF2EC">OOTB\Data\IES\1x4 4Lamp.ies</item>
<item>OOTB\Data\Lookup Tables</item>
<item>OOTB\Data\Lookup Tables\Conduit</item>
<item size="443" date="03/22/2019 22:09:44" hash="303A0011DC5834F8072337492C4F9B305D3B0DEA">OOTB\Data\Lookup Tables\Conduit\M_Conduit Body - Type C - Aluminum.csv</item>
<item size="380" date="03/22/2019 22:09:44" hash="60CE25A7D805BD1B3746FD21E3CF8BA9B31ACB80">OOTB\Data\Lookup Tables\Conduit\M_Conduit Body - Type C - PVC.csv</item>
<item>OOTB\Data\Lookup Tables\Pipe</item>
<item size="871" date="03/22/2019 22:09:44" hash="E4D246B7F9B530A82F87BFDD7680A4C150CD3015">OOTB\Data\Lookup Tables\Pipe\Elbow - Flanged - GI - Class 125.csv</item>
<item size="731" date="03/22/2019 22:09:44" hash="DA22BD74071BFC5A4A5FB00DAABE87A5F348D647">OOTB\Data\Lookup Tables\Pipe\Elbow - Flanged - GI - Class 25.csv</item>
<item size="879" date="03/22/2019 22:09:44" hash="2F3AE63C2A44370A48497AA2DDEC0339CAABA126">OOTB\Data\Lookup Tables\Pipe\Elbow - Flanged - GI - Class 250.csv</item>
</rootDirectory>
I have been profiling some code to update the "local index" and I have arrived at this.
$oldIndex = 'C:\Assets\Revit\oldIndex.xml'
$newIndex = 'C:\Assets\Revit\newIndex.xml'
$path = 'C:\Assets\Revit\2020'
CLS
[xml]$xml = Get-Content $oldIndex
$rootNode = $xml.SelectSingleNode('/*')
$rootPath = $rootNode.path
# Delete
(Measure-Command {
foreach ($item in $rootNode.ChildNodes) {
$itemPath = "$rootPath\$($item.'#text')"
if (-not (Test-Path $itemPath)) {
$rootNode.RemoveChild($item)
}
}
}).TotalSeconds
# Add or revise
(Measure-Command {
foreach ($filesystemItem in (Get-ChildItem $path -recurse)) {
$itemPath = $filesystemItem.FullName.TrimStart($rootPath)
$itemXPath = '//*[text()="' + $itemPath + '"]'
if ($indexItem = $xml.SelectSingleNode('//*[text()="' + $itemPath + '"]')) {
if ($indexItem.size -and $indexItem.date) {
[String]$newSize = $filesystemItem.Length
[String]$newDate = ([System.IO.FileInfo]::new($filesystemItem.FullName)).LastWriteTime
if (($indexItem.size -ne $newSize) -or ($indexItem.date -ne $newDate)) {
$indexItem.size = $newSize
$indexItem.date = $newDate
$indexItem.hash = (Get-FileHash -Path:$filesystemItem.FullName -Algorithm:SHA1).Hash
}
}
} else {
$childNode = $xml.CreateElement('item')
$childNode.InnerText = $filesystemItem.FullName.TrimStart($path)
if ($filesystemItem.GetType() -eq [System.IO.FileInfo]) {
$childNode.SetAttribute('size', $filesystemItem.Length)
$childNode.SetAttribute('date', (([System.IO.FileInfo]::new($filesystemItem.FullName))).LastWriteTime)
$childNode.SetAttribute('hash', (Get-FileHash -Path:$filesystemItem.FullName -Algorithm:SHA1).Hash)
}
$rootNode.AppendChild($childNode)
}
}
}).TotalSeconds
# Resort
(Measure-Command {
$sortedNodes = $rootNode.ChildNodes | Sort-Object {$_.InnerXml}
$rootNode.RemoveAll()
foreach ($sortedNode in $sortedNodes) {
[void]$rootNode.AppendChild($sortedNode)
}
}).TotalSeconds
$xml.Save($newIndex)
My performance issue is in the Add or Revise section. On my test data set, of 8,000 or so files, it takes a full 17 seconds to process, even when there are only one or two changes. I have already tried using Select-Xml
instead of .SelectSingleNode()
as seen here.
$oldIndex = 'C:\Assets\Revit\oldIndex.xml'
[xml]$xml = Get-Content $oldIndex
$XPath = '//*[text()="OOTB\Libraries\US Imperial\Annotations\Architectural"]'
Measure-Command {
foreach ($i in 1..1000) {
Select-Xml -xml:$xml -xpath:$XPath
}
}
Measure-Command {
foreach ($i in 1..1000) {
$xml.SelectSingleNode($XPath)
}
}
.SelectSingleNode()
out performs Select-Xml
700 ms to 2900 ms on my test data.
I have also done some other tests looking at how long it takes to iterate over the files and the differences between MD5 and SHA1 for the hashing. Both where minor. I also simply remarked out the actual update lines, and I am still seeing 17 or so seconds. So it seems the root issue is in repeatedly searching the XML to determine if an item already exists and needs to be tested for revision, or doesn't exist and needs to be added. So this line
if ($indexItem = $xml.SelectSingleNode('//*[text()="' + $itemPath + '"]')) {
What I am hoping for in a review is some suggestions on optimizations in the Add or Revise section, or a verification that there really is no optimization possible.
The issue for me is the fact that I need to do this at least at every user logon, and a given machine could have multiple libraries to be indexed, perhaps even as many as 10, each with an excess of 20,000 files. If performance scales with my current numbers, I will easily be looking at close to a minute just to verify the local index of a single library, so 10 libraries is a very long process, even before I start downloading any files from S3.
Also, I should mention that I do know I could skip the sorting. For now it's really there just to facilitate me being able to review results. But given that a resort takes less than 2 seconds, compared to the 17+ for the actual required work, I may just leave the sorting in place to keep the XML "neat" in any case.
I should also mention, I considered keeping the date of the last successful index in the XML file, so I could only rehash files that had been changed since then. However, editing a file isn't the only way to get out of sync with the master library. Someone could have a copy of a much older file somewhere, and move that to the local library. Then the date isn't newer than the last successful index, but the file is still wrong and needs a new hash for comparison with the master index. So, comparing current file date and size with last indexed date and size seems like the best way to minimize hashing (which does massively impact overall time) while still ensuring that hashes are up to date.