/// <summary>
        /// Walk the given directory (and all children) looking for images, upload to blob storage, and store metadata in table storage.
        /// </summary>
        /// <param name="rootDirectory">Directory to walk (recursively).</param>
        /// <param name="imagesVersion">Version to tag to uploaded image sets.</param>
        /// <returns></returns>
        public async Task WalkTree(string rootDirectory, string imagesVersion)
        {
            TestPreconditions(rootDirectory);

            var images = from file in Directory.EnumerateFiles(rootDirectory, "*.*", SearchOption.AllDirectories).Select(x => new FileInfo(x))
                         where this.Extensions.Contains(file.Extension)
                         select file;

            var byDirectory = from img in images
                              group img by img.DirectoryName;

            var uploadTasks = Enumerable.Empty<Tuple<FileInfo, Task>>();
            var imgSets = new List<ImageSet>();
            foreach (var dir in byDirectory)
            {
                var suffix = dir.Key.Length == rootDirectory.Length ? "" : dir.Key.Substring(rootDirectory.Length + 1);
                suffix = suffix.Trim();
                var imgSet = new ImageSet(suffix, imagesVersion)
                {
                    Tags = this.TagExtractor(suffix).Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToList()
                };

                Trace.TraceInformation("New Image Set {0} w/ tags ('{1}')", imgSet.PartitionKey, string.Join("', '", imgSet.Tags));
                uploadTasks = uploadTasks.Concat(dir.Select(file => 
                    Tuple.Create(file, this.BlobUploader(file.FullName, imgSet.BlobPath))));
                imgSets.Add(imgSet);
            }

            var failedUpserts = await Utilities.ThrottleWork(MaxParallelUpserts, imgSets.Select(imgSet => this.ImageSetUpserter(imgSet)));
            var failedUploads = await Utilities.ThrottleWork(MaxParallelUploads, uploadTasks.Select(x => x.Item2));
            if (failedUpserts.Any() || failedUploads.Any())
            {
                var filesByTask = uploadTasks.ToDictionary(x => x.Item2, x => x.Item1);
                throw Utilities.AsAggregateException(failedUploads.Concat(failedUpserts), 
                    t => filesByTask.ContainsKey(t) ? string.Format("Failed upload for '{0}'", filesByTask[t]) : null);
            }
        }
        /// <summary>
        /// Walk the given directory (and all children) looking for images, transform, upload to blob storage, and store metadata in table storage.
        /// </summary>
        /// <param name="rootDirectory">Directory to walk (recursively).</param>
        /// <param name="imagesVersion">Version to tag to uploaded image sets.</param>
        /// <returns></returns>
        /// <remarks>
        /// Side-effect: Transformed images are left on disk in transformedRoot. For us, this is a feature :)
        /// NOTE: Does NOT upsert ImageTransform - it's assumed this is a known transform that already exists.
        /// </remarks>
        public async Task TransformTree(string rootDirectory, string imagesVersion, ImageTransform transform, string imageMagickPath, string transformedRoot)
        {
            TestPreconditions(rootDirectory);

            var images = from file in Directory.EnumerateFiles(rootDirectory, "*.*", SearchOption.AllDirectories).Select(x => new FileInfo(x))
                         where this.Extensions.Contains(file.Extension)
                         select file;

            var byDirectory = from img in images
                              group img by img.DirectoryName;

            var uploadTasks = Enumerable.Empty<Tuple<FileInfo, Task>>();
            var imgSets = new List<ImageSet>();
            foreach (var dir in byDirectory)
            {
                var suffix = dir.Key.Length == rootDirectory.Length ? "" : dir.Key.Substring(rootDirectory.Length + 1);
                suffix = suffix.Trim();
                var transformDir = Path.Combine(transformedRoot, suffix);
                Directory.CreateDirectory(transformDir);

                var imgSet = new ImageSet(suffix, imagesVersion, transform)
                {
                    Tags = this.TagExtractor(suffix).Select(x => x.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToList()
                };

                foreach (var file in dir)
                {
                    var infile = file.FullName;
                    var outfile = Path.Combine(transformDir, file.Name);
                    var cmdline = transform.GetCommandLineArguments(infile, outfile);
                    Trace.TraceInformation("Transforming: '{0} {1}'", imageMagickPath, cmdline);
                    var proc = Process.Start(new ProcessStartInfo()
                    {
                        FileName = imageMagickPath,
                        Arguments = cmdline,
                        UseShellExecute = false,
                        RedirectStandardError = true
                    });
                    proc.WaitForExit();
                    var exitCode = proc.ExitCode;
                    proc.Close();
                    if (exitCode != 0)
                    {
                        Trace.TraceWarning("Failed to execute '{0} {1}': Code {2}", imageMagickPath, cmdline, exitCode);
                    }
                }

                var transformedImages = from file in Directory.EnumerateFiles(transformDir, "*.*", SearchOption.TopDirectoryOnly).Select(x => new FileInfo(x))
                                        where this.Extensions.Contains(file.Extension)
                                        select file;
                if (transformedImages.Any())
                {
                    Trace.TraceInformation("New Image Set {0} w/ tags ('{1}')", imgSet.PartitionKey, string.Join("', '", imgSet.Tags));
                    uploadTasks = uploadTasks.Concat(transformedImages.Select(file =>
                        Tuple.Create(file, this.BlobUploader(file.FullName, imgSet.BlobPath))));
                    imgSets.Add(imgSet);
                }
                else
                {
                    Trace.TraceWarning("No transformed images found for '{0}'", transformDir);
                }
            }

            var failedUpserts = await Utilities.ThrottleWork(MaxParallelUpserts, imgSets.Select(imgSet => this.ImageSetUpserter(imgSet)));
            var failedUploads = await Utilities.ThrottleWork(MaxParallelUploads, uploadTasks.Select(x => x.Item2));
            if (failedUpserts.Any() || failedUploads.Any())
            {
                var filesByTask = uploadTasks.ToDictionary(x => x.Item2, x => x.Item1);
                throw Utilities.AsAggregateException(failedUploads.Concat(failedUpserts),
                    t => filesByTask.ContainsKey(t) ? string.Format("Failed upload for '{0}'", filesByTask[t]) : null);
            }
        }