DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports Events Over 2 million developers have joined DZone. Join Today! Thanks for visiting DZone today,
Edit Profile Manage Email Subscriptions Moderation Admin Console How to Post to DZone Article Submission Guidelines
View Profile
Sign Out
Refcards
Trend Reports
Events
Zones
Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Partner Zones AWS Cloud
by AWS Developer Relations
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Partner Zones
AWS Cloud
by AWS Developer Relations
The Latest "Software Integration: The Intersection of APIs, Microservices, and Cloud-Based Systems" Trend Report
Get the report
  1. DZone
  2. Data Engineering
  3. Data
  4. HTTP/2 Server Push and ASP.NET MVC

HTTP/2 Server Push and ASP.NET MVC

The Cache Digest seems to be the missing piece which allows fine tuning of HTTP/2 Server Push. Hopefully, browsers will start providing native support soon.

Tomasz Pęczek user avatar by
Tomasz Pęczek
·
Mar. 24, 17 · Tutorial
Like (2)
Save
Tweet
Share
5.71K Views

Join the DZone community and get the full member experience.

Join For Free

In my previous post, I didn't write much on how Server Push works with caching besides the fact that it does. This can be easily verified by looking at Network tab in Chrome Developer Tools while making subsequent requests to the sample action.

Chrome Developer Tools Network Tab - Warm Server Push

But this isn't the full picture. The chrome://net-internals/#http2 tab can be used in order to get more details. The first screenshot below represents the cold scenario while second represents the warm one.

Chrome Net Internals - Cold Server Push

Chrome Net Internals - Warm Server Push

The highlighted column informs that there were resources pushed from server that haven't been used. View live HTTP/2 sessions provides even more detailed debug information, down to the single HTTP/2 frames.

Going through those reveals that HTTP2_SESSION_RECV_PUSH_PROMISE, HTTP2_SESSION_RECV_HEADERS, and HTTP2_SESSION_RECV_DATA frames are present for both pushed resources. This shouldn't affect latency, as the stream carrying HTML is supposed to have the highest priority, which means that server should start sending (and browser receiving) the data as soon as something is available (even if sending/receiving for pushed resources is not finished). On the other hand, the bandwidth is being lost and losing bandwidth often means losing money. As nobody wants to be losing money, this is something that needs to be resolved — and Cache Digest aims at doing exactly that.

How Cache Digest Solves the Issue

The Cache Digest specification proposal (in its current shape) uses Golomb-Rice coded Bloom filter in order to provide the server with information about the resources which are already contained by the client cache. The algorithm uses URLs and ETags for hashing, which ensures proper identification (and with correct ETags also freshness) of the resources. The server can query the digest and if the resource which was intended for the push is present, the push can be skipped.

There is no native browser support for Cache Digest yet (at least that I know of), but there are two ways in which HTTP/2 servers (http2server, h2o) and third party libraries are trying to provide it:

  • On the client side with usage of Service Workers and Cache API.
  • On server side by providing a cookie-based fallback.

Implementing Cache Digest With Service Worker and Cache API

One of the third-party libraries providing Cache Digest support on client side is cache-digest-immutable. It combines Cache Digest with HTTP Immutable Responses proposal in order to avoid overly aggressive caching. This means that only resources with cache-control header containing immutable extension will be considered. In order to add such extension to pushed resource the location and customHeaders elements of web.config can be used.

<?xml version="1.0"?>
<configuration>
  ...
  <location path="content/css/normalize.css">
    <system.webServer>
      <httpProtocol>
        <customHeaders>
          <add name="Cache-Control" value="max-age=31536000, immutable" />
        </customHeaders>
      </httpProtocol>
    </system.webServer>
  </location>
  <location path="content/css/site.css">
    <system.webServer>
      <httpProtocol>
        <customHeaders>
          <add name="Cache-Control" value="max-age=31536000, immutable" />
        </customHeaders>
      </httpProtocol>
    </system.webServer>
  </location>
  ...
</configuration>

