Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Caching layer #1073

Open
uNetworkingAB opened this issue Jun 17, 2024 · 62 comments
Open

Caching layer #1073

uNetworkingAB opened this issue Jun 17, 2024 · 62 comments

Comments

@uNetworkingAB
Copy link
Contributor

Another solution to excessive JS calls could be to simply add a caching layer that stores whatever the JS callback wrote, for a duration of X seconds, seding it back from cache until next update.

This could be entirely separate, as a wrapper of SSLApp / App like CachingApp, CachingSSLApp. Like a proxy but built-in.

It could be good for benchmarks while not having to change existing code. It also makes it possible to build real world examples like "what's the price of gold" being updated every 30 seconds, hitting the cache all other times.

Alternatively the API could be that you just add an integer after the handler, for how many seconds the cache is valid, defaulting to 0. Then you could mix cached endpoints with non-cached ones seamlessly.

@uasan
Copy link

uasan commented Jun 17, 2024

This is interesting.
What will be the cache key, will all variables from the router participate in the cache key?

@uNetworkingAB
Copy link
Contributor Author

The entire URL would be key

@uasan
Copy link

uasan commented Jun 17, 2024

Clearly, this is ideal for static endpoints that have immutable response.

But, if you add to the interface it is possible to pass a custom asynchronous cache revalidation function, then this will already be a cool smart cache layer, the revalidation function should receive all the request parameters and return true or false, if it returns true then the client can give the old response if false, then you need to call the router handler which will return a new response.

Thank you, this is a good idea

@uNetworkingAB
Copy link
Contributor Author

All public HTTP web APIs like those you access when browsing the stock market would be ideal candidates for having at least 30 second cache. It's not just static endpoints, it's literally all endpoints that deal with public data. Every single financial app that lets you browse some market would be ideal use.

custom asynchronous cache revalidation function

This kills the point, though. Calling into JS to decide if you don't need to call into JS will not fix anything. The idea is to entirely avoid calling any JS in the hot path, only doing so at sparse intervals to update the cache.

@uNetworkingAB
Copy link
Contributor Author

Arguments against the cache would be that:

  • This is too app specific and should be implemented by the user as needed, not built in
  • If needed, caching is already done in the proxy and most people use Cloudflare anyways
  • It's bloat, and too limited anyways

The arguments for it would be:

  • JS is too slow for implementing a cache, as calling into it kills performance defeating the entire point (for smallish simple responses)
  • Since the library is about supporting stand-alone best-case performance, having a built-in solution that can boost benchmarking scores by a lot (for JS use, not C++) without changing any code, and which has a valid, motivatable use for mentioned cases, it can be worth having in the library (again, everything performance related should be in the lib)
  • The PR itself, from scoring higher in benchmarks is valuable and even just 1 second cache will still boost performance by a lot in those cases
  • It's pretty simple to implement and can be entirely separate
  • It would entirely kill the need for uWS.DeclarativeResponse, as the cache is better in every way - simplifying the project

@uasan
Copy link

uasan commented Jun 17, 2024

Yes, you are probably right, but in our case you cannot give away the cache if it is no longer valid, the cache lifespan is not constant and depends on external factors, so in this case a revalidation function is needed.

This kills the point, though. Calling into JS to decide if you don't need to call into JS will not fix anything.

I understand, but I see greater potential for optimizing the validation function, inside this function there is no need to include the onAbort handler, there is no need to call cork(), i.e. minus 2 calls from C++ to JS.

It’s also very cool if you, on your part, make requests cork, i.e. If you called a function revalidation once and its promise has not yet been resolved, but exactly the same requests came from clients (match url), then you do not call the function revalidated, but continue to wait for the resolve of the promise from the first call of this function, this greatly optimizes the count.

Such optimization cannot be done with endpoint handlers, but it can be done with revalidation function, that's why I suggested it, it makes sense if you, on your part, optimize its call (without onAbort() it, without cork(), without repeated calls if promised pending)

@uNetworkingAB
Copy link
Contributor Author

