Skip to content

Optimize for large bundles with numerous globs - #326

Open
snipervld wants to merge 3 commits into
ligershark:masterfrom
snipervld:master
Open

Optimize for large bundles with numerous globs#326
snipervld wants to merge 3 commits into
ligershark:masterfrom
snipervld:master

Conversation

@snipervld

@snipervld snipervld commented Sep 25, 2024

Copy link
Copy Markdown

Update 2026-06-11: I've replaced manual caching with GlobbingUrlBuilder. Plus, I've added two more tests to check that GlobbingUrlBuilder works with nested directories/files.


At the moment, Asset.ExpandGlobs{:.c#} doesn't cache results of provider.GetAllFiles("/"), so in the following example (simplified example - in the real app, this bundle contains different globs at different nested levels):

builder
    .Services
    .AddWebOptimizer(
        pipeline =>
        {
            //...
            pipeline.AddJavaScriptBundle(
                "bundle1",
                "js/*",
                "js/*",
                "js/*",
                "js/*",
                "js/*",
                "js/*");
            //...
        },
        options =>
        {
            options.EnableTagHelperBundling = false;
        });

Asset.ExpandGlobs will get all files 6 times. It can be a problem, if the virtual files hierarchy is huge.

This PR caches the results of provider.GetAllFiles("/") per provider into HttpContext.Items dictionary.

Also, this fixes the case when many complex bundles are located on the same page, e.g.:

...
<script src="~/bundle1"></script>
<script src="~/bundle2"></script>
<script src="~/bundle3"></script>
...

Each of them will reuse the cached file hierarchy if possible.

P.S. As you can see, I've tested this with EnableTagHelperBundling = false. ScriptTagHelper and LinkTagHelper call Asset.ExpandGlobs on each execution, which resulted in 5 seconds of rendering for just the script tags in my app, instead of something like ~0.1 seconds. :)

@jzabroski

Copy link
Copy Markdown
Contributor

This should be re-worked to use GlobbingUrlBuilder - It internally uses a Cache and is backed by the full faith, credit and trust of ASP.NET Core :D

@jzabroski

Copy link
Copy Markdown
Contributor

I'll give it a whirl and let @madskristensen know but superficially makes sense.

There's another issue in the Open Issues list where I looked into a somewhat related issue with file provider use cases.

I personally think this code base needs a regression suite around that stuff. Might re-clone the repo and ask Claude to help think through OATS pair-wise tests of feature combinations.

@snipervld

Copy link
Copy Markdown
Author

@jzabroski, found the bug: GlobbingUrlBuilder doesn't keep the original included files order,
e.g. source files js/a.js,js/b.js,js/c.js... may turn into js/x.js,js/c.js,...

I'll restore the foreach loop and call the builder inside it. Of course, I'll add tests for this :)

@madskristensen

Copy link
Copy Markdown
Member

just let me know when this is ready to merge

@snipervld
snipervld force-pushed the master branch 2 times, most recently from ceb456b to ce5d20d Compare June 12, 2026 01:02
@snipervld

Copy link
Copy Markdown
Author

@jzabroski, @madskristensen, done

@jzabroski

Copy link
Copy Markdown
Contributor

GlobbingUrlBuilder doesn't keep the original included files order, e.g. source files js/a.js,js/b.js,js/c.js... may turn into js/x.js,js/c.js,...

@snipervld Thanks for putting this together; I didn't see where your tests explicitly ensured this original order is preserved? Otherwise, I added a minor comment about the MockFileInfo private constructor. I think the first parameter name should be directoryName rather than name, to reflect the fact it is just a helper for the CreateDirectory factory method.

snipervld added a commit to snipervld/WebOptimizer that referenced this pull request Jun 12, 2026
@snipervld

snipervld commented Jun 12, 2026

Copy link
Copy Markdown
Author

@jzabroski

I didn't see where your tests explicitly ensured this original order is preserved?

Oops, sorry, I fixed the logic, but forgot about them after the comment :)

Add some more tests.

@jzabroski, I didn't see your comments about MockFileInfo (maybe you didn't Submit Review?), but I think I got your idea: renamed name into directoryName/fileName

