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

Recipe: Caches #241

Merged
merged 10 commits into from
Sep 4, 2024
4 changes: 4 additions & 0 deletions hugo/content/docs/recipes/performance/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: "Performance"
weight: 175
---
215 changes: 215 additions & 0 deletions hugo/content/docs/recipes/performance/caches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
---
title: "Caches"
weight: 0
---

## What is the problem?

You have parsed a document and you would like to execute some computation on the AST. But you don’t want to do this every time you see a certain node. You want to do it once for the lifetime of a document. Where to save it?

## How to solve it?

For data that depends on the lifetime of a document or even the entire workspace, Langium has several kinds of caches:

* the document cache saves key-value-pairs of given types `K` and `V` for each document. If the document gets changed or deleted the cache gets cleared automatically for the single files
* the workspace cache also saves key-value-pairs of given types `K` and `V`, but gets cleared entirely when something in the workspace gets changed

Besides those specific caches, Langium also provides:

* a simple cache that can be used for any kind of key-value-data
* a context cache that stores a simple cache for each context object. The document cache and workspace cache are implemented using the context cache

## How to use it?

Here we will use the `HelloWorld` example from the learning section. Let's keep it simple and just list people in a document, which will come from a comic book.

We will have a computation for each person that determines from which publisher it comes from.

### Add a database

Let's build a "publisher inferer service". First let's create a small database of known publishers and known persons:

```typescript
type KnownPublisher = 'DC' | 'Marvel' | 'Egmont';
const KnownPersonNames: Record<KnownPublisher, string[]> = {
DC: ['Superman', 'Batman', 'Aquaman', 'Wonderwoman', 'Flash'],
Marvel: ['Spiderman', 'Wolverine', 'Deadpool'],
Egmont: ['Asterix', 'Obelix']
};
```

### Define the computation service

For our service we define an interface:

```typescript
export interface InferPublisherService {
inferPublisher(person: Person): KnownPublisher | undefined;
}
```

Now we implement the service:

```typescript
class UncachedInferPublisherService implements InferPublisherService {
inferPublisher(person: Person): KnownPublisher | undefined {
for (const [publisher, persons] of Object.entries(KnownPersonNames)) {
if (persons.includes(person.name)) {
return publisher as KnownPublisher;
}
}
return undefined;
}
}
```

### Add a cache

Now we want to cache the results of the `inferPublisher` method. We can use the `DocumentCache` for this. We will reuse the uncached service as base class and override the `inferPublisher` method:

```typescript
export class CachedInferPublisherService extends UncachedInferPublisherService {
private readonly cache: DocumentCache<Person, KnownPublisher | undefined>;
constructor(services: HelloWorldServices) {
super();
this.cache = new DocumentCache(services.shared);
}
override inferPublisher(person: Person): KnownPublisher | undefined {
const documentUri = AstUtils.getDocument(person).uri;
//get cache entry for the documentUri and the person
//if it does not exist, calculate the value and store it
return this.cache.get(documentUri, person, () => super.inferPublisher(person));
}
}
```

### Use the service

To use this service, let's create a validator that checks if the publisher of a person is known. Go to the `hello-world-validator.ts` file and add the following code:

```typescript
import type { ValidationAcceptor, ValidationChecks } from 'langium';
import type { HelloWorldAstType, Person } from './generated/ast.js';
import type { HelloWorldServices } from './hello-world-module.js';
import { InferPublisherService } from './infer-publisher-service.js';

/**
* Register custom validation checks.
*/
export function registerValidationChecks(services: HelloWorldServices) {
const registry = services.validation.ValidationRegistry;
const validator = services.validation.HelloWorldValidator;
const checks: ValidationChecks<HelloWorldAstType> = {
Person: validator.checkPersonIsFromKnownPublisher
};
registry.register(checks, validator);
}

/**
* Implementation of custom validations.
*/
export class HelloWorldValidator {
private readonly inferPublisherService: InferPublisherService;

constructor(services: HelloWorldServices) {
this.inferPublisherService = services.utilities.inferPublisherService;
}

checkPersonIsFromKnownPublisher(person: Person, accept: ValidationAcceptor): void {
if (!this.inferPublisherService.inferPublisher(person)) {
accept('warning', `"${person.name}" is not from a known publisher.`, {
node: person
});
}
}

}
```

### Register the service

Finally, we need to register the service in the module. Go to the `hello-world-module.ts` file and add the following code:

```typescript
export type HelloWorldAddedServices = {
utilities: {
inferPublisherService: InferPublisherService
},
validation: {
HelloWorldValidator: HelloWorldValidator
}
}
//...
export const HelloWorldModule: Module<HelloWorldServices, PartialLangiumServices & HelloWorldAddedServices> = {
utilities: {
inferPublisherService: (services) => new CachedInferPublisherService(services)
},
validation: {
//add `services` parameter here
HelloWorldValidator: (services) => new HelloWorldValidator(services)
}
};
```

### Test the result

Start the extension and create a `.hello` file with several persons, like this one:

```plaintext
person Wonderwoman
person Spiderman
person Homer //warning: unknown publisher!!
person Obelix
```

## Last words

Caching can improve the performance of your language server. It is especially useful for computations that are expensive to calculate. The `DocumentCache` and `WorkspaceCache` are the most common caches to use. The `ContextCache` is useful if you need to store data for a specific context object. If you only need a key-value store, you can use the `SimpleCache`.
All of these caches are disposable compared to a simple `Map<K, V>`. If you dispose them by calling `dispose()` the entries will be removed and the memory will be freed. Plus, from the moment you have called `dispose()`, the cache will not react to changes in the workspace anymore.

## Appendix

<details>
<summary>Full implementation</summary>

```typescript
import { AstUtils, DocumentCache } from "langium";
import { Person } from "./generated/ast.js";
import { HelloWorldServices } from "./hello-world-module.js";

type KnownPublisher = 'DC' | 'Marvel' | 'Egmont';
const KnownPersonNames: Record<KnownPublisher, string[]> = {
DC: ['Superman', 'Batman', 'Aquaman', 'Wonderwoman', 'Flash'],
Marvel: ['Spiderman', 'Wolverine', 'Deadpool'],
Egmont: ['Asterix', 'Obelix']
};

export interface InferPublisherService {
inferPublisher(person: Person): KnownPublisher | undefined;
}

class UncachedInferPublisherService implements InferPublisherService {
inferPublisher(person: Person): KnownPublisher | undefined {
for (const [publisher, persons] of Object.entries(KnownPersonNames)) {
if (persons.includes(person.name)) {
return publisher as KnownPublisher;
}
}
return undefined;
}
}

export class CachedInferPublisherService extends UncachedInferPublisherService {
private readonly cache: DocumentCache<Person, KnownPublisher | undefined>;
constructor(services: HelloWorldServices) {
super();
this.cache = new DocumentCache(services.shared);
}
override inferPublisher(person: Person): KnownPublisher | undefined {
const documentUri = AstUtils.getDocument(person).uri;
return this.cache.get(documentUri, person, () => super.inferPublisher(person));
}
}
```

</details>
Loading