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

feat!: DeleteAll match pattern and v5 adjustments #174

Merged
merged 5 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
28 changes: 17 additions & 11 deletions doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ Cloud State Management
* *[.getRegionalEndpoint(endpoint, region)](#AdobeState+getRegionalEndpoint) ⇒ <code>string</code>*
* *[.get(key)](#AdobeState+get) ⇒ [<code>Promise.&lt;AdobeStateGetReturnValue&gt;</code>](#AdobeStateGetReturnValue)*
* *[.put(key, value, [options])](#AdobeState+put) ⇒ <code>Promise.&lt;string&gt;</code>*
* *[.delete(key)](#AdobeState+delete) ⇒ <code>Promise.&lt;string&gt;</code>*
* *[.deleteAll()](#AdobeState+deleteAll) ⇒ <code>Promise.&lt;boolean&gt;</code>*
* *[.delete(key)](#AdobeState+delete) ⇒ <code>Promise.&lt;(string\|null)&gt;</code>*
* *[.deleteAll(options)](#AdobeState+deleteAll) ⇒ <code>Promise.&lt;({keys: number}\|null)&gt;</code>*
* *[.any()](#AdobeState+any) ⇒ <code>Promise.&lt;boolean&gt;</code>*
* *[.stats()](#AdobeState+stats) ⇒ <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|boolean)&gt;</code>*
* *[.stats()](#AdobeState+stats) ⇒ <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|null)&gt;</code>*
* *[.list(options)](#AdobeState+list) ⇒ <code>AsyncGenerator.&lt;{keys: Array.&lt;string&gt;}&gt;</code>*

<a name="AdobeState+getRegionalEndpoint"></a>
Expand Down Expand Up @@ -108,37 +108,43 @@ Creates or updates a state key-value pair

<a name="AdobeState+delete"></a>

### *adobeState.delete(key) ⇒ <code>Promise.&lt;string&gt;</code>*
### *adobeState.delete(key) ⇒ <code>Promise.&lt;(string\|null)&gt;</code>*
Deletes a state key-value pair

**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
**Returns**: <code>Promise.&lt;string&gt;</code> - key of deleted state or `null` if state does not exist
**Returns**: <code>Promise.&lt;(string\|null)&gt;</code> - key of deleted state or `null` if state does not exist

| Param | Type | Description |
| --- | --- | --- |
| key | <code>string</code> | state key identifier |

<a name="AdobeState+deleteAll"></a>

### *adobeState.deleteAll() ⇒ <code>Promise.&lt;boolean&gt;</code>*
Deletes all key-values
### *adobeState.deleteAll(options) ⇒ <code>Promise.&lt;({keys: number}\|null)&gt;</code>*
Deletes all key-values.

**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
**Returns**: <code>Promise.&lt;boolean&gt;</code> - true if deleted, false if not
**Returns**: <code>Promise.&lt;({keys: number}\|null)&gt;</code> - returns an object with the number of deleted keys or `null` if there are no keys in the container.

| Param | Type | Description |
| --- | --- | --- |
| options | <code>object</code> | deleteAll options. |
| options.match | <code>string</code> | only delete keys matching the glob pattern (supports '*'). |

<a name="AdobeState+any"></a>

### *adobeState.any() ⇒ <code>Promise.&lt;boolean&gt;</code>*
There exists key-values.
There exists key-values in the region.

**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
**Returns**: <code>Promise.&lt;boolean&gt;</code> - true if exists, false if not
<a name="AdobeState+stats"></a>

### *adobeState.stats() ⇒ <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|boolean)&gt;</code>*
### *adobeState.stats() ⇒ <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|null)&gt;</code>*
Get stats.

**Kind**: instance method of [<code>AdobeState</code>](#AdobeState)
**Returns**: <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|boolean)&gt;</code> - namespace stats or false if not exists
**Returns**: <code>Promise.&lt;({bytesKeys: number, bytesValues: number, keys: number}\|null)&gt;</code> - namespace stats or `null` if there are no keys in the container.
<a name="AdobeState+list"></a>

### *adobeState.list(options) ⇒ <code>AsyncGenerator.&lt;{keys: Array.&lt;string&gt;}&gt;</code>*
Expand Down
82 changes: 49 additions & 33 deletions e2e/e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const { MAX_TTL_SECONDS } = require('../lib/constants')
const stateLib = require('../index')
const { randomInt } = require('node:crypto')

const uniquePrefix = `${Date.now()}.${randomInt(10)}`
const uniquePrefix = `${Date.now()}.${randomInt(100)}`
const testKey = `${uniquePrefix}__e2e_test_state_key`
const testKey2 = `${uniquePrefix}__e2e_test_state_key2`

Expand All @@ -34,13 +34,33 @@ const initStateEnv = async (n = 1) => {
process.env.__OW_API_KEY = process.env[`TEST_AUTH_${n}`]
process.env.__OW_NAMESPACE = process.env[`TEST_NAMESPACE_${n}`]
const state = await stateLib.init()
// // make sure we cleanup the namespace, note that delete might fail as it is an op under test
// await state.delete(`${uniquePrefix}*`)
await state.delete(testKey)
await state.delete(testKey2)
// make sure we cleanup the namespace, note that delete might fail as it is an op under test
await state.deleteAll({ match: `${uniquePrefix}*` })
return state
}

// helpers
const genKeyStrings = (n, identifier) => {
return (new Array(n).fill(0).map((_, idx) => {
const char = String.fromCharCode(97 + idx % 26)
// list-[a-z]-[0-(N-1)]
return `${uniquePrefix}__${identifier}_${char}_${idx}`
}))
}
const putKeys = async (state, keys, ttl) => {
const _putKeys = async (keys, ttl) => {
await Promise.all(keys.map(async (k, idx) => await state.put(k, `value-${idx}`, { ttl })))
}

const batchSize = 20
let i = 0
while (i < keys.length - batchSize) {
await _putKeys(keys.slice(i, i + batchSize), ttl)
i += batchSize
}
// final call
await _putKeys(keys.slice(i), ttl)
}
const waitFor = (ms) => new Promise(resolve => setTimeout(resolve, ms))

test('env vars', () => {
Expand Down Expand Up @@ -146,34 +166,12 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
await expect(state.put(testKey, testValue, { ttl: -1 })).rejects.toThrow()
})

test('listKeys test: few < 128 keys, many, and expired entries', async () => {
test('listKeys test: few, many, and expired entries', async () => {
const state = await initStateEnv()

const genKeyStrings = (n) => {
return (new Array(n).fill(0).map((_, idx) => {
const char = String.fromCharCode(97 + idx % 26)
// list-[a-z]-[0-(N-1)]
return `${uniquePrefix}__list_${char}_${idx}`
}))
}
const putKeys = async (keys, ttl) => {
const _putKeys = async (keys, ttl) => {
await Promise.all(keys.map(async (k, idx) => await state.put(k, `value-${idx}`, { ttl })))
}

const batchSize = 20
let i = 0
while (i < keys.length - batchSize) {
await _putKeys(keys.slice(i, i + batchSize), ttl)
i += batchSize
}
// final call
await _putKeys(keys.slice(i), ttl)
}

// 1. test with not many elements, one iteration should return all
const keys90 = genKeyStrings(90).sort()
await putKeys(keys90, 60)
const keys90 = genKeyStrings(90, 'list').sort()
await putKeys(state, keys90, 60)

let it, ret

Expand Down Expand Up @@ -203,8 +201,8 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
expect(await it.next()).toEqual({ done: true, value: undefined })

// 2. test with many elements and large countHint
const keys900 = genKeyStrings(900)
await putKeys(keys900, 60)
const keys900 = genKeyStrings(900, 'list')
await putKeys(state, keys900, 60)

// note: we can't list in isolation without prefix
it = state.list({ countHint: 1000 })
Expand Down Expand Up @@ -255,7 +253,7 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
expect(retArray.length).toEqual(1)

// 4. make sure expired keys aren't listed
await putKeys(keys90, 1)
await putKeys(state, keys90, 1)
await waitFor(2000)

it = state.list({ countHint: 1000, match: `${uniquePrefix}__list_*` })
Expand All @@ -264,6 +262,24 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
expect(await it.next()).toEqual({ done: true, value: undefined })
})

test('deleteAll with match test', async () => {
const state = await initStateEnv()

const keys90 = genKeyStrings(90, 'deleteAll').sort()
await putKeys(state, keys90, 60)

// < 100 keys
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_a*` })).toEqual({ keys: 4 })
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*` })).toEqual({ keys: 86 })

// > 1000 keys
const keys1100 = genKeyStrings(1100, 'deleteAll').sort()
await putKeys(state, keys1100, 60)
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*_1` })).toEqual({ keys: 1 })
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*_1*0` })).toEqual({ keys: 21 }) // 10, 100 - 190, 1000-1090
expect(await state.deleteAll({ match: `${uniquePrefix}__deleteAll_*` })).toEqual({ keys: 1078 })
})

test('throw error when get/put with invalid keys', async () => {
const invalidKey = 'some/invalid:key'
const state = await initStateEnv()
Expand Down
50 changes: 40 additions & 10 deletions lib/AdobeState.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ class AdobeState {
* Deletes a state key-value pair
*
* @param {string} key state key identifier
* @returns {Promise<string>} key of deleted state or `null` if state does not exist
* @returns {Promise<string|null>} key of deleted state or `null` if state does not exist
* @memberof AdobeState
*/
async delete (key) {
Expand All @@ -395,12 +395,14 @@ class AdobeState {
}

/**
* Deletes all key-values
* Deletes all key-values.
*
* @returns {Promise<boolean>} true if deleted, false if not
* @param {object} options deleteAll options.
* @param {string} options.match only delete keys matching the glob pattern (supports '*').
* @returns {Promise<{ keys: number }|null>} returns an object with the number of deleted keys or `null` if there are no keys in the container.
* @memberof AdobeState
*/
async deleteAll () {
async deleteAll (options = {}) {
const requestOptions = {
method: 'DELETE',
headers: {
Expand All @@ -410,13 +412,40 @@ class AdobeState {

logger.debug('deleteAll', requestOptions)

const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions)
const schema = {
type: 'object',
properties: {
match: { type: 'string', pattern: REGEX_PATTERN_LIST_KEY_MATCH } // this is an important check
}
}

const { valid, errors } = validate(schema, options)
if (!valid) {
logAndThrow(new codes.ERROR_BAD_ARGUMENT({
messageValues: utils.formatAjvErrors(errors),
sdkDetails: { options, errors }
}))
}

const queryParams = {}
if (options.match) {
// note the matchData instead of match
queryParams.matchData = options.match
}

const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl('', queryParams), requestOptions)
const response = await _wrap(promise, {})
return (response.status !== 404)

if (response.status === 404) {
return null
} else {
const { keys } = await response.json()
return { keys }
}
}

/**
* There exists key-values.
* There exists key-values in the region.
*
* @returns {Promise<boolean>} true if exists, false if not
* @memberof AdobeState
Expand All @@ -439,7 +468,7 @@ class AdobeState {
/**
* Get stats.
*
* @returns {Promise<{ bytesKeys: number, bytesValues: number, keys: number} | boolean>} namespace stats or false if not exists
* @returns {Promise<{ bytesKeys: number, bytesValues: number, keys: number} | null>} namespace stats or `null` if there are no keys in the container.
* @memberof AdobeState
*/
async stats () {
Expand All @@ -455,9 +484,10 @@ class AdobeState {
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions)
const response = await _wrap(promise, {})
if (response.status === 404) {
return false
return null
} else {
return response.json()
const { keys, bytesKeys, bytesValues } = await response.json()
return { keys, bytesKeys, bytesValues }
}
}

Expand Down
28 changes: 24 additions & 4 deletions test/AdobeState.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -377,12 +377,18 @@ describe('deleteAll', () => {
store = await AdobeState.init(fakeCredentials)
})

test('invalid match option', async () => {
await expect(store.deleteAll({ match: ':isaninvalidchar' })).rejects.toThrow('/match must match pattern')
await expect(store.deleteAll({ match: '{isaninvalidchar' })).rejects.toThrow('/match must match pattern')
await expect(store.deleteAll({ match: '}isaninvalidchar' })).rejects.toThrow('/match must match pattern')
})

test('success', async () => {
const fetchResponseJson = {}
const fetchResponseJson = JSON.stringify({ keys: 10 })
mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson))

const value = await store.deleteAll()
expect(value).toEqual(true)
expect(value).toEqual({ keys: 10 })

expect(mockExponentialBackoff)
.toHaveBeenCalledWith(
Expand All @@ -391,11 +397,25 @@ describe('deleteAll', () => {
)
})

test('success with match', async () => {
const fetchResponseJson = JSON.stringify({ keys: 10 })
mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson))

const value = await store.deleteAll({ match: 'some.patter-_*' })
expect(value).toEqual({ keys: 10 })

expect(mockExponentialBackoff)
.toHaveBeenCalledWith(
'https://storage-state-amer.app-builder.adp.adobe.io/containers/some-namespace?matchData=some.patter-_*',
expect.objectContaining({ method: 'DELETE' })
)
})

test('not found', async () => {
mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404))

const value = await store.deleteAll()
expect(value).toEqual(false)
expect(value).toEqual(null)
})
})

Expand Down Expand Up @@ -424,7 +444,7 @@ describe('stats()', () => {
mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404))

const value = await store.stats()
expect(value).toEqual(false)
expect(value).toEqual(null)
})
})

Expand Down
18 changes: 11 additions & 7 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,26 @@ export class AdobeState {
* @param key - state key identifier
* @returns key of deleted state or `null` if state does not exist
*/
delete(key: string): Promise<string>;
delete(key: string): Promise<string | null>;
/**
* Deletes all key-values
* @returns true if deleted, false if not
* Deletes all key-values.
* @param options - deleteAll options.
* @param options.match - only delete keys matching the glob pattern (supports '*').
* @returns returns an object with the number of deleted keys or `null` if there are no keys in the container.
*/
deleteAll(): Promise<boolean>;
deleteAll(options: {
match: string;
}): Promise<{ keys: number; } | null>;
/**
* There exists key-values.
* There exists key-values in the region.
* @returns true if exists, false if not
*/
any(): Promise<boolean>;
/**
* Get stats.
* @returns namespace stats or false if not exists
* @returns namespace stats or `null` if there are no keys in the container.
*/
stats(): Promise<{ bytesKeys: number; bytesValues: number; keys: number; } | boolean>;
stats(): Promise<{ bytesKeys: number; bytesValues: number; keys: number; } | null>;
/**
* List keys, returns an iterator. Every iteration returns a batch of
* approximately `countHint` keys.
Expand Down
Loading