-
Notifications
You must be signed in to change notification settings - Fork 117
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
Typed search attributes #1612
base: main
Are you sure you want to change the base?
Typed search attributes #1612
Conversation
Most of the necessary revisions are done, fixing failing tests and adding new ones. |
- remove ITypedSearchAttributes, expose concrete class TypedSearchAttributes - update constructor to take a list of pairs, consistent with the usage of pairs and class throughout the API - `Options` objects (i.e. user input) allow for both pairs and the concrete class as input types - created `TypedSearchAttributeUpdate`/`Pair` types, to be used for upserts and updates, the only difference with these types and the existing `TypedSearchAttribute/Pair` types is that they except `null` values (for deletion) - fixed the `upsert` method to accomodate these types - `updateSearchAttributes` now returns a new copy of `TypedSearchAttributes` to avoid mutation of existing `TypedSearchAttributes` objects - a myriad of small changes to accomodate the type changes
e6c6b31
to
af0754e
Compare
Updated the PR description with an overview for reviewers. Some remaining points of uncertainty:
|
9d727b3
to
3877147
Compare
3877147
to
3261083
Compare
Who do I need to get in touch with to update docs? (considering we're deprecating a user-facing feature in exchange for another) |
…tic methods within the class renamed some types and methods
1a601a1
to
0c3afe2
Compare
@@ -723,6 +728,10 @@ test('Workflow can read WorkflowInfo', configMacro, async (t, config) => { | |||
runId: handle.firstExecutionRunId, | |||
taskQueue, | |||
searchAttributes: {}, | |||
// Ensure serialized data structure is correct | |||
typedSearchAttributes: JSON.parse(JSON.stringify(new TypedSearchAttributes())), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was not evident to me at first sight that the TypedSearchAttributes
object would actually serialize to JSON properly. By spec, JSON.stringify(obj)
only serializes enumerable own properties, so an object's private fields shouldn't be listed.
But turns out it does, as TS predates JS' EcmaScript 5 class model, so the TypeScript compiler translates "private fields" to regular properties on the JS object. So we get something like this:
{
"searchAttributes": {
"CustomIntField": ["INT", 42],
"CustomKeywordField": ["KEYWORD", "test-value2"]
}
}
Still, that feels fragile, especially given that this is what sink functions will see, and what users might see in logs if they print out Workflow Info or Search Attributes. I particularly don't like that we have this inner searchAttributes
object, and that we serialize using our internal storage format rather than something that adheres to the "user visible" API (i.e. the format that a user could pass as input to the TypedSearchAttributes
class).
So two suggestions:
- On the
TypedSearchAttributes
class, redefine thetoJSON()
function to return the precise object as we'd like it to get serialized, i.e. probably aTypedSearchAttributePair[]
; - In at least some of your tests, rather than doing
JSON.parse(JSON.stringify(someSearchAttributeObject))
for the purpose ofdeepEquals()
, be explicit on what you would expect to get (e.g.[[["CustomIntField", "INT"], 42]]
), so that we don't break that at a later point (that would be just too easy to miss otherwise).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I agree with these points.
- I've defined
toJSON()
on the class to return its JSON representation asSearchAttributePair[]
- I've removed all usages of
JSON.stringify
in tests. I've changedWorkflowInfo
to returnSearchAttributePair[]
instead ofTypedSearchAttributes
. This is the only case where the server response isSearchAttributePair[]
because we don't get an opportunity to decode. I think this is okay, particularly because users will likely useSearchAttributePair[]
when specifying search attribute options. Maybe we can change the field name to reflect that the value is a list of pairs, I have no preference. Consequently, when making assertions onWorkflowInfo
we just need to compare lists, no need forJSON.stringify
. All other test cases work withTypedSearchAttributes
directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reverted the type back to a class type TypedSearchAttributes
as discussed offline (keeping the toJSON
override).
packages/test/src/test-sinks.ts
Outdated
@@ -118,6 +118,7 @@ if (RUN_INTEGRATION_TESTS) { | |||
memo: {}, | |||
parent: undefined, | |||
searchAttributes: {}, | |||
typedSearchAttributes: JSON.parse(JSON.stringify(new TypedSearchAttributes())), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like we might have more and more discrepencies between the actual WorkflowInfo
interface and that sink functions see of it. We already had the unsafe.now()
, mentionned below, and now, typedSearchAttributes
will be plain objects/arrays rather than an instance of the TypedSearchAttributes
class.
We could consider rehydrating the WorkflowInfo
objects before actually passing them to sink functions, but I honestly don't think there's a strong use case for it enough to justify the extra CPU cycle and memory churning that would involve. So I think we may simply create a variant of the WorkflowInfo
interface that corresponds to what we actually get in sinks.
Don't fix this just now, but please add a FIXME and/or open a ticket for that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See comment above on changing this type to being SearchAttributePair[]
instead of TypedSearchAttributes
(again, maybe we can change the field name to better reflect this).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, just created a literal to satisfy the test and added a FIXME. As discussed offline, we don't expect this to be a problem in practice.
packages/workflow/src/workflow.ts
Outdated
...info, | ||
searchAttributes: { | ||
...info.searchAttributes, | ||
// Note that we keep empty arrays in the search attributes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that was a previous oversight and would prefer we actually fix that. We should prune out "empty" search attributes on upsert.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
I hesitated to do this before because I noticed this comment in Python's upsert impl:
# Update the keys in the existing dictionary. We keep exact values
# sent in instead of any kind of normalization. This means empty
# lists remain as empty lists which matches what the server does.
It's not clear to me why this would be useful, regardless if it matches server. Maybe worth asking Chad about this ?
packages/workflow/src/workflow.ts
Outdated
@@ -1347,7 +1362,8 @@ export function setDefaultSignalHandler(handler: DefaultSignalHandler | undefine | |||
* | |||
* @param searchAttributes The Record to merge. Use a value of `[]` to clear a Search Attribute. | |||
*/ | |||
export function upsertSearchAttributes(searchAttributes: SearchAttributes): void { | |||
|
|||
export function upsertSearchAttributes(searchAttributes: SearchAttributes | TypedSearchAttributeUpdatePair[]): void { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need to update the typedoc above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
packages/workflow/src/workflow.ts
Outdated
for (const pair of searchAttributes) { | ||
const [k, v] = pair; | ||
if (v === null) { | ||
// If the value is null (i.e. delete), set the value to an empty list. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as below, I think keeping the empty list in worklfowInfo.searchAttributes
was a previous oversight, and would prefer that we remove the attribute entirely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto the previous comment on the Python SDK's implementation (removed but maybe we should clarify semantics with Python SDK and Chad)
packages/workflow/src/workflow.ts
Outdated
const typedKey = TypedSearchAttributes.getKeyFromUntyped(k, v); | ||
|
||
// Unable to discern the typing of the key, skip. | ||
if (!typedKey) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in this case it would be preferable to see if there is already an SA named k
in typedSearchAttributes
, and if so:
- Try to use type of SA
k
intypedSearchAttributes
as the type forv
; - If the type is incompatible, remove entry
k
fromtypedSearchAttributes
.
That will avoid some possible cases of inconsistencies where typedSearchAttributes[k]
and searchAttributes[k]
both exists but have different values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think case 1 is possible. If we get undefined
from TypedSearchAttributes.getKeyFromUntyped
, we've checked that the value is not string
, number
, boolean
, Date
, or string[]
. Consequently, there would be no possible SearchAttributeType
that would be valid. The value would fail a isValidValueForType
check for any SearchAttributeType
.
For case 2, I had initially implemented this change but then reverted it. I can't think of scenarios where typedSearchAttributes[k]
and searchAttributes[k]
both exist but have different values (but I very well could just not be aware of the semantics).
If we enumerate the possibilities for attr A
:
A
belongs in bothtyped
anduntyped
, same valueA
belongs in bothtyped
anduntyped
, different value (if this is possible)A
belongs intyped
but notuntyped
(if this is possible)A
belongs inuntyped
but nottyped
(if this is possible)A
does not belong intyped
oruntyped
Deletion makes things more consistent for cases 2 (A
will only exist in untyped
but at least there are no differing values) and 3 (A
will not exist in either), but makes things less consistent for case 1 (which is arguably the common case). It's a no-op for cases 4 and 5.
In any case, I'm not sure if this would be expected behavior for a user. I don't think a user would expect an update/insert operation to delete unless explicitly specified to do so.
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed the deletion case for upsert.
Added some improvements to handle date strings and less type loss from KEYWORD
to TEXT
packages/workflow/src/workflow.ts
Outdated
// Use updateSearchAttributes to get a deep copy of the current state. | ||
let typedSearchAttributes = info.typedSearchAttributes.updateAttributes([]); | ||
for (const [k, v] of Object.entries(searchAttributes)) { | ||
const typedKey = TypedSearchAttributes.getKeyFromUntyped(k, v); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like the code here (getKeyFromUntyped
and below) wants to be permissive, kind of allowing going through with v
being a single primitive, rather than an array, but then you'll get until line 1442 and fail on newValue = v[0]
. You may also get weird behaviors if v
is set to the single value false
or 0
, because of the !v
check at line 1427.
Technically speaking, the former API only allowed v
to be an array or undefined, so let's just throw early if v
anything else than that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
v
is recognized as SearchAttributeValueOrReadonly
within the loop, which does imply array or undefined.
I don't get any compiler issues here given that typing, but I've added an error check just in case if we want to be extra safe.
packages/workflow/src/workflow.ts
Outdated
let newValue: unknown = v; | ||
if (typedKey.type !== SearchAttributeType.KEYWORD_LIST) { | ||
// Skip if we found multiple values for a non-list key. | ||
if (Array.isArray(v) && v.length > 1) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove from typedSearchAttributes
in that case. That's better than having incohenrencies.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto the previous comment on removing from typedSearchAttributes
.
In any case, I've removed this check and a couple other validation checks in the upsert
logic. If we can determine a valid type for the given input then these additional checks are redundant.
I've also left a short comment in the upsert
logic that covers the basic cases, thought it might be useful though the logic is not complicated. Can remove if you think unnecessary. Testing some of these cases (i.e. multi-value input for non keyword list, invalid objects/primitives) is difficult given that server will error if you try to do this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed the comment. Code is pretty readable and doesn't really warrant it.
packages/common/src/index.ts
Outdated
@@ -12,6 +12,7 @@ export * from './converter/data-converter'; | |||
export * from './converter/failure-converter'; | |||
export * from './converter/payload-codec'; | |||
export * from './converter/payload-converter'; | |||
export * from './converter/payload-search-attributes'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's be specific in what we reexport. TBH, I don't think there's anything in that file that I would consider as public API. It's ok for our own packages to import inner files of the common package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I agree.
Haven't commit this change yet, getting some weird import issues after replacing with @temporalio/common/lib/converter/payload-search-attributes
, but I'll add it once I fix it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
packages/common/src/index.ts
Outdated
@@ -24,6 +25,7 @@ export type { Timestamp, Duration, StringValue } from './time'; | |||
export * from './workflow-handle'; | |||
export * from './workflow-options'; | |||
export * from './versioning-intent'; | |||
export * from './typed-search-attributes'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again, be specific. There are things here that are meant to be public APIs, but not *
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, lack of oversight here. I've made the exports more explicit now.
Definitely |
}; | ||
|
||
// The corresponding typed search attributes from untypedSearchAttributes. | ||
const typedFromUntypedInput: TypedSearchAttributePair[] = [ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Though it may be useful to have a "createAtttributeValuePair()" utility function, it is not how I would expect a user to use the Typed Search Attributes API in general.
The mental model, from a user's POV must be key(name, type) => value
.
Take this Python code:
# Define TSA Keys, would usually be in some global place
key_1 = SearchAttributeKey.for_text("python-test-schedule-sa-update-key-1")
key_2 = SearchAttributeKey.for_keyword("python-test-schedule-sa-update-key-2")
# Example of setting TSA Keys=>Values from scratch
handle = await client.create_schedule(
f"schedule-{uuid.uuid4()}",
Schedule(action=create_action, spec=ScheduleSpec()),
search_attributes=TypedSearchAttributes(
[
SearchAttributePair(key_1, val_1),
SearchAttributePair(key_2, val_2),
]
),
)
# Example of reading TSA values
assert input.description.typed_search_attributes[key_1] == val_1
assert input.description.typed_search_attributes[key_2] == val_2
# Creating new TSA by deriving exisitng TSA K/V
return ScheduleUpdate(
input.description.schedule,
search_attributes=input.description.typed_search_attributes.updated(
SearchAttributePair(key_1, val_1 + "-new"),
SearchAttributePair(key_2, val_2 + "-new"),
),
)
In TS and following existing idioms of the TS SDK, that give something like this:
// Define TSA Keys, would usually be in some global place
export const key1 = defineSearchAttribute("my-custom-sa-key-1", 'STRING');
export const key2 = defineSearchAttribute("my-custom-sa-key-2", 'KEYWORD');
export const key3 = defineSearchAttribute("my-custom-sa-key-3", 'INT');
// Example of setting TSA Keys=>Values from scratch
const handle = await client.workflow.start(
// ...
searchAttributes: [ // Technically no need for a builder function here as TS type
[key1, "My customer Name"], // system is expressive enough to provide type safety on mappings.
[key2, "some-keyword"], // I wouldn't mind some utility function to make this more readable,
] // but the tuple notation should technically be enough.
);
// Example of reading TSA values
assert input.description.typedSearchAttributes.get(key1) === "My customer Name";
assert input.description.typedSearchAttributes.get(key2) "some-keyword";
# Creating new TSA by deriving exisitng TSA K/V
return ScheduleUpdate(
input.description.schedule,
search_attributes=input.description.typedSearchAttributes.updated([
[key2, undefined], // Delete an existing key
[key3, 84],
]),
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As discussed offline, the API now offers usage like:
// Define keys:
const key1 = { name: 'key_text', type: SearchAttributeType.TEXT };
const key2 = { name: 'key_int', type: SearchAttributeType.INT };
// Define pairs:
const textPair = { key: key1, value: 'even_newer_value' };
const intPair = { key: key2, value: 45 };
// Define collections
const searchAttrs = [textPair, intPair];
// or inline
const inlineAttrs = [
{ key: key1, value: 'even_newer_value' },
{ key: key2, value: 45 }
]
// Define class instance
const typedSearchAttrs = new TypedSearchAttributes(searchAttrs);
// or inline
const typedSearchAttrs = new TypedSearchAttributes([
{ key: key2, value: 45 }
]);
Just want to call out this comment I wrote in
I suspect the decoding from server attributes is the cause, but I don't understand how this could happen given that the type conversions due to the upsert should occur first? |
- change API - clarify upsert code/semantics - improve test assertions (removing json serialization for data comparisons)
2fb4d77
to
40f9117
Compare
…es. Improvements to handle date strings and TEXT/KEYWORD type inferences
We're pushing to the activator before upserting and the workflow exec description reads from server, so we don't incur this type loss for the description. In any case, we handle date strings and KEYWORD -> TEXT type loss a bit better now which removes this discrepancy. Removed this comment. |
What was changed
Introduces
TypedSearchAttributes
to the Typescript SDK, allowing users to supply and receive type-enforced search attributes.TypedSearchAttributes
are intended to replace the existing (not type-enforced)SearchAttributes
which have been marked as@deprecated
in its existing usages.Why?
Brings parity with other SDKs which have a typed search attribute implementation.
Brings parity to how the server represents search attributes.
Provides a typed, structured interface to use search attributes.
For reviewers
Points of interest:
typed-search-attributes.ts
contains the APItest-typed-search-attributes.ts
contains tests and demonstrates API usageworflow.ts
for changes toupsertSearchAttributes
(mutations to search attributes in workflow info)payload-search-attributes.ts
contains decoding/encoding logic for search attributes (both legacy and typed)Closes [Feature Request] Typed Search Attributes #1232
How was this tested:
Added tests, see
test-typed-search-attributes.ts
Any docs updates needed?
Likely as we are deprecating existing
SearchAttributes