Repository navigation
Sanitize unsupported ObjectStore keys and throw when the sanitized name holds another object - #10029
Conversation
…me holds another object An unsupported key is saved under its sanitized name and found by either name, with a warning the first time per store. A save whose sanitized name already holds an object this store did not write from that key throws, so an object from an upload, the CLI, another algorithm or a previous run is only replaced through its exact key. LocalObjectStore keeps rejecting unsupported keys with the per-issue message as the backstop.
…th the save that follows Keys may now carry dots in folder names and multiple extensions: only '..' is unsupported, since it would leave the storage root, along with a key that is nothing but dots and separators. A backslash is no longer a supported character and is sanitized to '/', the separator the local store already read it as. The supported check reuses the unsupported-characters regex, so the character set is written once and a trailing newline is no longer accepted. GetFilePath of an unsupported key registers its sanitized name in the store, so the save that followed threw the collision error. The key is now paired with that name when the call creates the entry.
f9a717e to
5c9c02a
Compare
|
Rebased on master and updated. The newest changes:
The description is updated to match. |
There was a problem hiding this comment.
Requesting changes: one regression for keys that work today, a design question on the main use case that needs a person to decide, and a missing end-to-end run.
CI is green on 5c9c02a.
1. A key with \ that works on master throws from the second run on (blocks). This PR now treats \ as unsupported. Before, the local store accepted it and stored it as /. Now it goes through the sanitized-save path, and its collision rule throws once the / name already exists, which it does after the first run. So an algorithm that saves ObjectStore.Save("folder\\results.csv", ...) every backtest works on master and throws ArgumentException here from the second backtest on. Reproduced locally:
Save("kronos_folder\\results.csv", "run1") // fresh store, run 1: ok
Save("kronos_folder\\results.csv", "run2") // fresh store, run 2:
ArgumentException: ObjectStore: key 'kronos_folder\results.csv' is not supported and its sanitized name 'kronos_folder/results.csv' already holds another object. ...
The same test passes on master. LocalObjectStore.SaveBytes now also rejects \ for callers that bypass the wrapper (ObjectStore.ValidateKey). The store has always read \ and / as the same object, so the collision rule doesn't protect anything there. A smaller fix: turn \ into / before the supported check, so it never enters the collision path and the backstop accepts it as before. Please add a test that saves a \ key in two runs.
2. The case in the description fails again on the next backtest (for a person to decide). The motivation is that "a completed backtest should not die on its final persistence call over a key spelling". With this change, Save("trade_log_ai_hardware_&_cloud.csv", ...) in OnEndOfAlgorithm succeeds on the first backtest of a project. Every later backtest, and a live algorithm after a redeploy, throws at that same call, because the sanitized object came from an earlier run (ANewRunThrowsOnSavingAnUnsupportedKeyAPreviousRunSanitized covers this on purpose). Users rerun backtests all the time. On master the failure shows up on the first run. Here it waits until the user is relying on the output. Choosing between "never overwrite an object this run didn't write" and "an unsupported key reliably persists across runs" is a product decision, so I'm asking a QuantConnect member to make it on this PR. Possible options: let a save overwrite the sanitized name and warn every time, or keep rejecting unsupported keys with the clearer per-issue message and leave sanitizing out.
3. ContainsKey and the other reads now throw for a key with no name (minor). ContainsKey("/"), or a key made only of dots and separators or with more than 100 /, returned false on master and now throws ArgumentException from SanitizeKey, via KeyToRead. Reproduced locally; it passes on master. This is a new way for a check-before-read to fail. On the read path, fall back to the key as given when it can't be sanitized.
Testing evidence. The unit and regression tests are thorough on the paths they cover. Two gaps: no test saves a \ key in two runs, which is why (1) slipped through, and there's no end-to-end run. This changes how algorithms persist data, so please add an end-to-end outcome to the description: a project backtested twice that saves an unsupported key and a \ key in OnEndOfAlgorithm, and what each run did.
Other repositories. QuantConnect/lean-cli#698 prints the warnings for object-store set and stays consistent with this. The Documentation repository needs the key rules and collision behavior, as the description says. The new optional warn parameter on ObjectStore/PythonObjectStore reaches the generated stubs on release.
Risk label: risk:high. This changes the user-facing ObjectStore API: what saves accept and when they throw, plus a new constructor parameter. Item (1) breaks algorithms that work today. To lower it: fix (1) and (3), get a decision on (2), and add an end-to-end run.
| public class ObjectStore : IObjectStore | ||
| { | ||
| // The single source of the key charset rules, also enforced by the LocalObjectStore implementation | ||
| private static readonly Regex UnsupportedKeyCharactersRegex = new(@"[^a-zA-Z0-9/_#\-\$= .]", RegexOptions.Compiled); |
There was a problem hiding this comment.
\ isn't in this set any more, so a key the local store accepted on master (folder\results.csv, stored as folder/results.csv) now goes through KeyToSave's collision check and throws from the second run on. LocalObjectStore.SaveBytes also rejects it now through ValidateKey. Turning \ into / before the supported check would keep today's behavior. See (1) in the review.
|
|
||
| var key = SanitizeKey(path); | ||
| WarnOnce(path, key); | ||
| if (!_savedKeys.ContainsKey(path) && _store.ContainsKey(key)) |
There was a problem hiding this comment.
_savedKeys starts empty in every run, so the sanitized object an earlier backtest saved through this same key makes every later backtest throw here at the same OnEndOfAlgorithm save the description wants to fix. See (2) in the review, which needs a QuantConnect member's decision.
| return path; | ||
| } | ||
|
|
||
| key = SanitizeKey(path); |
There was a problem hiding this comment.
SanitizeKey throws when there is no valid key to sanitize to ("/", "./", >100 /), so ContainsKey, ReadBytes, Delete and GetFilePath throw ArgumentException here where master returned false or not-found. Reproduced with ContainsKey("/"). On the read path, falling back to path when sanitizing fails would keep master's behavior.
A key such as folder\results.csv worked before this branch: the local store reads the backslash as a forward slash. Treating it as unsupported sent it through the sanitized-save path, whose collision rule threw from the second run on. The backslash is an accepted separator again and is passed to the store unchanged, with no warning. Adds a test that saves a backslash key in two runs.
|
Thanks for the review. Finding 1 is fixed in e2b689d, and findings 2 and 3 were decided by a QuantConnect member, with no code change. 1. A
2. A later run throws for the same unsupported key. Decided: keep it. The first run's warning names the sanitized key and says a save throws when that name already holds an object not written from that key in this run. So it is on the user to switch to the supported key. The rule stays that an object is only replaced through its exact key. The description records the decision. 3. Reads throw for a key with no name. Accepted as is. A key such as End-to-end run, as asked. One algorithm backtested twice through the Launcher against the same object store folder, saving both keys in
Run 2's error is the behavior decided in finding 2. The outcome is in the description. Result. Object store, Python wrapper, Other repositories. QuantConnect/lean-cli#698 is unaffected. The Documentation change stays listed as required in the description. |
…nd list both keys A save through an unsupported key always goes to its sanitized name and replaces the object there. Reads use the sanitized name when it exists, else an object stored under the name as given. ObjectStore.Keys lists an unsupported key saved in the run next to its sanitized name, and its Contains finds a key the way ContainsKey does.
|
Pushed What changed:
Why: the collision rules needed state and several special cases, and a later run threw on the key the first run had sanitized. The user is warned with the key used, so keeping keys apart is on them. What to know when reviewing:
Tests: the collision tests are replaced by replace-on-save, new-run and |
ObjectStore.Keys returns at once and lists the store when it is first enumerated, counted or copied. Contains, and Python's in, look the key up without listing. With no sanitized key in the run the store's own keys are returned as they are.
|
Pushed What changed:
What to know when reviewing:
Test: Since the last review, the two commits to look at are |
There was a problem hiding this comment.
Approving e24e6d0. My earlier findings are resolved or decided, CI is green on this commit, and I ran it end to end myself.
Earlier findings.
- A
\key threw from the second run on: fixed ine2b689d.\is a separator again and is passed to the store unchanged.ABackslashKeyIsSavedInEveryRunAsOnTheStoreItAlwaysReadAsASeparatorcovers it. - A later run threw for the same unsupported key: resolved by
e09336f. A save always goes to the sanitized name and replaces whatever is there. As the description records, a QuantConnect member decided this, along with keys that sanitize alike sharing one object. A member also approved that commit. - Reads throw for a key with no name, such as
/: kept as is, and the description now states it. I'm not raising it again.
What changed since my last review.
e09336fdrops the per-run collision state. Reads prefer the sanitized name, then fall back to an object stored under the name as given.Keyslists an unsupported key saved in this run next to its sanitized name, but only while that object exists.Keys.Containsnow agrees withContainsKey.e24e6d0makesKeyslazy. The listing still comes fromLocalObjectStore.Keys, a fresh list, so deleting while iterating stays safe.Containsand Python'sindo a singleContainsKeylookup. The description lists the one behavior change: aKeysvalue held in a variable now reflects the store at its first enumeration.LocalObjectStore.SaveBytesnow callsObjectStore.ValidateKey. It accepts everything the old regex did, keeps rejecting the rest with the per-issue message, and still caps separators at 100.
End-to-end run. I built this commit and ran one Python algorithm as two backtests through the Launcher against one object store folder. In on_end_of_algorithm it saves trade_log_ai_hardware_&_cloud.csv and e2e_folder\results.csv, then reads them back.
- Run 1: both saves returned
True. The warning above reached the algorithm's debug output. Both keys and the sanitized//spellings read back the saved content.inandlenonobject_store.keysworked, and the keys listede2e_folder/results.csv, the unsupported key and its sanitized name. - Run 2: both keys first read back run 1's content. Both saves then returned
Trueand replaced it with run 2's. The same three keys were listed. The folder held onlye2e_folder/results.csvandtrade_log_ai_hardware___cloud.csv.
That is the outcome the description promises: the end-of-run save no longer fails, in the first run or any later one.
Other repositories. The Documentation repository still needs the key rules, how keys are sanitized and listed, and how existing objects under unsupported names behave, as the description says. QuantConnect/lean-cli#698 stays consistent with this. The new optional warn parameter on ObjectStore and PythonObjectStore reaches the stubs on the next release.
Risk label: risk:high, unchanged. This changes user-facing ObjectStore API and behavior: which keys a save accepts and where it stores them, what Keys returns and when it lists, and a new constructor parameter. The evidence is strong: unit tests, regression algorithms in C# and Python, green CI and the end-to-end run. But a change this wide to how algorithms persist data doesn't qualify for risk:low.
Description
A key built from a user-facing name fails only at the end of the run, in
OnEndOfAlgorithm, withLocalObjectStore: path is not supported: 'trade_log_ai_hardware_&_cloud.csv'. The output of an otherwise green backtest is lost.The key rules:
.,/,\,_,#,-,$,=. Dots are allowed anywhere;..never is, since it would leave the storage root.\stays a separator, as before this PR: the local store reads it as/, sofolder\results.csvandfolder/results.csvare one object.ObjectStoresaves an unsupported key under its sanitized name instead of rejecting it: every unsupported character and every.of a..run become_.ObjectStore.Keyslists an unsupported key saved in the run next to its sanitized name, while that object exists.Keys.Contains, and Python'sinon the keys, find a key the wayContainsKeydoes, with one lookup and no listing.Keyslists the store only when it is first enumerated, counted or copied, once per returned collection.QCAlgorithm.Debug(the engine log inQuantBook). It names the issues, the rules and the key used./, cannot be sanitized: a save or a read through it throws.LocalObjectStore.SaveByteskeeps rejecting unsupported keys, as the backstop for callers that bypass the wrapper. The rule helpers are private toObjectStore.Decided by a QuantConnect member, to keep the store simple:
p&q.csvandp:q.csv, share one object and replace each other. The warning tells the user which key is used.Keys. Enumerating the store itself yields it once.Keyscollection held in a variable shows the store as of its first enumeration, not as of theKeyscall.Warning shape:
Builds on #9905, already merged:
PythonObjectStoreforwards the warning callback, so a Python algorithm's key warnings reach its debug log, and its save helpers follow the same rules.Related Issue
N/A
Motivation and Context
A completed backtest should not die on its final persistence call over a key spelling.
Requires Documentation Change
Yes: the key rules, how an unsupported key is sanitized and listed, and how existing objects under unsupported names behave.
How Has This Been Tested?
LocalObjectStoreTests: sanitization on save and the reads that follow, dots kept in folder names and multiple extensions, the once-per-store warning text, the issue wording per case, empty and nameless keys.LocalObjectStoreTests: traversal keys (../x,../../x,x/../y,x/../../y, with\too) stored with no..left and their file paths inside the storage root.LocalObjectStoreTests: a save through an unsupported key replacing the object under its sanitized name, in the same run and in a new run.LocalObjectStoreTests:GetFilePathof an unsupported key returning the file the save that follows reads.LocalObjectStoreTests: existing unsupported keys read as given, also as custom data; a save to such a key going to the sanitized name, which reads then return.LocalObjectStoreTests:Keysholding both names once each after a save, the unsupported one leaving with the object, andContainsmatchingContainsKey. The same from Python withinandlist().LocalObjectStoreTests:Containsfrom C# andinfrom Python causing no folder listing, enumeration listing once per collection, and a newKeysaccess seeing a later save.LocalObjectStoreTests: theLocalObjectStorebackstop;QCAlgorithmwarning throughDebugandQuantBookthrough the engine log.LocalObjectStoreTests: a\key saved in two runs and read back by both spellings, with no warning.LocalObjectStoreTests: the Python helpers save and read an unsupported key under its sanitized name.AlgorithmPythonWrapperTests: a Python algorithm's key warning lands in its debug log.ObjectStoreKeySanitizationRegressionAlgorithm(C# and Python): the unsupported key is stored, listed and read back under both names, updated, and replaces an object written directly under the sanitized name.QuantBookTestsandCustomLiveDataFeedTests.Types of changes
Checklist:
bug-<issue#>-<description>orfeature-<issue#>-<description>