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

chore: doc and tests for lock middleware #4

Merged
merged 1 commit into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/src/content/docs/built-in-middlewares/lock.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: Lock Middleware
description: Built-in middleware to lock messages.
---

import { Icon, Aside } from '@astrojs/starlight/components';

The Lock Middleware is built-in middleware that gives you capability to lock messages.
It's available for _CommandBus_ and _QueryBus_

## How to use it

As for any Middleware, you can use it by adding it to the `bus` instance.

```typescript
const commandBus = createCommandBus<CommandHandlerRegistry>();
commandBus.useLockMiddleware({
getLockKey: (envelope) => envelope.message.id,
}, {
adapter,
ttl: 500, // the default ttl for all messages
tick: 100, // the delay between each try to get the lock
timeout: 1000 // the maximum time to wait for the lock
}
)
```

> Remember built-in middlewares are _intent_ aware, therefore you can customize the behavior per intent using the key `intents`.

Of course, the key is in the `adapter` that you will provide to the `createLockMiddleware` function.
This adapter must respect the `LockAdapter` interface.

Here is an example:

```typescript
type LockerInfo = {
expiresAt: number;
};

export const createInMemoryLockAdapter = (): LockAdapter => {
const store: Map<string, LockerInfo> = new Map();

return {
acquire: async (key, ttl) => {
if (store.has(key)) {
if (store.get(key)!.expiresAt > Date.now()) {
return false;
}
}
const now = Date.now();
const expiresAt = now + ttl;
store.set(key, { expiresAt });
return true;
},
release: async (key) => {
store.delete(key);
},
};
};

const adapter = createInMemoryLockAdapter();
```
<Aside title="We got you cover!" type="tip">
Do not copy/paste this boilerplate, if `adapter` is not passed to the `createLockMiddleware` function,
it will default to `inMemoryLockAdapter` and provide you with the basic locker above.

Therefore, it is not really recommended to use the default adapter as it is won't be efficient or reliable for multi-node applications.
</Aside>

### Explanation

With the Lock Middleware, if the intent is already locked, it will be returned directly without
calling the handler.

You have to pass at least one of the following parameters to the `createLockMiddleware` function:
- `getLockKey`: a function that takes an envelope and returns a string key (the one that will be used to identify the lock)

Then as a second argument, you can provide the following parameters (and per intent):
- **adapter**: the adapter to use to store the lock (default: `inMemoryLockAdapter`)
- **ttl**: number (default: `500`) - the time to live in milliseconds
- **tick**: number (default: `100`) - the delay between each try to get the lock
- **timeout**: number (default: `1000`) - the maximum time to wait for the lock

## Added Stamps

No stamps are added by this middleware.


## Going further

<div class='flex flex-row'>
<span className='pr-2'>Look at the code of the </span>
<a href="https://github.com/Missive-js/missive.js/tree/main/libs/missive.js/src/middlewares/lock-middleware.ts" class="contents" target="_blank"><Icon name="github" class="mr-2"/>Lock Middleware</a>
</div>