A quick look at responses after running the demo application shows that headers are being added properly. Now, the Service Worker can be set up accordingly to the instructions. From this point, the subsequent requests to the demo application will contain cache-digest header. In the case of the demo application, its value is CdR2gA; complete. The part before the ; is the digest (Base64 encoded) while the rest contains flags.

The flags can be parsed easily by checking if corresponding constants are present in the header.

var compareInfo = CultureInfo.InvariantCulture.CompareInfo;
var compareOptions = CompareOptions.IgnoreCase;

bool reset = (compareInfo.IndexOf(cacheDigestHeaderValue, "RESET", compareOptions) >= 0);
bool complete = (compareInfo.IndexOf(cacheDigestHeaderValue, "COMPLETE", compareOptions) >= 0);
bool validators = (compareInfo.IndexOf(cacheDigestHeaderValue, "VALIDATORS", compareOptions) >= 0);
bool stale = (compareInfo.IndexOf(cacheDigestHeaderValue, "STALE", compareOptions) >= 0);

My first attempt to decode the digest has failed miserably with an awful exception. The reason is that the padding is being truncated and it must be added back.

int separatorIndex = cacheDigestHeaderValue.IndexOf(DigestValueSeparator);
string digestValueBase64 = cacheDigestHeaderValue.Substring(0, separatorIndex);

int neededPadding = (digestValueBase64.Length % 4);
if (neededPadding > 0)
{
    digestValueBase64 += new string('=', 4 - neededPadding);
}
byte[] digestValue = Convert.FromBase64String(digestValueBase64);

Now, the digest query algorithm can be implemented. The algorithm assumes going through digest value bit by bit, so the first step was changing the array of bytes into an array of bools.

bool[] bitArray = new bool[digestValue.Length * 8];
int bitArrayIndex = bitArray.Length - 1;

for (int byteIndex = digestValue.Length - 1; byteIndex  >= 0; byteIndex --)
{
    byte digestValueByte = digestValue[byteIndex];
    for (int byteBitIndex = 0; byteBitIndex < 8; byteBitIndex++)
    {
        bitArray[bitArrayIndex--] = ((digestValueByte % 2 == 0) ? false : true);
        digestValueByte = (byte)(digestValueByte >> 1);
    }
}

The algorithm also requires reading a series of bits as integers in few places, below method takes care of that.

private static uint ReadUInt32FromBitArray(bool[] bitArray, int starIndex, int length)
{
    uint result = 0;

    for (int bitIndex = starIndex; bitIndex < (starIndex + length); bitIndex++)
    {
        result <<= 1;
        if (bitArray[bitIndex])
        {
            result |= 1;
        }
    }

    return result;
}

With this method, count of URLs and probability can be easily retrieved.

// Read the first 5 bits of digest-value as an integer;
// let N be two raised to the power of that value.
int count = (int)Math.Pow(2, ReadUInt32FromBitArray(digestValueBitArray, 0, 5));

// Read the next 5 bits of digest-value as an integer;
// let P be two raised to the power of that value.
int log2Probability = (int)ReadUInt32FromBitArray(digestValueBitArray, 5, 5);
uint probability = (uint)Math.Pow(2, log2Probability);

The part which reads hashes requires keeping in mind that there might be additional 0 bits at the end, so there is a risk of going over the array without proper checks. I've decided to keep the hashes in HashSet for quicker lookups later.

HashSet hashes = new HashSet();

// Let C be -1.
long hash = -1;

int hashesBitIndex = 10;
while (hashesBitIndex < bitArray.Length)
{
    // Read ‘0’ bits until a ‘1’ bit is found; let Q bit the number of ‘0’ bits.
    uint q = 0;
    while ((hashesBitIndex < bitArray.Length) && !bitArray[hashesBitIndex])
    {
        q++;
        hashesBitIndex++;
    }

    if ((hashesBitIndex + log2Probability) < bitArray.Length)
    {
        // Discard the ‘1’.
        hashesBitIndex++;

        // Read log2(P) bits after the ‘1’ as an integer. Let R be its value.
        uint r = ReadUInt32FromBitArray(bitArray, hashesBitIndex, log2Probability);

        // Let D be Q * P + R.
        uint d = (q * probability) + r;

        // Increment C by D + 1.
        hash = hash + d + 1;

        hashes.Add((uint)hash);
        hashesBitIndex += log2Probability;
    }
}

