Skip to content

Commit

Permalink
Rate limit (#43)
Browse files Browse the repository at this point in the history
* wip

* test++

* add rateLimit to default functions

* add example and readme

* copyright++, lock files++

* fix import

---------

Co-authored-by: Sergey Sergeev <[email protected]>
  • Loading branch information
zhirafovod and Sergey Sergeev authored Jan 24, 2024
1 parent 6c70764 commit 77f5037
Show file tree
Hide file tree
Showing 8 changed files with 221 additions and 42 deletions.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1892,6 +1892,48 @@ Started tailing... Press Ctrl+C to stop.
```
Here is a screencapture showing the two commands above executed in the REPL.
![tailing deferred](https://raw.githubusercontent.com/geoffhendrey/jsonataplay/main/taildefer.gif)
## $rateLimit function
Rate limiting allows to ensure than no than one function call is executed withing some time. For exameple,
we want to ensure that a function calling external APIs does not overload it.
```json
>.init -f example/rateLimit.yaml
{
"acc": [],
"appendAcc": "${ function($v){$set('/acc/-', $v)} ~> $rateLimit(100)}",
"counter": "${ function(){($set('/count', $$.count+1); $$.count)} }",
"count": 0,
"rapidCaller": "${$setInterval(counter~>appendAcc, 10)}",
"stop": "${ count=100?($clearInterval($$.rapidCaller);'done'):'not done' }",
"accCount": "${ $count(acc) }"
}
```
Below output demonstrates, that `rateLimit` function calls to to set `acc` to once in no less than 100ms, which will
result in only 10 counts added o the `acc` array, the first one, the last one, and 10 in between.
```json ["data.accCounter = 12"]
.init -f example/rateLimit.yaml --tail "/ until accCount=12"
Started tailing... Press Ctrl+C to stop.
{
"acc": [
1,
10,
19,
28,
38,
48,
57,
66,
75,
85,
94,
100
],
"counter": "{function:}",
"count": 100,
"rapidCaller": "--interval/timeout--",
"stop": "done",
"accCount": 12
}
```
# Understanding Plans
This information is to explain the planning algorithms to comitters. As a user you do not need to understand how
Stated formulates plans. Before explaining how a plan is made, let's show the end-to-end flow of how a plan is used
Expand Down
8 changes: 8 additions & 0 deletions example/rateLimit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#try running this in REPL with: .init -f example/debounce.yaml --tail "/acc/- 0"
acc: []
appendAcc: "${ function($v){$set('/acc/-', $v)} ~> $rateLimit(100)}" #function to append $v to acc array, debounced to 15 ms
counter: "${ function(){($set('/count', $$.count+1); $$.count)} }" #function to increment a count
count: 0
rapidCaller: "${$setInterval(counter~>appendAcc, 10)}" #increment the count every 10 ms, and send result to appendAcc function which is debounced to 15 ms
stop: "${ count=100?($clearInterval($$.rapidCaller);'done'):'not done' }" #stop when we reached count of 100. Only 100 should wind up in acc array
accCount: "${ $count(acc) }" #count of items in acc array
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"./dist/src/TestUtils.js": "./dist/src/TestUtils.js",
"./dist/src/DependencyFinder.js": "./dist/src/DependencyFinder.js",
"./dist/src/JsonPointer.js": "./dist/src/JsonPointer.js",
"./dist/src/utils/debounce.js": "./dist/src/utils/debounce.js"
"./dist/src/utils/debounce.js": "./dist/src/utils/debounce.js",
"./dist/src/utils/rateLimit.js": "./dist/src/utils/rateLimit.js"
},
"scripts": {
"clean": "rm -rf dist",
Expand Down
5 changes: 3 additions & 2 deletions src/TemplateProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { LOG_LEVELS } from "./ConsoleLogger.js";
import {TimerManager} from "./TimerManager.js";
import { stringifyTemplateJSON } from './utils/stringify.js';
import {debounce} from "./utils/debounce.js"

import {rateLimit} from "./utils/rateLimit.js"

type MetaInfoMap = Record<JsonPointerString, MetaInfo[]>;
export type StatedError = {
Expand Down Expand Up @@ -77,7 +77,8 @@ export default class TemplateProcessor {
setTimeout,
console,
debounce,
Date
Date,
rateLimit
}

private static _isNodeJS = typeof process !== 'undefined' && process.release && process.release.name === 'node';
Expand Down
66 changes: 66 additions & 0 deletions src/test/utils/rateLimit.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2023 Cisco Systems, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { rateLimit } from "../../../dist/src/utils/rateLimit.js";
import {expect, jest} from '@jest/globals'

describe('rateLimit function', () => {
jest.useFakeTimers();
/**
* Below test validates the following scenario
* rateLimitedFunction('First call'); // called at 0ms and Executed immediately
* rateLimitedFunction('Second call'); // called at 500ms, deferred till execution at 1000ms
* rateLimitedFunction('Third call'); // called at 700ms, deferred till execution at 1000ms, and replaces the Second call
* // at 1000ms 'Third call' gets executed.
* rateLimitedFunction('Forth call'); // called at 1100ms and gets executed in 2000ms
**/
it('should rate limit function calls as specified', () => {
const mockFunction = jest.fn();
const maxWait = 1000;
const rateLimitedFunction = rateLimit(mockFunction, maxWait);

// First call - executed immediately
rateLimitedFunction('First call');
expect(mockFunction).toHaveBeenCalledTimes(1);
expect(mockFunction).toHaveBeenCalledWith('First call');

// Second call - deferred
jest.advanceTimersByTime(500);
rateLimitedFunction('Second call');
expect(mockFunction).toHaveBeenCalledTimes(1);

// Third call - replaces second, also deferred
jest.advanceTimersByTime(200);
rateLimitedFunction('Third call');
expect(mockFunction).toHaveBeenCalledTimes(1);

// Executing the deferred 'Third call'
jest.advanceTimersByTime(350);
expect(mockFunction).toHaveBeenCalledTimes(2);
expect(mockFunction).toHaveBeenCalledWith('Third call');

// Fourth call - at 1100ms from start gets defferred till 2000ms
jest.advanceTimersByTime(100);
rateLimitedFunction('Forth call');
jest.advanceTimersByTime(900);
expect(mockFunction).toHaveBeenCalledTimes(3);
expect(mockFunction).toHaveBeenCalledWith('Forth call');

// no more calls expected
jest.advanceTimersByTime(1000);
expect(mockFunction).toHaveBeenCalledTimes(3);
});
});


45 changes: 45 additions & 0 deletions src/utils/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2024 Cisco Systems, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

export function rateLimit<T extends AnyFunction>(func: T, maxWait: number = 1000): T {
let lastCallTime: number | null = null;
let deferredCallTimer: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;

return function (this: ThisParameterType<T>, ...args: Parameters<T>): void {
const context = this as ThisParameterType<T>;
lastArgs = args; // Store the latest arguments

const executeFunction = () => {
lastCallTime = Date.now();
if (lastArgs) {
func.apply(context, lastArgs);
lastArgs = null; // Reset after execution
}
};

if (lastCallTime === null || (Date.now() - lastCallTime) >= maxWait) {
// If this is the first call, or the wait time has passed since the last call
executeFunction();
} else if (!deferredCallTimer) {
// Set up a deferred execution if not already scheduled
deferredCallTimer = setTimeout(() => {
deferredCallTimer = null; // Clear the timer
executeFunction();
}, maxWait - (Date.now() - lastCallTime));
}
} as T;
}

type AnyFunction = (...args: any[]) => void;
Loading

0 comments on commit 77f5037

Please sign in to comment.