SED-4906 Support string interpolation in plan values - #700
Conversation
…n DynamiceBeanResolver
…verage for yaml plans
…igration task for parameters
There was a problem hiding this comment.
Code Review
This pull request introduces string interpolation support for plain (non-dynamic) string values using the ${...} syntax across plans and parameters, updating the schema version to 1.3.0. It includes migration tasks to escape pre-existing literals, core classes for parsing and interpolation, and the @NoStringInterpolation annotation to protect structured JSON containers. Feedback on the changes highlights a potential 'Halloween Problem' in MongoDB during migration when saving documents while iterating with a lazy cursor, which can be resolved by sorting by _id. Additionally, reviewers recommended optimizing performance by avoiding anonymous TypeReference instantiations and caching classpath scans, as well as improving code safety by not reassigning method parameters inside loops.
| AtomicLong errorCount = new AtomicLong(); | ||
|
|
||
| logger.info("Escaping the string interpolation placeholders of the plain values in '{}'...", collectionName); | ||
| try (Stream<Document> documents = collection.findLazy(Filters.empty(), null, null, null, 0)) { |
There was a problem hiding this comment.
In MongoDB, modifying and saving a document while iterating over the collection using a lazy cursor (findLazy) can lead to the Halloween Problem. If the document's size increases (which is likely here as we are replacing ${ with $${), MongoDB may relocate the document to the end of the collection. The cursor will then encounter the modified document again, leading to duplicate processing and infinite loops (e.g., escaping it multiple times to $$${ or $$$${).\n\nTo prevent this, you should sort the query by an immutable indexed field like _id (e.g., new Document("_id", 1)), which guarantees that each document is visited exactly once regardless of physical relocation.
| try (Stream<Document> documents = collection.findLazy(Filters.empty(), null, null, null, 0)) { | |
| try (Stream<Document> documents = collection.findLazy(Filters.empty(), new Document("_id", 1), null, null, 0)) { |
There was a problem hiding this comment.
The Halloween problem should not occur in MongoDB 5+with WiredTiger. Didn't find any place in the code where we sort by _id, so I prefer let this untouched. @david-stephan what do you think?
There was a problem hiding this comment.
Should not impact this query since we don't modify the index or use unique index. According to the doc :
Ref: https://www.mongodb.com/docs/manual/core/read-isolation-consistency-recency/?language-no-dependencies=python#cursor-snapshot
Cursor Snapshot
MongoDB cursors can return the same document more than once in some situations. As a cursor returns documents, other operations may interleave with the query. If one of these operations changes the indexed field on the index used by the query, then the cursor could return the same document more than once.
Queries that use unique indexes can, in some cases, return duplicate values. If a cursor using a unique index interleaves with a delete and insert of documents sharing the same unique value, the cursor may return the same unique value twice from different documents.
| parsed = OBJECT_MAPPER.readValue(json, new TypeReference<>() { | ||
| }); |
There was a problem hiding this comment.
Creating a new anonymous TypeReference instance on every call to escapeNestedDocument can lead to excessive class loading and memory overhead, especially when processing a large number of documents. Reusing Map.class directly is cleaner and avoids any anonymous class instantiation.
parsed = OBJECT_MAPPER.readValue(json, Map.class);There was a problem hiding this comment.
This code is only used as part of the migration and only for documents that have to be escaped, which probably represents a very small amount. Keeping as is to avoid new iterations
| fragment = getOrCreateDescriptorReader().readAutomationPackageFragment(fragmentYamlStream, importedFragmentReference, archive.getAutomationPackageName(), packageVersion); | ||
| fillAutomationPackageWithImportedFragments(targetPackage, fragment, archive, packageVersion); |
There was a problem hiding this comment.
Reassigning the method parameter fragment inside the loop is highly discouraged and can lead to subtle bugs or confusion (for instance, if the parameter is accessed later or if readers assume it refers to the original input). It is cleaner and safer to use a local variable for the imported fragment.
| fragment = getOrCreateDescriptorReader().readAutomationPackageFragment(fragmentYamlStream, importedFragmentReference, archive.getAutomationPackageName(), packageVersion); | |
| fillAutomationPackageWithImportedFragments(targetPackage, fragment, archive, packageVersion); | |
| AutomationPackageFragmentYaml importedFragment = getOrCreateDescriptorReader().readAutomationPackageFragment(fragmentYamlStream, importedFragmentReference, archive.getAutomationPackageName(), packageVersion); | |
| fillAutomationPackageWithImportedFragments(targetPackage, importedFragment, archive, packageVersion); |
| */ | ||
| protected MigrationManager initMigrationManager() { | ||
| MigrationManager migrationManager = new MigrationManager(); | ||
| try (AnnotationScanner annotationScanner = AnnotationScanner.forAllClassesFromClassLoader(AutomationPackageMigration.LOCATION, Thread.currentThread().getContextClassLoader())) { |
There was a problem hiding this comment.
Classpath scanning using AnnotationScanner is a heavy operation that can take hundreds of milliseconds or more. Performing this scan inside the constructor initMigrationManager() means it will run every time an AutomationPackageDescriptorReader is instantiated. Consider caching the scanned migration classes statically or initializing the migration manager once to avoid performance degradation.
There was a problem hiding this comment.
The AutomationPackageDescriptorReader is cached in the AutomationPackageReader which again should be instantiated once in the controller. But it's a valid point and we should check how it behaves, especially in the context of the CLI and Junit runner. Keeping open
david-stephan
left a comment
There was a problem hiding this comment.
The implementation of the feature looks good with some comments. For the migration part is looks complex and more difficult to assess the risks. Also seeing the size of the migration notes might scare / confuse some users. I wonder, given that values with such syntax "${" are probably very rare, if we should not have just notified users in the migration notes and add a step property to opt out (i.e. disable interpolation) ....
| @@ -1,4 +1,4 @@ | |||
| schemaVersion: 1.0.0 | |||
| version: 1.3.0 | |||
There was a problem hiding this comment.
why did we update all versions of our test resources, they were written for that version, and since there are no content changes they should still be compatible with that version. If we really want to keep it we should ensure we have tests for all previous version.
|
|
||
| /** | ||
| * Applies the migrations of the automation package format to a descriptor or fragment declaring an older schema | ||
| * version. This concerns the body of the file itself, the plans it contains are migrated by the yaml plan reader. |
There was a problem hiding this comment.
concretely the plans are part of the body too, not sure what a better world would be.
| @@ -80,15 +97,28 @@ protected Class<? extends AutomationPackageDescriptorYaml> getDescriptorClass() | |||
| } | |||
|
|
|||
| public AutomationPackageFragmentYaml readAutomationPackageFragment(InputStream yamlFragment, String fragmentName, String packageName) throws AutomationPackageReadingException { | |||
| * of the values reaching a DynamicValue is wider, see the migration documentation. | ||
| */ | ||
| @YamlPlanMigration | ||
| public class EscapeStringInterpolationYamlMigrationTask extends AbstractYamlPlanMigrationTask { |
There was a problem hiding this comment.
We should start using the same convention as for the controller DB migration task V1_3_0_...
| * it did. Authors opt into the interpolation by bumping the {@code version} of their descriptor and removing the | ||
| * escaping where they actually want a placeholder. | ||
| * <p> | ||
| * <b>The field set below is frozen.</b> It describes where a value sits in the schemas up to 1.2.0, which is closed: |
There was a problem hiding this comment.
I had the same issue when reading the javadoc for the DB migration tasks, this is generic for any migration tasks, it's written for the targeted version and should never be modified later, right?
| if (cached != null) { | ||
| return cached; | ||
| } | ||
| // Malformed strings aren't cached. They are rare and are re-parsed on every evaluation |
There was a problem hiding this comment.
comment is a bit weird, next lines does the parsing (always) but the cache is only updated if no StringInterpolationException is thrown
| int literalOffset = 0; | ||
| int length = source.length(); | ||
| int i = 0; | ||
| while (i < length) { |
There was a problem hiding this comment.
Feels a bit old school, but I guess it's fine
| clearValue.append(hasProtectedAccess ? String.valueOf(protectedVariable.value) : protectedVariable.obfuscatedValue); | ||
| containsProtectedValues = true; | ||
| } else { | ||
| // Covers GString results as well, whose toString() renders the interpolated value |
There was a problem hiding this comment.
we are in the else block of ProtectedVariable, so I'm not sure I get the comment
| Object protectedResult = null; | ||
| dynamicValue.evalutationResult = getEvaluationResult(dynamicValue.expression, bindings, dynamicValue.hasProtectedAccess()); | ||
| } else { | ||
| // A null result means that the value requires no interpolation and is to be returned as is |
There was a problem hiding this comment.
comment is not clear or not adapted for the next line of code
| } catch (Exception e) { | ||
| result.setEvaluationException(e); | ||
| } | ||
| return result; |
There was a problem hiding this comment.
I would expect to return null when there is nothing to interpolate, something is not clear to me.
No description provided.