The cache absolutely cannot call any JS whatsoever. That kills the entire purpose. What you can do is:

  1. Specify a timeout in seconds per-URL
  2. Prematurely invalidate the cache per URL
  3. Potentially specify what headers (such as "Authorization") should be part of the cache key

The cache must operate entirely separate from any JS, so that optimal performance can be delivered.

@uNetworkingAB
Copy link
Contributor Author

Reference for perf. difference in question:

uWebSockets js HTTP perf  comparison for small responses

@uasan
Copy link

uasan commented Jun 17, 2024

I don’t argue, without calling the JS this is an ideal case, it gives +50%, but if the revalidation function gives +20% compared to calling the endpoint handler, it also makes sense.

@miscellaneo
Copy link

So, the API would be something like this?

require('uWebSockets.js')
  .App()
  .get(
    '/some-route',
    (res, req) => {
      // Implementation here.
    },
    {
      // Cache on the "Authorization" header in addition to the URL.
      cacheHeaders: ['Authorization'],
      // Cache for thirty seconds.
      cacheTtl: 1000 * 30,
    },
  );

Would there be other options to control cache size, by number of entries or by memory used?

@uNetworkingAB
Copy link
Contributor Author

Something like that yes

@uasan
Copy link

uasan commented Jun 18, 2024

I propose to auto-assign caching HTTP headers, if the cacheTtl option is specified, send header
Cache-Control: max-age=${cacheTtl / 1000}

@uasan
Copy link

uasan commented Jun 18, 2024

It is also possible to automatically generate a response hash and transmit it in the Etag header, then you can automatically send clients a 304 status if the client cache has the same hash, this is better than always send clients a 200 status and a response that they already have in the client cache

@uasan
Copy link

uasan commented Jul 12, 2024

I came up with a better option: when creating caching routes, you need to return the controller object of this router so that you can change the contents of the response body and possibly the response headers.

Then we will be able to update the cache without calling JS functions on every request, this is an excellent performance and makes it possible to always send actual cache

const route = CachingApp().get('/some', (res, req) => body, { ...cacheOptions });

// Later in the application life cycle
// when an event occurs that requires the cache to be updated
// we simply update the cache of the desired route

route.setHeader(newHeder);
route.setBody(newBody);

@e3dio
Copy link
Contributor

e3dio commented Jul 12, 2024

app.get() returns app for chaining more routes app.get().get() so your current proposal would not work, can't make it return route

@uasan
Copy link

uasan commented Jul 12, 2024

This could be entirely separate, as a wrapper of SSLApp / App like CachingApp, CachingSSLApp. Like a proxy but built-in.

This behavior will be specific to only CachingApp and CachingSSLApp

@uNetworkingAB
Copy link
Contributor Author

I'm thinking about how the cache would work if something like middlewares were to be added.

Maybe it would just append, just like it would without the cache.

So middlewares would also have the same arguments: expiry and headers.

And they would follow the same logic: if cached, just use cache and continue.

Ok, sounds simple.

@uNetworkingAB
Copy link
Contributor Author

Problem with the cache is that, it boosts performance A LOT.

But all the "web frameworks" built on uWS.js use their own router and middlewares and crap like that, so they (which would be the most affected by a cache) can't use the cache.

So eventually, middlewares needs to be added to uWS.js itself, so that these sluggish derivatives can go away, or at least drastically reduce their involvement.

And for middlewares to work, this automatic copy of Request needs to be added.

So there are 3 parts to this:

  • The Cache itself
  • The middleware support
  • The support for async reading of Request (so that async middlewares can be supported) aka. "lifting" of Request

All of this probably warrants a v21 release

@uNetworkingAB
Copy link
Contributor Author

@uasan you are overcomplicating it. Invalidating the cache is just App.invalidate("the pattern string") or similar

@uNetworkingAB
Copy link
Contributor Author

Oh heck no. Async middlewares will never be supported. They are complete lunacy.

Sync middlewares should be easy to add.

@uNetworkingAB
Copy link
Contributor Author

Actually, we already support sync middlewares. They are the same as adding a handler that calls setYield(true). So it would be simple syntactic sugar to add App.use.

Lol

@uNetworkingAB
Copy link
Contributor Author