Used GlobbingUrlBuilder to find matching files in the file provider.

Added tests for nested file structure (there were tests just for plain
FSs).

Caveat: GlobbingUrlBuilder uses binary search, so the order of files
matching the glob pattern may differ from the previous implementation.

E.g. if the bundle is defined this way:
pipeline.AddJavaScriptBundle("bundle1",
    "js/a.js", "js/x-*.js", "js/b.js", "js/c/js");
files within "js/x-*.js" group may have the different order.
@jzabroski

Copy link
Copy Markdown
Contributor

Yes, there seems to be a weird glitch where I can't submit the review since im technically not marked as a reviewer.

@jzabroski

Copy link
Copy Markdown
Contributor

Screenshot_20260612_075722_GitHub.jpg

On second thought...it might be because I added this comment a year ago 🤔

snipervld added a commit to snipervld/WebOptimizer that referenced this pull request Jun 12, 2026
@snipervld

Copy link
Copy Markdown
Author

@jzabroski, what do you think about 2d5d828?

@jzabroski jzabroski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@snipervld There's a few code clean-ups I recommend doing to make sure as other contributors build on top of your tests, they do not violate invariants in your test setups. Or, if they do, future PR reviews are easier to distinguish the changes and think critically about.

context.Setup(c => c.RequestServices.GetService(typeof(IMemoryCache)))
.Returns(cache.Object);
context.SetupGet(c => c.Request.PathBase).Returns(pathBase);
context.SetupGet(c => c.Items).Returns(new Dictionary<object, object>());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should call Mock.VerifyAll(context, optionsFactory, optionsMonitorCache, cache, asset, assetPipeline); at the end of the method, where you perform Asserts.

LastModified = lastModified;
}

private MockFileInfo(string name, DateTimeOffset lastModified, IList<MockFileInfo> files = null)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename the first parameter to directoryName; this makes it more intuitive that this private constructor is meant to serve as a back-end constructor for the CreateDirectory factory method.

context.Verify(c => c.RequestServices.GetService(typeof(IWebHostEnvironment)));
context.Verify(c => c.RequestServices.GetService(typeof(IMemoryCache)));
context.Verify(c => c.Response.Headers);
env.Verify(e => e.WebRootFileProvider);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use Mock.VerifyAll?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried Mock.VerifyAll, but it failed, because Mock.VerifyAll required all mock file provider's setup to be executed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other tests, where Mock.VerifyAll worked, I used it.

Ok, I'll recheck tests once again.

context.Verify(c => c.RequestServices.GetService(typeof(IWebHostEnvironment)));
context.Verify(c => c.RequestServices.GetService(typeof(IMemoryCache)));
context.Verify(c => c.Response.Headers);
env.Verify(e => e.WebRootFileProvider);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use Mock.VerifyAll?

context.Verify(c => c.RequestServices.GetService(typeof(IWebHostEnvironment)));
context.Verify(c => c.RequestServices.GetService(typeof(IMemoryCache)));
context.Verify(c => c.Response.Headers);
env.Verify(e => e.WebRootFileProvider);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use Mock.VerifyAll?


Assert.Equal(new[] { "b.css", "a.css" }, files);

env.Verify(e => e.WebRootFileProvider);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mock.VerifyAll


Assert.Equal(new[] { "sub/nested.css", "root.css" }, files);

env.Verify(e => e.WebRootFileProvider);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mock.VerifyAll

Assert.True(indexStyleA < indexAlpha && indexStyleB < indexAlpha,
"style-*.css group precedes alpha.css group");

env.Verify(e => e.WebRootFileProvider);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mock.VerifyAll

Replaced Moq-based MockFileProvider with a plain implementation.
@snipervld

Copy link
Copy Markdown
Author

@jzabroski, replaced manual verifies with VerifyAll.

@snipervld
snipervld requested a review from jzabroski June 13, 2026 22:13
@jzabroski

Copy link
Copy Markdown
Contributor

LGTM. Thank you for taking the feedback in so fast.

@snipervld

Copy link
Copy Markdown
Author

@madskristensen,

just let me know when this is ready to merge

ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants