Skip to content

Commit 3635d0a

Browse files
Eric Dahlvangmdrichardson
authored andcommitted
Add KeySuffix and CompatibilityMode to CosmosDbPartitionedStorage (#1468)
* add compatibility mode and backward compat * remove semaphore and use promises, fix tests * Update libraries/botbuilder-azure/src/cosmosDbPartitionedStorage.ts Co-Authored-By: Michael Richardson <40401643+mdrichardson@users.noreply.github.com>
1 parent aa2723a commit 3635d0a

10 files changed

+1235
-966
lines changed

libraries/botbuilder-azure/src/cosmosDbKeyEscape.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
var crypto = require('crypto');
99

1010
export namespace CosmosDbKeyEscape {
11-
// Per the CosmosDB Docs, there is a max key length of 255.
12-
// https://docs.microsoft.com/en-us/azure/cosmos-db/faq#table
11+
// Older libraries had a max key length of 255.
12+
// The limit is now 1023. In this library, 255 remains the default for backwards compat.
13+
// To override this behavior, and use the longer limit, set cosmosDbPartitionedStorageOptions.compatibilityMode to false.
14+
// https://docs.microsoft.com/en-us/azure/cosmos-db/concepts-limits#per-item-limits
1315
const maxKeyLength = 255;
1416
const illegalKeys: string[] = ['\\', '?', '/', '#', '\t', '\n', '\r', '*'];
1517
const illegalKeyCharacterReplacementMap: Map<string, string> =
@@ -27,8 +29,11 @@ export namespace CosmosDbKeyEscape {
2729
* The following characters are restricted and cannot be used in the Id property: '/', '\', '?', '#'
2830
* More information at https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.documents.resource.id?view=azure-dotnet#remarks
2931
* @param key The provided key to be escaped.
32+
* @param keySuffix The string to add a the end of all RowKeys.
33+
* @param compatibilityMode True if keys should be truncated in order to support previous CosmosDb
34+
* max key length of 255. This behavior can be overridden by setting cosmosDbPartitionedStorageOptions.compatibilityMode to false.
3035
*/
31-
export function escapeKey(key: string): string {
36+
export function escapeKey(key: string, keySuffix?: string, compatibilityMode?: boolean): string {
3237
if (!key) {
3338
throw new Error('The \'key\' parameter is required.');
3439
}
@@ -39,7 +44,7 @@ export namespace CosmosDbKeyEscape {
3944

4045
// If there are no illegal characters return immediately and avoid any further processing/allocations
4146
if (firstIllegalCharIndex === -1) {
42-
return truncateKey(key);
47+
return truncateKey(`${key}${keySuffix || ''}`, compatibilityMode);
4348
}
4449

4550
let sanitizedKey = keySplitted.reduce(
@@ -48,10 +53,14 @@ export namespace CosmosDbKeyEscape {
4853
''
4954
);
5055

51-
return truncateKey(sanitizedKey);
56+
return truncateKey(`${sanitizedKey}${keySuffix || ''}`, compatibilityMode);
5257
}
5358

54-
function truncateKey(key: string): string {
59+
function truncateKey(key: string, truncateKeysForCompatibility?: boolean): string {
60+
if (truncateKeysForCompatibility === false) {
61+
return key;
62+
}
63+
5564
if (key.length > maxKeyLength) {
5665
const hash = crypto.createHash('sha256');
5766
hash.update(key);

libraries/botbuilder-azure/src/cosmosDbPartitionedStorage.ts

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
import { Storage, StoreItems } from 'botbuilder';
1010
import { Container, CosmosClient, CosmosClientOptions } from '@azure/cosmos';
1111
import { CosmosDbKeyEscape } from './cosmosDbKeyEscape';
12-
import * as semaphore from 'semaphore';
12+
import { DoOnce } from './doOnce';
1313

14-
const _semaphore: semaphore.Semaphore = semaphore(1);
14+
const _doOnce: DoOnce<Container> = new DoOnce<Container>();
1515

1616
/**
1717
* Cosmos DB Partitioned Storage Options.
@@ -41,6 +41,26 @@ export interface CosmosDbPartitionedStorageOptions {
4141
* The throughput set when creating the Container. Defaults to 400.
4242
*/
4343
containerThroughput?: number;
44+
/**
45+
* The suffix to be added to every key. See cosmosDbKeyEscape.escapeKey
46+
*
47+
* Note: compatibilityMode must be set to 'false' to use a KeySuffix.
48+
* When KeySuffix is used, keys will NOT be truncated but an exception will
49+
* be thrown if the key length is longer than allowed by CosmosDb.
50+
*
51+
* The keySuffix must contain only valid ComosDb key characters.
52+
* (e.g. not: '\\', '?', '/', '#', '*')
53+
*/
54+
keySuffix?: string;
55+
/**
56+
* Early version of CosmosDb had a max key length of 255. Keys longer than
57+
* this were truncated in cosmosDbKeyEscape.escapeKey. This remains the default
58+
* behavior of cosmosDbPartitionedStorage, but can be overridden by setting
59+
* compatibilityMode to false.
60+
*
61+
* compatibilityMode cannot be true if keySuffix is used.
62+
*/
63+
compatibilityMode?: boolean;
4464
}
4565

4666
/**
@@ -93,6 +113,7 @@ export class CosmosDbPartitionedStorage implements Storage {
93113
private container: Container;
94114
private readonly cosmosDbStorageOptions: CosmosDbPartitionedStorageOptions;
95115
private client: CosmosClient;
116+
private compatabilityModePartitionKey: boolean = false;
96117

97118
/**
98119
* Initializes a new instance of the <see cref="CosmosDbPartitionedStorage"/> class.
@@ -106,6 +127,22 @@ export class CosmosDbPartitionedStorage implements Storage {
106127
if (!cosmosDbStorageOptions.authKey) { throw new ReferenceError('authKey for CosmosDB is required.'); }
107128
if (!cosmosDbStorageOptions.databaseId) { throw new ReferenceError('databaseId is for CosmosDB required.'); }
108129
if (!cosmosDbStorageOptions.containerId) { throw new ReferenceError('containerId for CosmosDB is required.'); }
130+
// In order to support collections previously restricted to max key length of 255, we default
131+
// compatabilityMode to 'true'. No compatibilityMode is opt-in only.
132+
if (typeof cosmosDbStorageOptions.compatibilityMode === "undefined") {
133+
cosmosDbStorageOptions.compatibilityMode = true;
134+
}
135+
if (cosmosDbStorageOptions.keySuffix) {
136+
if (cosmosDbStorageOptions.compatibilityMode){
137+
throw new ReferenceError('compatibilityMode cannot be true while using a keySuffix.');
138+
}
139+
// In order to reduce key complexity, we do not allow invalid characters in a KeySuffix
140+
// If the keySuffix has invalid characters, the escaped key will not match
141+
const suffixEscaped = CosmosDbKeyEscape.escapeKey(cosmosDbStorageOptions.keySuffix);
142+
if (cosmosDbStorageOptions.keySuffix !== suffixEscaped) {
143+
throw new ReferenceError(`Cannot use invalid Row Key characters: ${cosmosDbStorageOptions.keySuffix} in keySuffix`);
144+
}
145+
}
109146

110147
this.cosmosDbStorageOptions = cosmosDbStorageOptions;
111148
}
@@ -120,9 +157,9 @@ export class CosmosDbPartitionedStorage implements Storage {
120157

121158
await Promise.all(keys.map(async (k: string): Promise<void> => {
122159
try {
123-
const escapedKey = CosmosDbKeyEscape.escapeKey(k);
160+
const escapedKey = CosmosDbKeyEscape.escapeKey(k, this.cosmosDbStorageOptions.keySuffix, this.cosmosDbStorageOptions.compatibilityMode);
124161

125-
const readItemResponse = await this.container.item(escapedKey, escapedKey).read<DocumentStoreItem>();
162+
const readItemResponse = await this.container.item(escapedKey, this.getPartitionKey(escapedKey)).read<DocumentStoreItem>();
126163
const documentStoreItem = readItemResponse.resource;
127164
if (documentStoreItem) {
128165
storeItems[documentStoreItem.realId] = documentStoreItem.document;
@@ -159,7 +196,7 @@ export class CosmosDbPartitionedStorage implements Storage {
159196
// The ETag information is updated as an _etag attribute in the document metadata.
160197
delete changesCopy.eTag;
161198
const documentChange = new DocumentStoreItem({
162-
id: CosmosDbKeyEscape.escapeKey(k),
199+
id: CosmosDbKeyEscape.escapeKey(k, this.cosmosDbStorageOptions.keySuffix, this.cosmosDbStorageOptions.compatibilityMode),
163200
realId: k,
164201
document: changesCopy
165202
});
@@ -180,10 +217,10 @@ export class CosmosDbPartitionedStorage implements Storage {
180217
await this.initialize();
181218

182219
await Promise.all(keys.map(async (k: string): Promise<void> => {
183-
const escapedKey = CosmosDbKeyEscape.escapeKey(k);
220+
const escapedKey = CosmosDbKeyEscape.escapeKey(k, this.cosmosDbStorageOptions.keySuffix, this.cosmosDbStorageOptions.compatibilityMode);
184221
try {
185222
await this.container
186-
.item(escapedKey, escapedKey)
223+
.item(escapedKey, this.getPartitionKey(escapedKey))
187224
.delete();
188225
} catch (err) {
189226
// If trying to delete a document that doesn't exist, do nothing. Otherwise, throw
@@ -200,35 +237,63 @@ export class CosmosDbPartitionedStorage implements Storage {
200237
*/
201238
public async initialize(): Promise<void> {
202239
if (!this.container) {
203-
204240
if (!this.client) {
205241
this.client = new CosmosClient({
206242
endpoint: this.cosmosDbStorageOptions.cosmosDbEndpoint,
207243
key: this.cosmosDbStorageOptions.authKey,
208244
...this.cosmosDbStorageOptions.cosmosClientOptions,
209245
});
210246
}
247+
this.container = await _doOnce.waitFor(async ()=> await this.getOrCreateContainer());
248+
}
249+
}
250+
251+
private async getOrCreateContainer(): Promise<Container> {
252+
let createIfNotExists = !this.cosmosDbStorageOptions.compatibilityMode;
253+
let container;
254+
if(this.cosmosDbStorageOptions.compatibilityMode) {
255+
try {
256+
container = await this.client
257+
.database(this.cosmosDbStorageOptions.databaseId)
258+
.container(this.cosmosDbStorageOptions.containerId);
259+
const partitionKeyPath = await container.readPartitionKeyDefinition();
260+
const paths = partitionKeyPath.resource.paths;
261+
if(paths) {
262+
// Containers created with CosmosDbStorage had no partition key set, so the default was '/_partitionKey'.
263+
if (paths.indexOf('/_partitionKey') !== -1) {
264+
this.compatabilityModePartitionKey = true;
265+
}
266+
else if (paths.indexOf(DocumentStoreItem.partitionKeyPath) === -1) {
267+
// We are not supporting custom Partition Key Paths.
268+
new Error(`Custom Partition Key Paths are not supported. ${this.cosmosDbStorageOptions.containerId} has a custom Partition Key Path of ${paths[0]}.`);
269+
}
270+
} else {
271+
this.compatabilityModePartitionKey = true;
272+
}
273+
return container;
274+
} catch (err) {
275+
createIfNotExists = true;
276+
}
277+
}
211278

212-
if (!this.container) {
213-
this.container = await new Promise((resolve: Function): void => {
214-
_semaphore.take(async (): Promise<void> => {
215-
const result = await this.client
216-
.database(this.cosmosDbStorageOptions.databaseId)
217-
.containers.createIfNotExists({
218-
id: this.cosmosDbStorageOptions.containerId,
219-
partitionKey: {
220-
paths: [DocumentStoreItem.partitionKeyPath]
221-
},
222-
throughput: this.cosmosDbStorageOptions.containerThroughput
223-
});
224-
_semaphore.leave();
225-
resolve(result.container);
226-
});
279+
if (createIfNotExists) {
280+
const result = await this.client
281+
.database(this.cosmosDbStorageOptions.databaseId)
282+
.containers.createIfNotExists({
283+
id: this.cosmosDbStorageOptions.containerId,
284+
partitionKey: {
285+
paths: [DocumentStoreItem.partitionKeyPath]
286+
},
287+
throughput: this.cosmosDbStorageOptions.containerThroughput
227288
});
228-
}
289+
return result.container;
229290
}
230291
}
231292

293+
private getPartitionKey(key) {
294+
return this.compatabilityModePartitionKey ? undefined : key;
295+
}
296+
232297
/**
233298
* The Cosmos JS SDK doesn't return very descriptive errors and not all errors contain a body.
234299
* This provides more detailed errors and err['message'] prevents ReferenceError
@@ -240,4 +305,4 @@ export class CosmosDbPartitionedStorage implements Storage {
240305
err['message'] = `[${ prependedMessage }] ${ err['message'] }`;
241306
throw err;
242307
}
243-
}
308+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @module botbuilder-azure
3+
*/
4+
/**
5+
* Copyright (c) Microsoft Corporation. All rights reserved.
6+
* Licensed under the MIT License.
7+
*/
8+
9+
export class DoOnce<T> {
10+
private task: Promise<T>;
11+
12+
public waitFor(fn: () => Promise<T>): Promise<T> {
13+
if (!this.task) {
14+
this.task = fn();
15+
}
16+
17+
return this.task;
18+
}
19+
}

0 commit comments

Comments
 (0)