The last thing needed to query the digest value is the ability to calculate a hash of given URL (and optionally ETag). The important thing here is that DataView used by Service Worker in order to convert SHA-256 to integer is internally using Big-endian byte order so the implementation must accommodate for that.

// URL should be properly percent-encoded (RFC3986).
string key = url;

// If validators is true and ETag is not null.
if (validators && !String.IsNullOrWhiteSpace(entityTag))
{
    // Append ETag to key.
    key += entityTag;
}

// Let hash-value be the SHA-256 message digest (RFC6234) of key, expressed as an integer.
byte[] hash = new SHA256Managed().ComputeHash(Encoding.UTF8.GetBytes(key));
uint hashValue = BitConverter.ToUInt32(hash, 0);
if (BitConverter.IsLittleEndian)
{
    hashValue = (hashValue & 0x000000FFU) << 24 | (hashValue & 0x0000FF00U) << 8
                | (hashValue & 0x00FF0000U) >> 8 | (hashValue & 0xFF000000U) >> 24;
}

// Truncate hash-value to log2(N*P) bits.
int hashValueLength = (int)Math.Log(count * probability, 2);
hashValue = (hashValue >> (_hashValueLengthUpperBound - hashValueLength))
            & (uint)((1 << hashValueLength) - 1);

I've combined all this logic into CacheDigestHashAlgorithm, CacheDigestValue, and CacheDigestHeaderValue classes, which I've used in PushPromiseAttribute in order to conditionally push resources.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, ...)]
public class PushPromiseAttribute : FilterAttribute, IActionFilter
{
    ...

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        ...
        HttpRequestBase request = filterContext.HttpContext.Request;
        CacheDigestHeaderValue cacheDigest = (request.Headers["Cache-Digest"] != null) ?
            new CacheDigestHeaderValue(request.Headers["Cache-Digest"]) : null;

        IEnumerable pushPromiseContentPaths = _pushPromiseTable.GetPushPromiseContentPaths(
            filterContext.ActionDescriptor.ControllerDescriptor.ControllerName,
            filterContext.ActionDescriptor.ActionName);

        foreach (string pushPromiseContentPath in pushPromiseContentPaths)
        {
            string pushPromiseContentUrl = GetAbsolutePushPromiseContentUrl(
                filterContext.RequestContext,
                pushPromiseContentPath);

            if (!(cacheDigest?.QueryDigest(pushPromiseContentUrl) ?? false))
            {
                filterContext.HttpContext.Response.PushPromise(pushPromiseContentPath);
            }
        }
    }
}

The easiest way to verify the functionality is to debug the demo application or check View live HTTP/2 sessions in chrome://net-internals/#http2 tab. After an initial request, the Service Worker will take over fetching of the resources by adding cache-digest header to the subsequent requests and serving cached resources with help of Cache API. On the server side, the cache-digest header will be picked up and push properly skipped.

Chrome Developer Tools Network Tab - Service Worker

Chrome Net Internals - Service Worker

Implementing Cache Digest With Cookie-Based Fallback

The idea behind the cookie-based fallback is very simple. The server generates the cache digest by itself and passes it between requests as a cookie. An important difference is that only the digest value can be stored (as cookies don't allow semicolon and white spaces in values), so the server needs to assume the flags. The algorithm for generating the digest value is similar to the one for reading it, so I'll skip it here (for interested ones it is split into CacheDigestValue.FromUrls and CacheDigestValue.ToByteArray methods) and move on to changes in PushPromiseAttribute.

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, ...)]
public class PushPromiseAttribute : FilterAttribute, IActionFilter
{
    ...

