Skip to content

Commit

Permalink
Recipe: Caches (#241)
Browse files Browse the repository at this point in the history
Co-authored-by: Benjamin Friedman Wilson <[email protected]>
  • Loading branch information
Lotes and montymxb authored Sep 4, 2024
1 parent 4dfaa9b commit 7f098d8
Show file tree
Hide file tree
Showing 2 changed files with 219 additions and 0 deletions.
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>

0 comments on commit 7f098d8

Please sign in to comment.