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(cdk): support encoding Tokens as numbers #2534

Merged
merged 3 commits into from
May 13, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
72 changes: 71 additions & 1 deletion packages/@aws-cdk/cdk/lib/encoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,79 @@ export function containsListTokenElement(xs: any[]) {
export function unresolved(obj: any): boolean {
if (typeof(obj) === 'string') {
return TokenString.forStringToken(obj).test();
} else if (typeof obj === 'number') {
return extractTokenDouble(obj) !== undefined;
} else if (Array.isArray(obj) && obj.length === 1) {
return typeof(obj[0]) === 'string' && TokenString.forListToken(obj[0]).test();
} else {
return obj && typeof(obj[RESOLVE_METHOD]) === 'function';
}
}
}

/**
* Bit pattern in the top 16 bits of a double to indicate a Token
*
* An IEEE double in LE memory order looks like this (grouped
* into octets, then grouped into 32-bit words):
*
* mmmmmmm.mmmmmmm.mmmmmmm.mmmmmmm | mmmmmmm.mmmmmmm.EEEEEmm.sEEEEEE
*
* - m: mantissa (52 bits)
* - E: exponent (11 bits)
* - s: sign (1 bit)
*
* We put the following marker into the top 16 bits (exponent and sign), and
* use the mantissa part to encode the token index. To save some bit twiddling
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"encode the index to the token in the token map"

* we use all top 16 bits for the tag. That loses us 2 mantissa bits to store
* information in but we still have 50, which is going to be plenty for any
* number of tokens to be created during the lifetime of any CDK application.
*
* Can't have all bits set because that makes a NaN, so unset the least
* significant exponent bit.
*
* Currently not supporting BE architectures.
*/
// tslint:disable-next-line:no-bitwise
const DOUBLE_TOKEN_MARKER_BITS = 0xFBFF << 16;

/**
* Return a special Double value that encodes the given integer
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention in the docstring that this is used to encode an index to the token map and therefore it's okay that we don't support negative numbers. Also, mention the limit on the number of number tokens we can support.

export function createTokenDouble(x: number) {
if (Math.floor(x) !== x || x < 0) {
throw new Error('Can only encode positive integers');
}

const buf = new ArrayBuffer(8);
const ints = new Uint32Array(buf);

// tslint:disable:no-bitwise
ints[0] = x & 0x0000FFFFFFFF; // Bottom 32 bits of number
ints[1] = (x & 0xFFFF00000000) >> 32 | DOUBLE_TOKEN_MARKER_BITS; // Top 16 bits of number and the mask
// tslint:enable:no-bitwise

return (new Float64Array(buf))[0];

}

/**
* Extract the encoded integer out of the special Double value
*
* Returns undefined if the float is a not an encoded token.
*/
export function extractTokenDouble(encoded: number): number | undefined {
const buf = new ArrayBuffer(8);
(new Float64Array(buf))[0] = encoded;

const ints = new Uint32Array(buf);

// tslint:disable:no-bitwise
if ((ints[1] & 0xFFFF0000) !== DOUBLE_TOKEN_MARKER_BITS) {
return undefined;
}

// Must use + instead of | here (bitwise operations
// will force 32-bits integer arithmetic, + will not).
return ints[0] + (ints[1] & 0xFFFF0000) << 16;
// tslint:enable:no-bitwise
}
13 changes: 13 additions & 0 deletions packages/@aws-cdk/cdk/lib/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export function resolve(obj: any, context: ResolveContext): any {
return resolveStringTokens(obj, context);
}

//
// number - potentially decode Tokenized number
//
if (typeof(obj) === 'number') {
return resolveNumberToken(obj, context);
}

//
// primitives - as-is
//
Expand Down Expand Up @@ -184,3 +191,9 @@ function resolveListTokens(xs: string[], context: ResolveContext): any {
}
return fragments.mapUnresolved(x => resolve(x, context)).values[0];
}

function resolveNumberToken(x: number, context: ResolveContext): any {
const token = TokenMap.instance().lookupNumberToken(x);
if (token === undefined) { return x; }
return resolve(token, context);
}
33 changes: 28 additions & 5 deletions packages/@aws-cdk/cdk/lib/token-map.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BEGIN_LIST_TOKEN_MARKER, BEGIN_STRING_TOKEN_MARKER, END_TOKEN_MARKER, TokenString, VALID_KEY_CHARS } from "./encoding";
import { BEGIN_LIST_TOKEN_MARKER, BEGIN_STRING_TOKEN_MARKER, createTokenDouble,
END_TOKEN_MARKER, extractTokenDouble, TokenString, VALID_KEY_CHARS } from "./encoding";
import { Token } from "./token";

const glob = global as any;
Expand All @@ -23,7 +24,9 @@ export class TokenMap {
return glob.__cdkTokenMap;
}