    public bool UseCookieBasedCacheDigest { get; set; }

    public uint CacheDigestProbability { get; set; }
    ...

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        ...
        HttpRequestBase request = filterContext.HttpContext.Request;

        CacheDigestHeaderValue cacheDigest = null;
        if (UseCookieBasedCacheDigest)
        {
            cacheDigest = (request.Cookies["Cache-Digest"] != null) ?
                new CacheDigestHeaderValue(
                    CacheDigestValue.FromBase64String(request.Cookies["Cache-Digest"].Value)) : null;
        }
        else
        {
            cacheDigest = (request.Headers["Cache-Digest"] != null) ?
                new CacheDigestHeaderValue(request.Headers["Cache-Digest"]) : null;
        }

        IDictionary cacheDigestUrls = new Dictionary();

        IEnumerable pushPromiseContentPaths = _pushPromiseTable.GetPushPromiseContentPaths(
            filterContext.ActionDescriptor.ControllerDescriptor.ControllerName,
            filterContext.ActionDescriptor.ActionName);

        foreach (string pushPromiseContentPath in pushPromiseContentPaths)
        {
            string pushPromiseContentUrl = GetAbsolutePushPromiseContentUrl(
                filterContext.RequestContext,
                pushPromiseContentPath);

            if (!(cacheDigest?.QueryDigest(absolutePushPromiseContentUrl) ?? false))
            {
                filterContext.HttpContext.Response.PushPromise(pushPromiseContentPath);
            }
            cacheDigestUrls.Add(absolutePushPromiseContentUrl, null);
        }

        if (UseCookieBasedCacheDigest)
        {
            HttpCookie cacheDigestCookie = new HttpCookie("Cache-Digest")
            {
                Value = CacheDigestValue.FromUrls(cacheDigestUrls, CacheDigestProbability)
                    .ToBase64String(),
                Expires = DateTime.Now.AddYears(1)
            };
            filterContext.HttpContext.Response.Cookies.Set(cacheDigestCookie);
        }
    }
}

There shouldn't be anything surprising here, but the code exposes a couple of limitations when cookie-based fallback is being used.

First of all, there is no way to recreate the full list of pushed resources from the digest value. The code above creates new cookie value during every request which is good enough if we always attempt to push the same resources, but if we need to push something different for a specific action, we will lose the information about resources which are not related to that action.

One possible optimization here would be to store in digest only resources which are supposed to be pushed almost always and in the case of actions that require smaller subset keep the information that all the needed resources were already in the digest (which means that value doesn't have to be changed). Another approach would be to keep a registry of every resource pushed for given session or client, but with such detailed registry using, additionally, a cookie becomes questionable.

The other things are checks for resources freshness. The above implementation works well if names can be used for freshness control (for example they contain versions) but in other cases, we will not know that a resource has expired. In such scenarios, the validators flag should be set to true and ETags provided.

Final Thoughts

The Cache Digest seems to be the missing piece which allows fine tuning of HTTP/2 Server Push. There is a number of open issues around the proposal so things might change, but the idea is already being adopted by some of the Web Servers with HTTP/2 support. When the proposal will start closing to its final shape, hopefully, browsers will start providing native support.

ASP.NET MVC push ASP.NET Cache (computing) Database

Published at DZone with permission of Tomasz Pęczek. See the original article here.

Opinions expressed by DZone contributors are their own.

Popular on DZone

  • Solving the Kubernetes Security Puzzle
  • Full Lifecycle API Management Is Dead
  • Building Microservice in Golang
  • Spring Boot vs Eclipse MicroProfile: Resident Set Size (RSS) and Time to First Request (TFR) Comparative

Comments

Partner Resources

X

ABOUT US

  • About DZone
  • Send feedback
  • Careers
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 600 Park Offices Drive
  • Suite 300
  • Durham, NC 27709
  • support@dzone.com
  • +1 (919) 678-0300

Let's be friends: