Add S3 storage integration tests, and S3 support for webdataset index files - #6483
Conversation
|
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical unsupported-build handling and multiple moderate external-S3 lifecycle and configuration issues remain.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds S3 reader integration tests using a subprocess-based Moto server, with optional external S3-compatible endpoints.
Changes:
- Adds S3 lifecycle, seeding, environment, and capability helpers.
- Tests file readers, WebDataset readers, pagination, and error handling.
- Updates dependencies, sanitizer exclusions, and
AWS_ENDPOINT_URLdocumentation.
File summaries
| File | Changes and review comments |
|---|---|
qa/TL0_python-self-test-readers-decoders/test_nofw.sh |
Adds Moto, Flask, Flask-CORS, and boto3 dependencies. |
qa/TL0_python-self-test-readers-decoders/test_body.sh |
Skips the S3 module under sanitizers. |
docs/env_vars.rst |
Documents AWS_ENDPOINT_URL. |
dali/test/python/s3_test_utils.py |
Provides S3 server and setup helpers. Critical (2 votes): the unsupported-build probe does not exercise FileStream::Open, so BUILD_AWSSDK=OFF builds fail instead of skipping. Moderate: external runs do not reliably handle missing boto3 (1), overwrite standard AWS credentials (2), can hang before the startup timeout (1), force us-east-1 (1), and do not support external-region bucket creation (1). |
dali/test/python/reader/test_s3.py |
Adds file-reader and WebDataset tests, pagination, and missing-resource checks. Moderate: external runs leave seeded objects behind (2) and rely on a potentially pre-existing missing-bucket name (1). |
Review details
Suppressed comments (4)
dali/test/python/reader/test_s3.py:191
- The negative test relies on
dali-no-such-bucketnever existing, but that is not guaranteed whenDALI_TEST_S3_ENDPOINTpoints at a persistent MinIO or real S3 service (the name could already be present or owned by the test account). In that case listing succeeds or returns a different error and theNoSuchBucketassertion fails. Use a per-run unique bucket name or make the missing-bucket case configurable/skip it for external services.
def test_file_reader_missing_bucket():
with assert_raises(RuntimeError, glob="*NoSuchBucket*"):
file_pipe(file_root="s3://dali-no-such-bucket/data").build()
dali/test/python/s3_test_utils.py:193
- When
DALI_TEST_S3_ENDPOINTis set, this returns before checkingboto3, butsetUpModulestill callss3_client()and that function unconditionally importsboto3at line 166. Consequently an external MinIO/real-S3 run withoutboto3fails with an uncaught import error instead of being skipped; checkboto3unconditionally and only makemoto.serverconditional on the absence of the external endpoint.
if os.environ.get("DALI_TEST_S3_ENDPOINT"):
return
dali/test/python/s3_test_utils.py:94
- This blocking
readline()is executed before_wait_until_serving()appliesstartup_timeout_s. If moto import orserver.start()hangs before printing a port, the whole reader suite hangs indefinitely rather than failing after the configured 60 seconds; read/poll the pipe with the deadline (or add a watchdog) before waiting for the startup line.
line = self._proc.stdout.readline() # the child reports the port it bound
dali/test/python/s3_test_utils.py:29
REGIONis forced tous-east-1and then exported over both standard region variables, so the advertised external real-S3 mode cannot use a bucket in another region: the unconditionalcreate_bucketcall will require a location constraint, and DALI requests will be signed with the wrong region. Allow the external test region to be configured and create the bucket with the matchingLocationConstraintwhen needed.
REGION = "us-east-1"
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
!build |
|
CI MESSAGE: [67423372]: BUILD STARTED |
|
CI MESSAGE: [67423372]: BUILD FAILED |
5d1d821 to
a383378
Compare
| # flask is pinned to 3.0 on purpose: 3.1 requires blinker>=1.9, and upgrading the blinker that | ||
| # the base image installs through apt fails with `uninstall-no-record-file`. flask 3.0 asks for | ||
| # blinker>=1.6.2, which the preinstalled one already satisfies, so nothing is uninstalled. | ||
| pip_packages='${python_test_runner_package} numpy librosa scipy nvidia-ml-py==11.450.51 psutil dill cloudpickle pillow opencv-python-headless astropy av lmdb moto==5.2.3 flask==3.0.3 flask-cors boto3' |
There was a problem hiding this comment.
I wonder if we can extend package installation logic to catch uninstall-no-record-file and then apply --ignore-installed` to pip cmd.
There was a problem hiding this comment.
Good idea, and pip itself now recommends exactly that — in pip 26.2 the hint attached to this error changed from --force-reinstall --no-deps to --ignore-installed --no-deps. I prototyped it and it does work, but it has enough sharp edges that I would rather send it on its own than fold it into a test PR.
What I found, on a venv with a blinker 1.7.0 whose RECORD I deleted to emulate the apt layout:
- The string to match is not stable across pip versions. pip 25.1 prints
error: uninstall-no-record-file; pip 22.0 printsERROR: Cannot uninstall blinker 1.7.0, RECORD file not found.with no reference token at all; and there is the siblinguninstall-distutils-installed-package. So the match has to be a three-way alternation, and all of it goes to stderr, so the capture needs2>&1. --ignore-installedis not scoped to the offending package — it clears the resolver's entire view of installed distributions, so nothing is uninstalled and everything is overwritten in place. After a successful retry, site-packages holds bothblinker-1.7.0.dist-infoandblinker-1.9.0.dist-info,importlib.metadata.version("blinker")reports the stale 1.7.0,pip listagrees, and files dropped between the two versions survive.- It does not heal the environment. A later
pip install "blinker>=1.9"in the same container fails with the same error, because the RECORD-less dist-info is still there. So the fallback rescues each command individually and leaves the underlying state broken. (pip's older--force-reinstall --no-depssuggestion does not work either — same error.) - The shell side has two traps. With
set -ein force, the obvious{ cmd1 || cmd2; echo $? > f; } | tee logsilently loses the status: errexit kills the pipeline subshell on the failing AND-OR list before theechoruns. And$PIPESTATUSis only meaningful withset -o pipefail— a barepipeline || ret=$?would readtee's 0 and turn a failed install into a pass. The version I have wraps the AND-OR list in anif, passes the status through a temp file, keeps${install_cmd}unquoted so globs and multi-package commands behave as today, and returns pip's real exit code so the call sites are unaffected.
Since install_pip_pkg is on the path of all 51 suites that source qa/test_template.sh, plus the CUDA wheel installs, the numpy<2 downgrade in TL0_python-self-test-core and the TF install in TL1_tensorflow-dali_test, I will send it as its own PR and relax this pin to flask<4 in that same commit, so that PR's CI run is itself the proof the new path fires. The upper bound stays because moto is pinned at 5.2.3 and moto.server sits on flask + flask-cors.
For the record, the version facts in the comment check out — flask 3.0.3 requires blinker>=1.6.2, flask 3.1.0 requires blinker>=1.9 — and this pin is exactly what the last CI failure was: that pipeline ran the unpinned flask revision and died in pip install flask with uninstall-no-record-file before a single test executed.
There was a problem hiding this comment.
Opened as #6485.
One adjustment from what I said above: it doesn't relax the flask pin in the same commit after all. That pin lives on this PR's branch, which hasn't merged, so qa/TL0_python-self-test-readers-decoders/test_nofw.sh on main has no flask entry yet to relax - there's nothing there for #6485 to touch. Once both land, relaxing flask==3.0.3 to flask<4 here is a one-line follow-up, and it'll double as the first real CI exercise of the new retry path.
There was a problem hiding this comment.
I think you can merge #6485 (already reviewed) and adjust the code here.
There was a problem hiding this comment.
#6485 is approved, checks green, and mergeable. Once it lands on main I'll relax flask==3.0.3 to flask<4 here in the same PR so this branch's CI actually exercises the new retry path.
|
Pushed 27f41c3, which addresses the review. Summary for anyone reading the diff rather than the threads:
Two threads are left open on purpose: the single-vs-double backtick one, where the new heading matches all 22 headings on that page and changing only it would look worse, and the Verified locally before pushing, against moto 5.2.3 (the pinned version), with
One thing worth flagging: the earlier CI run never executed these tests. It died in |
|
!build |
|
CI MESSAGE: [67947457]: BUILD STARTED |
|
CI MESSAGE: [67947457]: BUILD FAILED |
DALI's s3:// support had no test coverage. Add a nose2 module that starts a mock S3 server in the background, seeds a bucket, and asserts that the readers produce byte-identical results to the same data on local disk. The server is moto, run in a subprocess. It has to be a subprocess: Pipeline Build() is bound without py::call_guard<py::gil_scoped_release>, and S3 object listing happens inside Build(), so an in-process server thread is GIL-starved and the request eventually times out in libcurl. The endpoint is always an IP literal. DALI does not set useVirtualAddressing, so aws-sdk-cpp only selects path-style addressing when the endpoint host is an IP; with a host name the bucket is prepended to the host instead, which needs a matching DNS entry (and MINIO_DOMAIN, for a real MinIO). DALI_TEST_S3_ENDPOINT points the same tests at MinIO or at real S3 instead. In that mode credentials fall back to the standard AWS variables, every object is written under a prefix unique to the process, and the prefix - plus the bucket, if the test created it - is removed again on teardown, so a shared endpoint is left as it was found. Covered: fn.readers.file with file_root= and files= (including a key with a space and a zero-byte object), ListObjectsV2 continuation across the 1000-key page limit, fn.readers.webdataset with an inferred and with a local index, and the missing-object and missing-bucket error paths. The tests are wired into the existing TL0_python-self-test-readers-decoders suite, which already runs nose2 over dali/test/python/reader, so only the pip package list needs updating. They skip cleanly when moto is absent, which keeps the conda and tegra suites - which also run that directory - unchanged, and are skipped under sanitizers because qa/leak.sup has no suppressions for the AWS SDK, libcurl or OpenSSL globals. The BUILD_AWSSDK=OFF probe reads through file_root, because only a listing touches S3 while the pipeline is built. Also document AWS_ENDPOINT_URL, which the tests depend on and which was previously undocumented. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
Bound the mock server startup. ThreadedMotoServer.start() waits on an event that its server thread never sets if make_server() raises, so a child that stalls before printing its port made the parent block in readline() forever - the 60 s deadline was only armed afterwards. A watchdog kills the child, which closes the pipe and turns the read into the EOF the code already reports. State the bucket location when it is not us-east-1. CreateBucket outside us-east-1 is rejected with IllegalLocationConstraintException when it omits the LocationConstraint, and rejected with InvalidLocationConstraint when it sends us-east-1, so it has to be conditional. The previous form failed for anyone with AWS_DEFAULT_REGION exported, not only for real S3. Never delete the bucket. Deleting it at teardown races a concurrent run sharing the endpoint: either DeleteBucket fails because that run's objects are still there, or it succeeds and pulls the bucket from under it. Ownership could not be told apart anyway, because in us-east-1 re-creating a bucket you already own answers 200 OK instead of BucketAlreadyOwnedByYou. Objects are still removed, and DALI_TEST_S3_BUCKET is documented as needing to be disposable. Leave credentials alone when DALI_TEST_S3_ENDPOINT is set. The mock defaults were exported unconditionally, so a real-S3 run authenticated by a profile, an SSO session or an EC2 instance role was signed with dalitestaccesskey instead; AWS_EC2_METADATA_DISABLED=true was cutting off the instance role as well. The dummy values now apply only to the mock server, and boto3 gets None and resolves credentials through its own chain, which is also what carries AWS_SESSION_TOKEN. Derive the bucket used by the error paths from the per-run uuid. Bucket names are one global namespace, so a hardcoded dali-no-such-bucket may be owned by somebody else on real S3 - ListObjectsV2 then answers AccessDenied, not NoSuchBucket - or be left over on a shared MinIO, where listing just succeeds. Also: generate the webdataset index inside the try that triggers cleanup, since unittest skips tearDownModule once setUpModule raised; report the per-key errors that DeleteObjects returns instead of raising; require boto3 in both modes and moto only in the mock one; say why the BUILD_AWSSDK probe swallows what it does. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
27f41c3 to
54484e6
Compare
|
!build |
|
CI MESSAGE: [68014495]: BUILD STARTED |
|
CI MESSAGE: [68014495]: BUILD PASSED |
3000 samples puts the index at ~137 KiB (measured), past FileStreamBuf's 64 KiB read chunk, so this exercises multiple Read() round trips against S3 - including the final short read at the exact end of the index, which is the case the FileStreamBuf EOF fix targets. Marked slow and generated lazily in the test itself, not setUpModule, to keep it off the cost of every other test in this module. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
|
CI MESSAGE: [68235175]: BUILD STARTED |
FileStream::Read documents that a return smaller than requested is allowed without meaning end-of-stream, and ODirectFileStream (a raw read() syscall, unlike StdFileStream's fread-based retry-to-EOF or MmapedFileStream's exact remaining-bytes clamp) can genuinely exercise that. Treating any short read as EOF, as the previous fix did, could silently truncate the buffered stream for that backend. Bound the request by remaining_, tracked from Size(), instead: that's what a remote FileStream actually can't tolerate reading past (an out-of-range byte range raises there rather than returning 0), and retry Read() within the buffer's remaining budget until it's full or a call returns 0, which is the only real EOF signal per the documented contract. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
…versions Mocks two real, divergent FileStream backends: ShortReadStream (models ODirectFileStream - a raw read() syscall that can legitimately return fewer bytes than requested without meaning EOF) and RemoteLikeStream (models S3FileStream - never returns 0 for EOF, and reading a byte range starting at or past the object's end raises). Compiled and ran this exact test, unmodified, against the header as it stood at each of the last 3 commits, standalone (no full DALI build): before 9249de1 (no eof_/remaining_ tracking): passes ShortReadStream, throws on both RemoteLikeStream cases 9249de1 (short read == EOF): passes RemoteStreamNeverReadsPastEnd (its intended fix), but truncates ShortReadStream AND still throws on the exact-multiple- of-buffer-size RemoteLikeStream case - the heuristic didn't even fully cover what it was meant to fix 9389d49 (current, remaining_-tracked retry): passes all three Confirms the bug was real, that the first fix traded one failure mode for two others, and that the current fix resolves them together. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
|
Added
Before pushing, I compiled and ran this exact test file, unmodified, against the header as it stood at each of the last 3 commits (standalone harness, not the full
The middle row is the interesting one: the first fix didn't even fully solve what it targeted — an exact-buffer-size-multiple payload never triggers a "short" read, so |
|
CI MESSAGE: [68238308]: BUILD STARTED |
Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
Caught by CI: dali/util/file_test.cc:72 uses std::move in the mock FileStream constructors without including <utility>. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
|
CI MESSAGE: [68240550]: BUILD STARTED |
|
CI MESSAGE: [68240550]: BUILD PASSED |
…x file RecordIOLoader::ReadIndexFile read its index through a raw std::ifstream, the same local-only gap webdataset_loader.cc's ParseIndexFile had before this PR - its shards (paths_) already went through FileStream::Open, only the index didn't. TFRecord doesn't have this gap: IndexedFileLoader::ReadIndexFile already streams through FileStream::Open/FileStreamBuf. Fixed the same way as webdataset, and added test_mxnet_reader_local_index/ test_mxnet_reader_remote_index to reader/test_s3.py, backed by a small synthetic RecordIO shard built with no external dependency. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
DALI_TEST_S3_ENDPOINT/ExternalS3Server and the credential/region/bucket fallback chains that existed only to serve it were unused by anything in this PR - only DALI_TEST_S3_VERBOSE is actually exercised. s3_test_utils.py now always starts the mock server, with fixed mock credentials/region/ bucket, and the now-dead delete_prefix helper is gone along with tearDownModule's external-endpoint cleanup branch. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
| kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION} | ||
| try: | ||
| client.create_bucket(**kwargs) | ||
| except client.exceptions.BucketAlreadyOwnedByYou: |
|
CI MESSAGE: [68348405]: BUILD STARTED |
|
CI MESSAGE: [68348405]: BUILD FAILED |
Same issue as GCSClientManager (see the GCS PR, NVIDIA#6467): header-only with Instance() a function-local static in an inline method, so with -fvisibility=hidden as the project default, each shared library that includes the header compiles its own private copy. discover_files_s3.cc (dali_operators) and s3_file.cc/file.cc (dali) each got their own singleton, each running Aws::InitAPI/Aws::ShutdownAPI and taking its own one-time getenv() snapshot of AWS_ENDPOINT_URL/DALI_S3_NO_VERIFY_SSL independently. Moved Instance()'s definition (and the constructor/destructor/ RunInitOrShutdown it depends on) out-of-line into a new s3_client_manager.cc, compiled only into libdali.so (dali/util/CMakeLists.txt), and marked the struct DLL_PUBLIC - dali_operators already links against dali, and neither library's version script restricts default-visibility symbols beyond an unrelated cuFile* exclusion, so ordinary ELF symbol resolution now gives both libraries the same instance. Compile-verified locally: BUILD_AWSSDK=ON in this environment, and `make dali` links libdali.so cleanly with the new s3_client_manager.cc.o. Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
|
CI MESSAGE: [68364637]: BUILD STARTED |
|
CI MESSAGE: [68365175]: BUILD STARTED |
|
CI MESSAGE: [68365175]: BUILD FAILED |
fn.readers.mxnet(...) returns its (image, label) outputs through a multi-output wrapper, not a plain Python tuple. Returning it directly from an @pipeline_def function fails at pipeline build time with "Illegal pipeline output type. The output 0 contains a nested DataNode." - file_pipe/wds_pipe already wrap their reader calls in tuple() for the same reason; mxnet_pipe didn't. Caught by CI: test_mxnet_reader_local_index/test_mxnet_reader_remote_index both errored during Pipeline.build(). Signed-off-by: Joaquin Anton Guirao <janton@nvidia.com>
|
CI MESSAGE: [68392609]: BUILD STARTED |
|
CI MESSAGE: [68392609]: BUILD PASSED |
Category:
New feature (S3 support for webdataset and RecordIO index files, plus integration tests and
documentation)
Description:
DALI's
s3://support had no test coverage. This adds integration tests that start a mock S3 serverin the background, seed a bucket, and assert that the readers return byte-identical results to the
same data read from local disk.
The mock server is
moto, started and torn down by the test module itself, so no external service orCI-side fixture is needed.
While covering
fn.readers.webdataset, it became clear thatindex_pathsonly ever worked for localfiles:
ParseIndexFileopened it through a rawstd::ifstream, unlikepaths(the tar shards),which already goes through
FileStream::Openand therefore already supportss3://. This PR addsthat support:
index_pathscan now point at S3 (or any otherFileStream-backed scheme) alongsidethe shards it describes. The index is streamed through
FileStreamBuf(the same utilityIndexedFileLoaderalready uses for local index files) rather than read into memory in full, sinceindex_pathsis a public, caller-controlled argument and the index can be arbitrarily large. Wiringthis up also surfaced and fixed a latent bug in
FileStreamBufitself: the read exactly atend-of-file could issue an out-of-range request on a remote stream (a local file just returns 0
bytes; S3 raises on a byte range starting at or past the object's end) - previously unreachable since
nothing had used
FileStreamBufagainst a remoteFileStreambefore.Review raised the same question for the other index-file readers:
fn.readers.tfrecordwas alreadyfine (
IndexedFileLoader::ReadIndexFilehas gone throughFileStream::Open/FileStreamBufsince#5515), but
fn.readers.mxnet'sRecordIOLoader::ReadIndexFilehad the identical local-onlystd::ifstreamgapwebdataset_loader.ccjust lost - its shards (paths_) already usedFileStream::Open, only its index didn't. Fixed the same way, in scope here, with matching S3coverage.
Two implementation details in the mock-server helper are load-bearing and are commented in the source
so they are not "simplified" away later:
Pipeline::Buildis bound withoutpy::call_guard<py::gil_scoped_release>, and S3 object listing happens insideBuild(), so anin-process server thread is GIL-starved and the request eventually times out in libcurl
(
curlCode: 28). Everymotoexample shows the in-process form, hence the comment.useVirtualAddressing, so the AWS SDKonly selects path-style addressing when the endpoint host is an IP. With a host name the bucket is
prepended to the host instead, which requires a matching DNS entry (and
MINIO_DOMAIN, for a realMinIO).
Additional information:
Affected modules and functionalities:
dali/operators/reader/loader/webdataset_loader.cc/.h:index_pathsnow opens throughFileStream::Openand is parsed through a streamingFileStreamBuf, instead of a local-onlystd::ifstreamread fully into memory.dali/operators/reader/loader/recordio_loader.h: same fix forfn.readers.mxnet's index file.dali/util/file.h: fixes a latent end-of-file bug inFileStreamBufthat only a remoteFileStreamcan trigger (see above).dali/test/python/s3_test_utils.py(new): mock server lifecycle, env setup, bucket helpers andthe
require_mock_server/require_s3_supportpreconditions. Mock-only: nothing here points at areal endpoint, so credentials/region/bucket are fixed rather than configurable.
dali/test/python/reader/test_s3.py(new): the tests.qa/TL0_python-self-test-readers-decoders/test_nofw.sh: addsmoto,flask,flask-corsandboto3topip_packages. Plainmotodoes not pull inflask; that lives in themoto[server]extra, which also drags in
cfn-lint,dockerand more.qa/TL1_python-self-test_conda/test_nofw.sh: adds the same packages, so the conda suite gets realS3 coverage instead of skipping the module.
qa/TL0_python-self-test_tegra/test_body.sh: excludestest_s3.pyexplicitly - unlike conda,aarch64 wheel availability makes installing
moto/boto3there impractical.qa/TL0_python-self-test-readers-decoders/test_body.sh: the sanitizer-skip removed fromtest_body.shfor this module was actually redundant -SKIP_TESTS="test_numpy.py test_s3.py"here already excludes it under
DALI_ENABLE_SANITIZERS.docs/env_vars.rst: documentsAWS_ENDPOINT_URL, which the tests rely on and which waspreviously undocumented.
Key points relevant for the review:
S3ClientManageralready readsAWS_ENDPOINT_URL. The webdatasetindex_pathschange is the one real source change in this PR.require_mock_server/require_s3_supportraise instead of skipping when boto3/moto are missing orBUILD_AWSSDKis off, mirroring the equivalent decision already made on the GCS PR: every suitethat runs
dali/test/python/readeris expected to have these, so a missing dependency there is anenvironment bug, not something that should silently skip the coverage.
qa/leak.suphas no suppressions for the AWS SDK, libcurlor OpenSSL globals, and the mock server subprocess would inherit
LD_PRELOAD.motodoes not verify SigV4, so the key-with-a-space casepins URL path construction rather than canonical-request encoding. Signing fidelity would need a
separate run against MinIO or real S3; not attempted here.
device_id=None), on a suite that already runs for several minutes.Roughly 9 s of that is the listing-pagination case, which seeds 1100 objects; the multi-chunk
webdataset-index test is marked
slowand adds a few more seconds.Tests:
New module
reader/test_s3.py, covering:test_file_reader_file_rootListObjectsV2with a prefix, label assignment,file_filtersover keystest_file_reader_files_argHeadObject+ rangedGetObject, a key containing a space, a 0-byte objecttest_file_reader_listing_paginationtest_webdataset_index_inferredtest_webdataset_local_indextest_webdataset_remote_indextest_webdataset_remote_index_multi_chunkFileStreamBufreads (including the end-of-file boundary case)test_mxnet_reader_local_indextest_mxnet_reader_remote_indextest_file_reader_missing_objecttest_file_reader_missing_bucketReaders that do not route through
FileStream::Open(FITS, LMDB-backed caffe/caffe2, nemo_asr,sequence, video) are out of scope.
fn.readers.numpyovers3://is also left out of this PR - ithits a separate bug, fixed in DALI-4887; it can be added to this matrix once that lands.
Checklist
Documentation
DALI team only
Requirements
REQ IDs: N/A
JIRA TASK: DALI-4010