1 change: 1 addition & 0 deletions docs/src/content/docs/guides/middlewares.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ You can also use the built-in middleware in a simpler way
const queryBus: QueryBus = createQueryBus<QueryHandlerRegistry>();
queryBus.useLoggerMiddleware();
queryBus.useCacherMiddleware();
queryBus.useLockMiddleware({ getLockKey: (envelope) => envelope.message.id });
queryBus.use(myMiddleware);
```

Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ We love clean architecture and CQRS, we didn't find any simple Service Bus that
<Card title="Middlewares" icon="open-book">
Can we call it a Service Bus without Middlewares? _(we don't)_. Register middlewares to leverage the full power
of the service bus.
We got you covered with built-in middlewares like [Logger](/missive.js/built-in-middlewares/logger), [Caching](/missive.js/built-in-middlewares/cacher), [Retry](/missive.js/built-in-middlewares/retryer) or [Webhook](/missive.js/built-in-middlewares/webhook).
We got you covered with built-in middlewares like [Logger](/missive.js/built-in-middlewares/logger), [Caching](/missive.js/built-in-middlewares/cacher), [Retry](/missive.js/built-in-middlewares/retryer), [Lock](/missive.js/built-in-middlewares/lock) or [Webhook](/missive.js/built-in-middlewares/webhook).
</Card>
<Card title="Envelopes and Stamps" icon="email">
Handle cross-cutting concerns with Envelopes and Stamps.
Expand Down Expand Up @@ -132,7 +132,7 @@ That's the power of Missive.js! Here are the built-in middlewares:

<Card title="Lock Middleware" icon="seti:lock">
<p>It provide a way to lock the processing of a message based on something in the intent itself. For instance your could lock the `command` that touches a Cart.</p>
<LinkButton href={'/missive.js/contributing'} variant="secondary" class="float-right" ><Badge text='coming soon' variant='caution'/></LinkButton>
<LinkButton href={'/missive.js/built-in-middlewares/lock'} variant="secondary" class="float-right" title={"Read the doc about the Lock Middleware"}>Read the doc</LinkButton>
</Card>

</CardGrid>
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/why.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ centralized system for handling Commands, Queries, and Events, allowing you to b

And on top of what you can do on your own, Missive.js provides a set of built-in middlewares to handle common concerns like:
- [**Caching**](/missive.js/built-in-middlewares/cacher): Cache the results of queries to speed up your application.
- [**Lock**](/missive.js/built-in-middlewares/lock): Lock the processing of a message based on something in the intent itself.
- [**Retryer**](/missive.js/built-in-middlewares/retryer): Retry handling if an error occurs.
- [**Webhook**](/missive.js/built-in-middlewares/webhook): Send the envelope to webhook(s).
- [**Logger**](/missive.js/built-in-middlewares/logger): Log the messages and the full Envelope (with the Stamps) before and once handled (or errored) in the backend of your choice.
Expand Down
35 changes: 35 additions & 0 deletions libs/missive.js/tests/adapter/in-memory-lock-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { createInMemoryLockAdapter } from '../../src/adapters/in-memory-lock-adapter';

describe('InMemoryLockAdapter', () => {
const adapter = createInMemoryLockAdapter();
it('should acquire a lock', async () => {
const isAcquired = await adapter.acquire('key', 100);
expect(isAcquired).toBe(true);

await adapter.release('key');
});
it('should be able to acquire a lock after the lock has expired', async () => {
const isAcquired = await adapter.acquire('test', 0);
expect(isAcquired).toBe(true);

const isAcquired2 = await adapter.acquire('test', 100);
expect(isAcquired2).toBe(true);

await adapter.release('test');
});

it('should not be able to acquire a lock if it is not released', async () => {
const isAcquired = await adapter.acquire('another-key', 10000);
expect(isAcquired).toBe(true);

const isAcquired2 = await adapter.acquire('another-key', 10000);
expect(isAcquired2).toBe(false);

await adapter.release('another-key');
});

it('should be able to release a lock', async () => {
await adapter.release('non-existing-key');
});
});
118 changes: 118 additions & 0 deletions libs/missive.js/tests/lock-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Envelope } from '../src/core/envelope';
import { TypedMessage } from '../src/core/bus';
import { createLockMiddleware, LockAdapter } from '../src/middlewares/lock-middleware';

type MessageRegistry = {
'test-message': {
command: { id: number };
result: {
data: string;
};
};
};
describe('createLockMiddleware', () => {
let next: ReturnType<typeof vi.fn>;
let envelope: Envelope<TypedMessage<MessageRegistry['test-message']['command']>>;
let adapter: LockAdapter;

beforeEach(() => {
next = vi.fn();
envelope = {
message: { __type: 'test-message', id: 1 },
stamps: [],
stampsOfType: vi.fn(),
addStamp: vi.fn(),
firstStamp: vi.fn(),
lastStamp: vi.fn(),
};
adapter = {
acquire: vi.fn(),
release: vi.fn(),
};
});

it('work when everything is working', async () => {
const middleware = createLockMiddleware<'command', MessageRegistry>(
{
getLockKey: (e) => {
console.log(e.message.id);
return e.message.id.toString();
},
},
{
adapter,
timeout: 0,
},
);

(adapter.acquire as ReturnType<typeof vi.fn>).mockResolvedValue(true);

await middleware(envelope, next);

expect(next).toHaveBeenCalled();
expect(adapter.acquire).toHaveBeenCalledOnce();
expect(adapter.release).toHaveBeenCalledOnce();
});
it('should throw an error if next is throwing an error', async () => {
const middleware = createLockMiddleware<'command', MessageRegistry>(
{
getLockKey: (e) => {
console.log(e.message.id);
return e.message.id.toString();
},
},
{
adapter,
timeout: 0,
},
);

(adapter.acquire as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(next as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Test Error'));

await expect(middleware(envelope, next)).rejects.toThrow('Test Error');
expect(adapter.acquire).toHaveBeenCalledOnce();
expect(adapter.release).toHaveBeenCalledOnce();
});

it('should throw an error if the lock is not acquired and there is no timeout', async () => {
const middleware = createLockMiddleware<'command', MessageRegistry>(
{
getLockKey: (e) => {
console.log(e.message.id);
return e.message.id.toString();
},
},
{
adapter,
timeout: 0,
},
);
(adapter.acquire as ReturnType<typeof vi.fn>).mockResolvedValue(false);

await expect(middleware(envelope, next)).rejects.toThrow('Lock not acquired or timeout');
});

it('should retry to get the lock', async () => {
const middleware = createLockMiddleware<'command', MessageRegistry>(
{
getLockKey: (e) => {
console.log(e.message.id);
return e.message.id.toString();
},
},
{
adapter,
timeout: 200,
},
);
(adapter.acquire as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false).mockResolvedValueOnce(true);

await middleware(envelope, next);

expect(next).toHaveBeenCalled();
expect(adapter.acquire).toHaveBeenCalledTimes(2);
expect(adapter.release).toHaveBeenCalledOnce();
});
});