Skip to content

Commit fe75614

Browse files
authored
feat: list keys (#164)
* feat: list keys * remove dead code * adjust parameter changes + e2e tests * fix e2e test * fix comments
1 parent f41cdeb commit fe75614

File tree

5 files changed

+384
-34
lines changed

5 files changed

+384
-34
lines changed

e2e/e2e.js

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
7070
}))
7171
})
7272

73-
test('key-value basic test on one key with string value: put, get, delete, any, deleteAll', async () => {
73+
test('key-value basic test on one key with string value: put, get, delete, any, stats, deleteAll', async () => {
7474
const state = await initStateEnv()
7575

7676
const testValue = 'a string'
@@ -126,6 +126,129 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => {
126126
expect(await state.get(testKey)).toEqual(undefined)
127127
})
128128

129+
test('listKeys test: few < 128 keys, many, and expired entries', async () => {
130+
const state = await initStateEnv()
131+
await state.deleteAll() // cleanup
132+
133+
const genKeyStrings = (n) => {
134+
return (new Array(n).fill(0).map((_, idx) => {
135+
const char = String.fromCharCode(97 + idx % 26)
136+
// list-[a-z]-[0-(N-1)]
137+
return `list-${char}-${idx}`
138+
}))
139+
}
140+
const putKeys = async (keys, ttl) => {
141+
const _putKeys = async (keys, ttl) => {
142+
await Promise.all(keys.map(async (k, idx) => await state.put(k, `value-${idx}`, { ttl })))
143+
}
144+
145+
const batchSize = 20
146+
let i = 0
147+
while (i < keys.length - batchSize) {
148+
await _putKeys(keys.slice(i, i + batchSize), ttl)
149+
i += batchSize
150+
}
151+
// final call
152+
await _putKeys(keys.slice(i), ttl)
153+
}
154+
155+
// 1. test with not many elements, one iteration should return all
156+
const keys90 = genKeyStrings(90).sort()
157+
await putKeys(keys90, 60)
158+
159+
let it = state.list()
160+
let ret = await it.next()
161+
expect(ret.value.keys.sort()).toEqual(keys90)
162+
expect(await it.next()).toEqual({ done: true, value: undefined })
163+
164+
it = state.list({ match: 'list-*' })
165+
ret = await it.next()
166+
expect(ret.value.keys.sort()).toEqual(keys90)
167+
expect(await it.next()).toEqual({ done: true, value: undefined })
168+
169+
it = state.list({ match: 'list-a*' })
170+
ret = await it.next()
171+
expect(ret.value.keys.sort()).toEqual(['list-a-0', 'list-a-26', 'list-a-52', 'list-a-78'])
172+
expect(await it.next()).toEqual({ done: true, value: undefined })
173+
174+
it = state.list({ match: 'list-*-1' })
175+
ret = await it.next()
176+
expect(ret.value.keys.sort()).toEqual(['list-b-1'])
177+
expect(await it.next()).toEqual({ done: true, value: undefined })
178+
179+
// 2. test with many elements and large countHint
180+
const keys900 = genKeyStrings(900)
181+
await putKeys(keys900, 60)
182+
183+
it = state.list({ countHint: 1000 })
184+
ret = await it.next()
185+
expect(ret.value.keys.length).toEqual(900)
186+
expect(await it.next()).toEqual({ done: true, value: undefined })
187+
188+
it = state.list({ countHint: 1000, match: 'list-*' })
189+
ret = await it.next()
190+
expect(ret.value.keys.length).toEqual(900)
191+
expect(await it.next()).toEqual({ done: true, value: undefined })
192+
193+
it = state.list({ countHint: 1000, match: 'list-z*' })
194+
ret = await it.next()
195+
expect(ret.value.keys.length).toEqual(34)
196+
expect(await it.next()).toEqual({ done: true, value: undefined })
197+
198+
it = state.list({ match: 'list-*-1' })
199+
ret = await it.next()
200+
expect(ret.value.keys.sort()).toEqual(['list-b-1'])
201+
expect(await it.next()).toEqual({ done: true, value: undefined })
202+
203+
// 3. test with many elements while iterating
204+
let iterations = 0
205+
let retArray = []
206+
for await (const { keys } of state.list()) {
207+
iterations++
208+
retArray.push(...keys)
209+
}
210+
expect(iterations).toBeGreaterThan(5) // should be around 9-10
211+
expect(retArray.length).toEqual(900)
212+
213+
iterations = 0
214+
retArray = []
215+
for await (const { keys } of state.list({ match: 'list-*' })) {
216+
iterations++
217+
retArray.push(...keys)
218+
}
219+
expect(iterations).toBeGreaterThan(5) // should be around 9-10
220+
expect(retArray.length).toEqual(900)
221+
222+
iterations = 0
223+
retArray = []
224+
for await (const { keys } of state.list({ match: 'list-z*' })) {
225+
iterations++
226+
retArray.push(...keys)
227+
}
228+
expect(iterations).toEqual(1)
229+
expect(retArray.length).toEqual(34)
230+
231+
iterations = 0
232+
retArray = []
233+
for await (const { keys } of state.list({ match: 'list-*-1' })) {
234+
iterations++
235+
retArray.push(...keys)
236+
}
237+
expect(iterations).toEqual(1)
238+
expect(retArray.length).toEqual(1)
239+
240+
// 4. make sure expired keys aren't listed
241+
await putKeys(keys90, 1)
242+
await waitFor(2000)
243+
244+
it = state.list({ countHint: 1000 })
245+
ret = await it.next()
246+
expect(ret.value.keys.length).toEqual(810) // 900 - 90
247+
expect(await it.next()).toEqual({ done: true, value: undefined })
248+
249+
await state.deleteAll()
250+
})
251+
129252
test('throw error when get/put with invalid keys', async () => {
130253
const invalidKey = 'some/invalid:key'
131254
const state = await initStateEnv()

lib/AdobeState.js

Lines changed: 111 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable jsdoc/no-undefined-types */
12
/*
23
Copyright 2024 Adobe. All rights reserved.
34
This file is licensed to you under the Apache License, Version 2.0 (the "License");
@@ -9,16 +10,27 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA
910
OF ANY KIND, either express or implied. See the License for the specific language
1011
governing permissions and limitations under the License.
1112
*/
12-
const { codes, logAndThrow } = require('./StateError')
13-
const utils = require('./utils')
1413
const cloneDeep = require('lodash.clonedeep')
1514
const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' })
1615
const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking')
1716
const url = require('node:url')
1817
const { getCliEnv } = require('@adobe/aio-lib-env')
19-
const { REGEX_PATTERN_STORE_KEY, HEADER_KEY_EXPIRES, CUSTOM_ENDPOINT, ENDPOINTS, ALLOWED_REGIONS } = require('./constants')
2018
const Ajv = require('ajv')
2119

20+
const { codes, logAndThrow } = require('./StateError')
21+
const utils = require('./utils')
22+
const {
23+
REGEX_PATTERN_STORE_KEY,
24+
HEADER_KEY_EXPIRES,
25+
CUSTOM_ENDPOINT,
26+
ENDPOINTS,
27+
ALLOWED_REGIONS,
28+
MAX_LIST_COUNT_HINT,
29+
REQUEST_ID_HEADER,
30+
MIN_LIST_COUNT_HINT,
31+
REGEX_PATTERN_LIST_KEY_MATCH
32+
} = require('./constants')
33+
2234
/* *********************************** typedefs *********************************** */
2335

2436
/**
@@ -92,20 +104,21 @@ async function handleResponse (response, params) {
92104
}
93105

94106
const copyParams = cloneDeep(params)
107+
copyParams.requestId = response.headers.get(REQUEST_ID_HEADER)
108+
95109
switch (response.status) {
96110
case 404:
97-
// no exception on 404
98111
return response
99112
case 401:
100-
return logAndThrow(new codes.ERROR_UNAUTHORIZED({ messageValues: ['underlying DB provider'], sdkDetails: copyParams }))
113+
return logAndThrow(new codes.ERROR_UNAUTHORIZED({ messageValues: ['State service'], sdkDetails: copyParams }))
101114
case 403:
102-
return logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['underlying DB provider'], sdkDetails: copyParams }))
115+
return logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['State service'], sdkDetails: copyParams }))
103116
case 413:
104-
return logAndThrow(new codes.ERROR_PAYLOAD_TOO_LARGE({ messageValues: ['underlying DB provider'], sdkDetails: copyParams }))
117+
return logAndThrow(new codes.ERROR_PAYLOAD_TOO_LARGE({ messageValues: ['State service'], sdkDetails: copyParams }))
105118
case 429:
106119
return logAndThrow(new codes.ERROR_REQUEST_RATE_TOO_HIGH({ sdkDetails: copyParams }))
107120
default: // 500 errors
108-
return logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unexpected response from provider with status: ${response.status} body: ${await response.text()}`], sdkDetails: copyParams }))
121+
return logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unexpected response from State service with status: ${response.status} body: ${await response.text()}`], sdkDetails: copyParams }))
109122
}
110123
}
111124

@@ -159,18 +172,13 @@ class AdobeState {
159172
* Creates a request url.
160173
*
161174
* @private
162-
* @param {string} key the key of the state store
175+
* @param {string} containerURLPath defaults to '' to hit the container
176+
* endpoint, add /data/key to hit the key endpoint
163177
* @param {object} queryObject the query variables to send
164178
* @returns {string} the constructed request url
165179
*/
166-
createRequestUrl (key, queryObject = {}) {
167-
let urlString
168-
169-
if (key) {
170-
urlString = `${this.endpoint}/containers/${this.namespace}/data/${key}`
171-
} else {
172-
urlString = `${this.endpoint}/containers/${this.namespace}`
173-
}
180+
createRequestUrl (containerURLPath = '', queryObject = {}) {
181+
const urlString = `${this.endpoint}/containers/${this.namespace}${containerURLPath}`
174182

175183
logger.debug('requestUrl string', urlString)
176184
const requestUrl = new url.URL(urlString)
@@ -277,7 +285,7 @@ class AdobeState {
277285
}
278286
}
279287
logger.debug('get', requestOptions)
280-
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions)
288+
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(`/data/${key}`), requestOptions)
281289
const response = await _wrap(promise, { key })
282290
if (response.ok) {
283291
// we only expect string values
@@ -334,8 +342,11 @@ class AdobeState {
334342

335343
logger.debug('put', requestOptions)
336344

337-
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key, queryParams), requestOptions)
338-
await _wrap(promise, { key, value, ...options })
345+
const promise = this.fetchRetry.exponentialBackoff(
346+
this.createRequestUrl(`/data/${key}`, queryParams),
347+
requestOptions
348+
)
349+
await _wrap(promise, { key, value, ...options }, true)
339350
return key
340351
}
341352

@@ -358,7 +369,7 @@ class AdobeState {
358369

359370
logger.debug('delete', requestOptions)
360371

361-
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions)
372+
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(`/data/${key}`), requestOptions)
362373
const response = await _wrap(promise, { key })
363374
if (response.status === 404) {
364375
return null
@@ -423,7 +434,7 @@ class AdobeState {
423434
}
424435
}
425436

426-
logger.debug('any', requestOptions)
437+
logger.debug('stats', requestOptions)
427438

428439
const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions)
429440
const response = await _wrap(promise, {})
@@ -433,6 +444,84 @@ class AdobeState {
433444
return response.json()
434445
}
435446
}
447+
448+
/**
449+
* List keys, returns an iterator. Every iteration returns a batch of
450+
* approximately `countHint` keys.
451+
* @example
452+
* for await (const { keys } of state.list({ match: 'abc*' })) {
453+
* console.log(keys)
454+
* }
455+
* @param {object} options list options
456+
* @param {string} options.match a glob pattern that supports '*' to filter
457+
* keys.
458+
* @param {number} options.countHint an approximate number on how many items
459+
* to return per iteration. Default: 100, min: 10, max: 1000.
460+
* @returns {AsyncGenerator<{ keys: [] }>} an async generator which yields a
461+
* { keys } object at every iteration.
462+
* @memberof AdobeState
463+
*/
464+
list (options = {}) {
465+
logger.debug('list', options)
466+
const requestOptions = {
467+
method: 'GET',
468+
headers: {
469+
...this.getAuthorizationHeaders()
470+
}
471+
}
472+
logger.debug('list', requestOptions)
473+
474+
const queryParams = {}
475+
if (options.match) {
476+
queryParams.match = options.match
477+
}
478+
if (options.countHint) {
479+
queryParams.countHint = options.countHint
480+
}
481+
482+
if (queryParams.countHint < MIN_LIST_COUNT_HINT || queryParams.countHint > MAX_LIST_COUNT_HINT) {
483+
logAndThrow(new codes.ERROR_BAD_ARGUMENT({
484+
messageValues: `'countHint' must be in the [${MIN_LIST_COUNT_HINT}, ${MAX_LIST_COUNT_HINT}] range`,
485+
sdkDetails: { queryParams }
486+
}))
487+
}
488+
const schema = {
489+
type: 'object',
490+
properties: {
491+
match: { type: 'string', pattern: REGEX_PATTERN_LIST_KEY_MATCH }, // this is an important check
492+
countHint: { type: 'integer' }
493+
}
494+
}
495+
496+
const { valid, errors } = validate(schema, queryParams)
497+
if (!valid) {
498+
logAndThrow(new codes.ERROR_BAD_ARGUMENT({
499+
messageValues: utils.formatAjvErrors(errors),
500+
sdkDetails: { queryParams, errors }
501+
}))
502+
}
503+
504+
const stateInstance = this
505+
return (async function * iter () {
506+
let cursor = 0
507+
508+
do {
509+
const promise = stateInstance.fetchRetry.exponentialBackoff(
510+
stateInstance.createRequestUrl('/data', { ...queryParams, cursor }),
511+
requestOptions
512+
)
513+
const response = await _wrap(promise, { ...queryParams, cursor })
514+
if (response.status === 404) {
515+
yield { keys: [] }
516+
return
517+
}
518+
const res = await response.json()
519+
cursor = res.cursor
520+
521+
yield { keys: res.keys }
522+
} while (cursor !== 0)
523+
}())
524+
}
436525
}
437526

438527
module.exports = { AdobeState }

lib/StateError.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ const E = ErrorWrapper(
5555
E('ERROR_INTERNAL', '%s')
5656
E('ERROR_BAD_REQUEST', '%s')
5757
E('ERROR_BAD_ARGUMENT', '%s')
58+
E('ERROR_UNEXPECTED_NOT_FOUND', '%s')
5859
E('ERROR_UNKNOWN_PROVIDER', '%s')
5960
E('ERROR_UNAUTHORIZED', 'you are not authorized to access %s')
6061
E('ERROR_BAD_CREDENTIALS', 'cannot access %s, make sure your credentials are valid')
6162
E('ERROR_PAYLOAD_TOO_LARGE', 'key, value or request payload is too large')
6263
E('ERROR_REQUEST_RATE_TOO_HIGH', 'Request rate too high. Please retry after sometime.')
63-
E('ERROR_FIREWALL', 'cannot access %s because your IP is blocked by a firewall, please make sure to run in an Adobe I/O Runtime action')
6464

6565
// eslint-disable-next-line jsdoc/require-jsdoc
6666
function logAndThrow (e) {

lib/constants.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ const HEADER_KEY_EXPIRES = 'x-key-expires-ms'
4646
const REGEX_PATTERN_STORE_NAMESPACE = '^(development-)?([0-9]{3,10})-([a-z0-9]{1,20})(-([a-z0-9]{1,20}))?$'
4747
// The regex for keys, allowed chars are alphanumerical with _ - .
4848
const REGEX_PATTERN_STORE_KEY = `^[a-zA-Z0-9-_.]{1,${MAX_KEY_SIZE}}$`
49+
// The regex for list key pattern, allowed chars are alphanumerical with _ - . and * for glob matching
50+
const REGEX_PATTERN_LIST_KEY_MATCH = `^[a-zA-Z0-9-_.*]{1,${MAX_KEY_SIZE}}$`
51+
const MAX_LIST_COUNT_HINT = 1000
52+
const MIN_LIST_COUNT_HINT = 100
53+
54+
const REQUEST_ID_HEADER = 'x-request-id'
4955

5056
module.exports = {
5157
ALLOWED_REGIONS,
@@ -56,6 +62,10 @@ module.exports = {
5662
REGEX_PATTERN_STORE_NAMESPACE,
5763
REGEX_PATTERN_STORE_KEY,
5864
HEADER_KEY_EXPIRES,
65+
REGEX_PATTERN_LIST_KEY_MATCH,
66+
MAX_LIST_COUNT_HINT,
67+
MIN_LIST_COUNT_HINT,
68+
REQUEST_ID_HEADER,
5969
// for testing only
6070
ENDPOINT_PROD,
6171
ENDPOINT_PROD_INTERNAL,

0 commit comments

Comments
 (0)