-
-
Notifications
You must be signed in to change notification settings - Fork 9
Description
Hi folks, I'm not sure if this is a bug of @nanostores/vue or Astro.
First time writing an issue, not sure if I know the best practices, plz be kind.
There is an issue open in astro's repository probabily related to this.
Symptoms
Web BFF (Astro + Vue + Svelte + NodeJS adapter) was clearly leaking memory over time.
Evidence of the memory leak
- in 20/30 minutes the services were restarting because health checks were failing;
- heap memory usage only increased during the lifetime of the service, eventually reaching the allocated resource limit;
- GC time only increased during the lifetime of the service;
PS: The memory dropping in the graph indicates the pod restart time.
Root causes of the problem
Problem 1 – Incorrect usage of subscribe and listen in nanostores on the server side (SSR)
Since we are running SSR in Node (via @astrojs/node), we should not use store.subscribe() or store.listen() directly on the server, because this creates listeners that are not “cleaned up” between requests. However, some parts of our code were doing this, which quickly accumulated many objects in memory and caused memory usage to continuously increase, never (or very rarely) going down or stabilizing.
To solve this problem we just moved this kind of code to lifecycle hooks like onMounted, because code inside then only run in the client. After fixing this problem, our services started leaking memory much more slowly, but it still leaked.
Image above shows memory usage after fixing problem 1.
Problem 2 – Vue scopes not being cleaned up
The getCurrentScope() from the @nanostores/vue library was returning null in some situations, and onScopeDispose was not being executed, which prevented the cleanup of Nanostores listeners.
Evidence of Problem 2 – Vue scopes not being cleaned up
What we did to identify the problem was:
- we profiled the application right after it started and took a memory snapshot;
- we executed a high load of requests, then stopped, and took another memory snapshot;
- when comparing the two snapshots, it became crystal clear that three types of constructors were growing significantly and none were being deleted:
Dep,RefImpl, andfunction(all with exactly the same number of newly created objects and none removed).
In the image, we are comparing the last snapshot with the first snapshot, with the result sorted by Size Delta. Notice that the first three rows are the ones that account for almost all the memory difference between the two snapshots, and also notice that new, deleted, and delta are identical for the three. Thousands allocated, none removed.
When following the retainers tree of one of these objects, we noticed that one of our stores was being referenced.
Our store can be seen in the line that contains: buyFormContextStore in system / Context @211653
We identified the point in our code that uses this store and noticed that in the server-side code (since our component was client:idle) we were calling the useStore API from the @nanostores/vue library. When debugging this function, we realized that getCurrentScope was returning null, which prevented the cleanup of the created listeners (it is even possible to see the listeners array referencing the store in the image above), causing the listeners array to grow indefinitely.
Work arounds for problem 2
Work around 1
Use the client:only directive in components to force SSR to not happen (client:only directives cause the component code to be executed only on the client).
After applying this solution and running a new profile, the problem was solved and all objects were properly deleted from memory between snapshots.
Work around 2
We created a helper as a workaround, and in all places where we used the useStore API from the @nanostores/vue library, we replaced it with our helper, which basically checks whether we are in an SSR environment and, if so, only returns the state without creating subscriptions.
import { shallowRef, onScopeDispose, getCurrentScope } from 'vue';
import type { Store } from 'nanostores';
export function useStoreSafe<T>(store: Store<T>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const value = store.get?.() ?? (store as any).value;
const state = shallowRef<T>(value);
if (import.meta.env.SSR) {
return state;
}
const unsubscribe = store.subscribe((v) => (state.value = v));
const scope = getCurrentScope();
if (scope) {
onScopeDispose(unsubscribe);
}
return state;
}
How to reproduce the problem
In this repository I built a minimal project with instructions in the README to reproduce the issue.
PS: very easy and very fun to reproduce, give it a try
Our result
After the work around applied, memory usage stabilized between 70 and 90 megabytes.