private readonly tokenMap = new Map<string, Token>();
private readonly stringTokenMap = new Map<string, Token>();
private readonly numberTokenMap = new Map<number, Token>();
private tokenCounter = 0;

/**
* Generate a unique string for this Token, returning a key
Expand All @@ -49,6 +52,15 @@ export class TokenMap {
return [`${BEGIN_LIST_TOKEN_MARKER}${key}${END_TOKEN_MARKER}`];
}

/**
* Create a unique number representation for this Token and return it
*/
public registerNumber(token: Token): number {
const tokenIndex = this.tokenCounter++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error if we reached the limit.

this.numberTokenMap.set(tokenIndex, token);
return createTokenDouble(tokenIndex);
}

/**
* Reverse a string representation into a Token object
*/
Expand Down Expand Up @@ -76,24 +88,35 @@ export class TokenMap {
return undefined;
}

/**
* Reverse a number encoding into a Token, or undefined if the number wasn't a Token
*/
public lookupNumberToken(x: number): Token | undefined {
const tokenIndex = extractTokenDouble(x);
if (tokenIndex === undefined) { return undefined; }
const t = this.numberTokenMap.get(tokenIndex);
if (t === undefined) { throw new Error('Encoded representation of unknown number Token found'); }
return t;
}

/**
* Find a Token by key.
*
* This excludes the token markers.
*/
public lookupToken(key: string): Token {
const token = this.tokenMap.get(key);
const token = this.stringTokenMap.get(key);
if (!token) {
throw new Error(`Unrecognized token key: ${key}`);
}
return token;
}

private register(token: Token, representationHint?: string): string {
const counter = this.tokenMap.size;
const counter = this.tokenCounter++;
const representation = (representationHint || `TOKEN`).replace(new RegExp(`[^${VALID_KEY_CHARS}]`, 'g'), '.');
const key = `${representation}.${counter}`;
this.tokenMap.set(key, token);
this.stringTokenMap.set(key, token);
return key;
}
}
25 changes: 25 additions & 0 deletions packages/@aws-cdk/cdk/lib/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class Token {

private tokenStringification?: string;
private tokenListification?: string[];
private tokenNumberification?: number;

/**
* Creates a token that resolves to `value`.
Expand Down Expand Up @@ -132,6 +133,30 @@ export class Token {
}
return this.tokenListification;
}

/**
* Return a floating point representation of this Token
*
* Call this if the Token intrinsically resolves to something that represents
* a number, and you need to pass it into an API that expects a number.
*
* You may not do any operations on the returned value; any arithmetic or
* other operations can and probably will destroy the token-ness of the value.
*/
public toNumber(): number {
if (this.tokenNumberification === undefined) {
const valueType = typeof this.valueOrFunction;
// Optimization: if we can immediately resolve this, don't bother
// registering a Token.
if (valueType === 'number') { return this.valueOrFunction; }
if (valueType !== 'function') {
throw new Error(`Token value is not number or lazy, can't represent as number: ${this.valueOrFunction}`);
}
this.tokenNumberification = TokenMap.instance().registerNumber(this);
}

return this.tokenNumberification;
}
}

/**
Expand Down
47 changes: 45 additions & 2 deletions packages/@aws-cdk/cdk/test/test.tokens.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Test } from 'nodeunit';
import { App as Root, Fn, Token } from '../lib';
import { App as Root, Fn, Token, Stack } from '../lib';
import { createTokenDouble, extractTokenDouble } from '../lib/encoding';
import { TokenMap } from '../lib/token-map';
import { evaluateCFN } from './evaluate-cfn';

Expand Down Expand Up @@ -387,7 +388,49 @@ export = {

test.done();
},
}
},

'number encoding': {
'arbitrary integers can be encoded, stringified, and recovered'(test: Test) {
for (let i = 0; i < 100; i++) {

// We can encode all numbers up to 2^50-1
const x = Math.floor(Math.random() * (Math.pow(2, 50) - 1));

const encoded = createTokenDouble(x);
// Roundtrip through JSONification
const roundtripped = JSON.parse(JSON.stringify({ theNumber: encoded })).theNumber;
const decoded = extractTokenDouble(roundtripped);
test.equal(decoded, x, `Fail roundtrip encoding of ${x}`);
}

test.done();
},

'arbitrary numbers are correctly detected as not being tokens'(test: Test) {
test.equal(undefined, extractTokenDouble(0));
test.equal(undefined, extractTokenDouble(1243));
test.equal(undefined, extractTokenDouble(4835e+532));

test.done();
},

'can number-encode and resolve Token objects'(test: Test) {
// GIVEN
const stack = new Stack();
const x = new Token(() => 123);

// THEN
const encoded = x.toNumber();
test.equal(true, Token.isToken(encoded), 'encoded number does not test as token');

// THEN
const resolved = stack.node.resolve({ value: encoded });
test.deepEqual(resolved, { value: 123 });

test.done();
},
},
};

class Promise2 extends Token {
Expand Down