But why would you even need sync middlewares? They are literally the exact same thing as functions.

Web devs need to learn what functions are, and just start putting common tasks in functions, and call them. It really is that simple.

@uNetworkingAB
Copy link
Contributor Author

TLDR; I can't solve all the problems in the world. People need to help themselves, and if they refuse to change their ways. Well, good luck then.

I can only add a cache, that benefits uWS.js users. Whatever people build on top is not my problem.

@uNetworkingAB
Copy link
Contributor Author

Ok, "web frameworks" that use uWS.js can still enjoy the cache, but not with as fine grained control:

They can enable cache per-app. As they most likely do

uWS.App.any("/*", handler);

to get inputs. They could also make it slightly more fine grained by having "cache namespaces" instead of just any("/*") they could do:

uWS.App.any("/namespace1/*", handler_for_namespace1) // this namespace is cached
uWS.App.any("/*", handler) // this is not

and they could add as many of them as needed.

@uasan
Copy link

uasan commented Jul 16, 2024

 App.invalidate("/the pattern string")

Yes, you are right, I am completely satisfied with this.

@uasan
Copy link

uasan commented Jul 16, 2024

Regarding middlewares, I think those who use this pattern in their servers do not need the full performance of uWS, it is important for them to use the familiar pattern and be faster Express, that is their goal.

If you try to implement their patterns you will drown in issues from them )

@uNetworkingAB
Copy link
Contributor Author

You probably want a way to invalidate a very specific part of the cache, not a whole route (as it could be holding many users via parameter match). So as long as thre is some function App.invalidate(method, URL) or whatever, which is super easy to make, should be solved. How exactly it will look like is undecided.

@uasan
Copy link

uasan commented Jul 16, 2024

Yes, ideally it would be possible to invalidate using a route mask and a specific url the second is more necessary.

It is also important to do if there is no cache or it is invalid, only 1 request calls the route handler 1 time, until the async handler is resolved, parallel requests to this url should wait for the cache to appear and not call the handler again.

@uNetworkingAB
Copy link
Contributor Author

makes sense, but that will probably not be in first revision

@uasan
Copy link

uasan commented Jul 16, 2024

More questions about limits and cache evictions.

  1. What is the maximum number of unique urls that can be in the cache, is it thousands or millions?
  2. What is the maximum memory size for the entire cache, is it megabytes or gigabytes?
  3. What is the maximum size of 1 response that can be cached?
  4. What is the algorithm for eviction of cache urls, for example, when 1 million urls have accumulated in the cache, delete the oldest urls, i.e. those that no one has request for a long time.

For your business, it is best to make a cache as a free feature, but with low limits; the paid version is the same cache but with higher limits.
Then potential clients will be able to try the cash for free, if they need it they can buy a version with high limits.

@mustafa519
Copy link

mustafa519 commented Jul 16, 2024

The confusing part is also nginx entirely separate from the app logic but not uws, since we have to define the cache layer options in the JS side.

Nginx allows serving static files automatically from the external proxy. But, here proxy is also the uws itself which makes me remembered the inception movie.

Well, I got what you mean.

After making some brainstorming:

import uws from 'uWebSockets.js';

interface MatchedParams
{
  ip: string;
  url: string;
  qs: string;
  headers: Record<string, string>;
  bodyLength: number;
}

const app = uws
  .App()
  .cacheRules('key', {
    matches(params: MatchedParams): boolean
    {
      if (params.url === '/my.js') return true;

      return false;
    },
    methods: ['GET'],
    statusCodes: [200, 204],
    excludeHeaders: [],
    maxEntries: Infinity,
    revalidate: Infinity, // the revalidate the cache entry
    expires: 5, // in seconds
    minUses: 5,// same as in nginx(proxy_cache_min_uses), if it hits "minUses" times then cache it
    defaultStorage: 'memory', // which means the memory has the highest priority if fs params also set
    memory:
    {
      totalSize: Infinity, // in KB
      maxSize: Infinity, // bodyLength
      adapter: { type: 'redis', ... } ,
    },
    fs:
    {
      totalSize: Infinity, // in KB
      maxSize: Infinity, // bodyLength
      path: '/my_static_cache',
    },
  })
  .cacheRules('another_key', {
    // another params
    ...
  });

// Purge the cache somewhere, anytime
app.purgeCache('key');

On the above example if matches() returns true, the algorithm is going to decide caching it or not. It can also be stored into the filesystem after reaching out to the memory limits. Or according to the bodyLength.

NGINX supports many things which allow us to validate the cache, like variables or something like that. So, not sure if we can solve this without using matches function. I guess, it's going to execute in the JS stack, right?

@uNetworkingAB
Copy link
Contributor Author

I guess, it's going to execute in the JS stack, right?

No, you're not following. The cache is entirely separate from JS. No JS code must ever be run, in hot path

@uasan
Copy link

uasan commented Jul 17, 2024

I think many, like me, will look for opportunities to control the cache, but the @uNetworkingAB is right that the cache only makes sense if you don’t call the JS code, this means we must work with the cache, not the pool method (in the JS handler we decide whether to give a response from the cache or not ), we should use push method (uWS itself sends from the cache, but we can control the time and call invalidate method)

It’s just that uWS needs to push changes, and not try handle requests.

@dalisoft
Copy link

dalisoft commented Aug 2, 2024

@uNetworkingAB I am tried snippet from CachedHelloWorld.js but seems it is does not work.

package.json

{
  "name": "uws-test",
  "version": "1.0.0",
  "type": "module",
  "main": "server.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "uWebSockets.js": "uNetworking/uWebSockets.js#binaries"
  },
  "devDependencies": {
    "@biomejs/biome": "^1.8.2"
  },
  "trustedDependencies": ["@biomejs/biome"]
}

server.js

import uWS from 'uWebSockets.js';

const app = uWS.App();

app.get(
  '/cache',
  async (res) => {
    return res.cork(() => {
      res.writeHeader('content-type', 'application/json');
      return res.end(JSON.stringify({ status: 'ok' }));
    });
  },
  5
);

app.get('/no-cache', async (res) => {
  return res.cork(() => {
    res.writeHeader('content-type', 'application/json');
    return res.end(JSON.stringify({ status: 'ok' }));
  });
});

app.listen('0.0.0.0', 4000, (socket) =>
  socket ? console.log('Listen at 4000') : console.log('Failed to listen')
);

Running

node --watch server.js

Benchmark

uws on  master [?] is 📦 v1.0.0 via 🥟 v1.1.21 via ⬢ v22.5.1 
❯ wrk http://localhost:4000/no-cache -c8 -t4 -d 3s
Running 3s test @ http://localhost:4000/no-cache
  4 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    40.19us   17.61us 569.00us   75.05%
    Req/Sec    47.31k     2.38k   49.17k    96.77%
  583699 requests in 3.10s, 77.93MB read
Requests/sec: 188320.56
Transfer/sec:     25.14MB
uws on  master [?] is 📦 v1.0.0 via 🥟 v1.1.21 via ⬢ v22.5.1 took 3s 
❯ wrk http://localhost:4000/cache -c8 -t4 -d 3s
Running 3s test @ http://localhost:4000/cache
  4 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    40.32us   17.39us 543.00us   75.14%
    Req/Sec    47.16k     2.37k   49.31k    96.77%
  581683 requests in 3.10s, 77.66MB read
Requests/sec: 187676.00
Transfer/sec:     25.06MB

There i do not see difference

@uNetworkingAB
Copy link
Contributor Author

It will print that it is using cache and you will get a uWS.CachedHttpResponse if you have latest. But macos is broken on GitHub Actions right now so can't update binaries

@uNetworkingAB
Copy link
Contributor Author

Registering cached get handler
Listening to port 9001
uWS.CachedHttpResponse {}

@dalisoft
Copy link

dalisoft commented Aug 2, 2024

@uNetworkingAB Tested on Debian bullseye and it is crashes. Seems related to HttpResponse.cork, for caching async requests + cork does not work?

Code above on #1073 (comment) comment

vscode ➜ /workspaces/uws (master) $ curl http://localhost:4000/no-cache --output -
{"status":"ok"}vscode ➜ /workspaces/uws (master) $ curl http://alhost:4000/no-cache --output -
curl: (52) Empty reply from server
vscode ➜ /workspaces/uws (master) $ 
Restarting 'uws-app.js'
Registering cached get handler
Listening at 4000
file:///workspaces/uws/uws-app.js:9
    return res.cork(() => {
               ^

TypeError: res.cork is not a function
    at file:///workspaces/uws/uws-app.js:9:16

Node.js v22.5.1

@dalisoft
Copy link

dalisoft commented Aug 2, 2024

Removed .cork and used only writeHeader + end, still failing.

Restarting 'uws-app.js'
Registering cached get handler
Listening at 4000
file:///workspaces/uws/uws-app.js:9
      res.writeHeader('content-type', 'application/json');
          ^

TypeError: res.writeHeader is not a function
    at file:///workspaces/uws/uws-app.js:9:11

Node.js v22.5.1

@uNetworkingAB
Copy link
Contributor Author

Consider using the provided example.

@dalisoft
Copy link

dalisoft commented Aug 2, 2024

Example works. And if use only .end method, it works

@uNetworkingAB
Copy link
Contributor Author

I know, I made the example.

@dalisoft
Copy link

dalisoft commented Aug 2, 2024

Yes, now i know why it is not working, found at here, thank you

@uNetworkingAB
Copy link
Contributor Author

Can you post your benchmark results of it?

@dalisoft
Copy link

dalisoft commented Aug 2, 2024

Yes, sure

vscode ➜ /workspaces/uws (master) $ wrk http://localhost:4000/no-cache -c8 -t4 -d 3s
Running 3s test @ http://localhost:4000/no-cache
  4 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    93.47us  262.40us   2.49ms   94.85%
    Req/Sec    50.11k    10.88k   76.56k    75.61%
  613205 requests in 3.10s, 63.16MB read
Requests/sec: 197839.66
Transfer/sec:     20.38MB
vscode ➜ /workspaces/uws (master) $ wrk http://localhost:4000/cache -c8 -t4 -d 3s
Running 3s test @ http://localhost:4000/cache
  4 threads and 8 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    52.88us  158.58us   5.52ms   96.61%
    Req/Sec    62.82k    14.74k   97.13k    77.42%
  775019 requests in 3.10s, 79.82MB read
Requests/sec: 250077.36
Transfer/sec:     25.76MB
vscode ➜ /workspaces/uws (master) $ 

Between 20% to 25% performance boost

@uasan
Copy link

uasan commented Aug 3, 2024

20% - 25% the increase in relation to a simple JS synchronous response, if you cache the response of the SQL request, then the increase will be an order of magnitude greater

@klauss194
Copy link

20% - 25% the increase in relation to a simple JS synchronous response, if you cache the response of the SQL request, then the increase will be an order of magnitude greater

How would you do that ( cache response of the sql request ) <- can you give an example of a situation? In which context would you use that ?

@uNetworkingAB
Copy link
Contributor Author

One example is if you write a service that provides public data like the price of gold. With the cache you can write it very lazily with direct calls to the source, without fearing poor performance. The cache makes your lazy coding fast (faster than what you could do in JS yourself). 10 seconds cache is more than enough to make it go from crappy to supercharged, and you need no business logic other than some crappy getter client.

@uNetworkingAB
Copy link
Contributor Author

It's basically rate limiting, the cache. So you can use it to rate limit slow endpoints.

@uasan
Copy link

uasan commented Aug 7, 2024

How would you do that ( cache response of the sql request ) <- can you give an example of a situation? In which context would you use that ?

Well, this is a classic case, the database is usually a bottleneck, it makes sense to cache queries.

The concept is simple

respondHTPP(await sql`SELECT ....`);

You send only 1 request to database, many clients will receive this response from the cache.

When the data in the database has changed, you can use the subscription and notification method Postgres, to invalidate the cache
https://www.postgresql.org/docs/current/sql-notify.html

@uNetworkingAB
Copy link
Contributor Author

Yes and then you have basically reimplemented https://rethinkdb.com/ 😄

@uasan
Copy link

uasan commented Aug 7, 2024

@uNetworkingAB there is also a useful caching mode, now many people use proxies, cloudflare or nginx, they can cache, but they need to give the 304 status if the If-None-Match request header is equal to the Etag response header, then you can not store the response body, it is enough to store the URL map and Etag, give the 304 status if they are equal, this is useful not only for proxies, but also for a private client cache, which is not effective to store on the server, it is more effective to store in the browser cache and give the browser the 304 status, think about it

@uNetworkingAB
Copy link
Contributor Author

You just add Etag and If-None-Match to the list of headers. Or just don't use cache for those endpoints

@uasan
Copy link

uasan commented Aug 7, 2024

The whole point is in the 304 status, from the server to the proxy or/and browse, zero traffic in the response body and client private cache.
The Etag header is not a key to the cache, it is a cache validator, I don't see the point in adding it to the cache key.

@uNetworkingAB
Copy link
Contributor Author

Cache has no logic. If you want to handle such a case, you need to invalidate the cache by adding Etag and If-None-Match to the cache key and implement that logic in your handler.

@uasan
Copy link

uasan commented Aug 7, 2024

304 status by Etag, implemented all caching servers, calling the JS code for this purpose reduces the efficiency of caching, Etag is not business logic, it is the logic of HTTP caching which is clearly described in the HTTP protocol

@webcarrot
Copy link

webcarrot commented Aug 8, 2024

IMHO it's possible to build public cache (no auth, headers, query validation) with DeclarativeResponse ( and it is easy to build our favourite and lovely JS abstraction around it 😉 ):

// example quite bad but simple code

const { App, DeclarativeResponse } = require("uWebSockets.js");
const { setTimeout } = require("node:timers/promises");

const app = new App();
app.listen(3000, () => {});
// http://127.0.0.1:3000/price-of/au

app.get("/price-of/:thing", async (res, req) => {
  const ac = new AbortController();
  const signal = ac.signal;
  res.onAborted(() => ac.abort());
  const thing = req.getParameter("thing");
  const path = req.getUrl();
  const data = await getPriceInfo(thing, ac.signal);
  pricesToCheck.push(thing);
  setJsonCache(path, data);
  if (!signal.aborted) {
    res.cork(() => {
      res.writeStatus("200 OK");
      res.writeHeader("content-type", "application/json");
      res.end(JSON.stringify(data));
    });
  }
});

// Set cache using DeclarativeResponse / Uint8Array
function setJsonCache(path, data) {
  app.get(
    path,
    new DeclarativeResponse()
      .writeHeader("content-type", "application/json")
      .end(JSON.stringify(data))
  );
}

// Fetch from DB or something
async function getPriceInfo(thing, signal) {
  setTimeout(5, null, { signal }); // 5ms
  return {
    id: thing,
    value: parseFloat((Math.random() * 100_000).toFixed(4)),
    date: new Date().toISOString(),
  };
}

// Refresh cache every 5s
const pricesToCheck = [];
setInterval(async () => {
  for (const thing of pricesToCheck) {
    setJsonCache(`/price-of/${thing}`, await getPriceInfo(thing));
  }
}, 5_000);

@uasan
Copy link

uasan commented Aug 8, 2024

This is understandable, but the closer the cache is to the consumer (private cache in the browser, public in the load balancer), the better and faster, between the server and the consumer, if possible, just 304 statuses without the response body should be transmitted, because the consumer already has this body

@webcarrot
Copy link

just 304 statuses without the response body should be transmitted, because the consumer already has this body

I'm poisoned by the microservices environment - no http response caching on clients side to avoid memory leaks (which is in many cases just stupid).

@uasan
Copy link

uasan commented Aug 8, 2024

Without 304 status, the cache will work, but there will be increased memory consumption, network load, the result is lower speed.

It's a pity that TechEmpower does not have a cache test with and without status 304, because solutions with status 304 will always be faster than solutions without it, a low position in the benchmark rating could motivate @uNetworkingAB )

@7iomka
Copy link

7iomka commented Oct 17, 2024

any news?

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

No branches or pull requests

9 participants