Skip to content

feat(scan): support timestamp-based time travel - #3260

Merged
laskoviymishka merged 17 commits into
apache:mainfrom
dhruvarya-db:feat-timestamp-time-travel
Oct 21, 2026
Merged

laskoviymishka merged 17 commits into
apache:mainfrom
dhruvarya-db:feat-timestamp-time-travel

Conversation

@dhruvarya-db

@dhruvarya-db dhruvarya-db commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

None.

What changes are included in this PR?

Adds timestamp-based time travel through snapshot_id_as_of_time and TableScanBuilder::as_of_time:

let scan = table.scan().as_of_time(timestamp_ms).build()?;

The timestamp is in epoch milliseconds with an inclusive cutoff. Selection uses the loaded table's main snapshot history, including rollback events. The first history entry wins equal timestamps, and lookup handles clock-skewed history without assuming timestamp order.

The selected snapshot uses the existing snapshot-ID scan path, including its historical schema, projection, filtering and reader behavior. Unavailable history or a missing snapshot returns an error. A private snapshot-selection enum stores one choice; the last call to either selector takes precedence. Default scans remain unchanged. Public API signatures are included.

Are these changes tested?

Yes. Runnable doctests cover both new public APIs. Unit tests cover timestamp boundaries and extremes, rollback, ties, clock skew, unavailable history, missing snapshots, last-call-wins selection in both orders, historical schemas, and file-task/Arrow-row equivalence with explicit snapshot-ID scans.

The historical-row regression reads real Parquet data through distinct snapshots: timestamp and explicit-ID scans return the older row (x = 100) with a one-column schema, while the default scan returns 2,048 current rows (x = 1) with eight columns. The test was also verified to fail when timestamp selection was temporarily bypassed; the implementation was then restored.

AI Disclosure

Implemented with assistance from OpenAI Codex. All code has been manually reviewed.

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice — this is a clean implementation and the test coverage is better than most first passes I see: unsorted/skewed logs, equal-timestamp ties, rollback records, expired prefixes, schema fallback, and a real end-to-end read against a historical snapshot. I traced the selection logic against Java's SnapshotUtil and it matches exactly — nullableSnapshotIdAsOfTime uses the same strict-> max-timestamp accumulator, so first-wins-on-ties and the ≤60s-skew cases you've encoded are the correct behavior, not a divergence. Good to see those pinned down in tests.

The one thing I'd settle before this merges is the public-API shape. snapshot_id_as_of_time takes &TableMetadata while ancestors_of/ancestors_between in the same module take &TableMetadataRef, and the param is metadata vs their table_metadata. Trivial to align now and semver-breaking once public-api.txt ships, so better before the first publish than after.

A few smaller things, none blocking:

  • The tie-break is correct but fragile to a well-meaning >→>= edit; I'd restructure it so first-wins is explicit and locally obvious (left an inline).
  • Worth a docstring line that the returned id may not resolve via snapshot_by_id if the snapshot was later expired.
  • Cross-engine wrinkle worth a doc note: we match Java (first-wins on ties), but PyIceberg iterates history in reverse and picks last-wins, so duplicate-timestamp queries can select different snapshots across engines. Not something to fix here — just worth acknowledging.
  • Test gaps I'd close: the builder-level rollback test steps past the equal-timestamp case (timestamp_ms + 1000), so the one path where ties actually matter isn't covered end-to-end; and there's no case where every qualifying log entry points at an evicted snapshot. The vec![1; 2048] fixture constant could use a name too.
  • New public APIs but no CHANGELOG entry.

Once the signature's locked down I'm happy to take another pass.

Comment thread crates/iceberg/src/util/snapshot.rs Outdated
///
/// Equal timestamps select the first entry. Returns [`ErrorKind::DataInvalid`]
/// if no matching history exists; does not check whether the snapshot is retained.
pub fn snapshot_id_as_of_time(metadata: &TableMetadata, timestamp_ms: i64) -> Result<i64> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd switch this to &TableMetadataRef to match ancestors_of/ancestors_between in this module. Right now a caller holding a TableMetadataRef (e.g. StaticTable::metadata()) has to deref for this one fn but not the siblings. &TableMetadata is technically sufficient since we only read an i64 out, but the inconsistency is cheap to fix now and a semver-breaking change once public-api.txt ships. While we're here, the param is metadata where the siblings use table_metadata — worth aligning too. The build() call site can stay self.table.metadata().

@dhruvarya-db dhruvarya-db Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated to table_metadata: &TableMetadataRef

Comment thread crates/iceberg/src/util/snapshot.rs Outdated
pub fn snapshot_id_as_of_time(metadata: &TableMetadata, timestamp_ms: i64) -> Result<i64> {
let mut best: Option<&SnapshotLog> = None;
for entry in metadata.history() {
if entry.timestamp_ms() <= timestamp_ms

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The tie-break is correct and matches Java's nullableSnapshotIdAsOfTime (strict > on the best timestamp, so the first entry at the max qualifying timestamp wins). My worry is it's easy to break silently: entry is captured in both the guard and the is_none_or closure, and a maintainer "simplifying" > to >= would flip ties to last-wins with no compile error. I'd make first-wins explicit with a reduce so the invariant stays local:

let best = metadata.history()
    .filter(|e| e.timestamp_ms() <= timestamp_ms)
    .reduce(|best, e| if e.timestamp_ms() > best.timestamp_ms() { e } else { best });

and keep a one-line note on why it's > (matches Java; first-appended wins).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The suggestion looks much cleaner. Updated!

Comment thread crates/iceberg/src/util/snapshot.rs Outdated
/// Resolve the snapshot ID from the latest main-history entry at or before
/// `timestamp_ms` (milliseconds since the Unix epoch).
///
/// Equal timestamps select the first entry. Returns [`ErrorKind::DataInvalid`]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since this is public now, worth spelling out that the returned id isn't guaranteed to still resolve — if the snapshot was expired after that log entry, snapshot_by_id returns None even though this returned Ok. build() handles it, but a standalone caller doing snapshot_by_id(snapshot_id_as_of_time(...)?) won't expect a None after an Ok.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense. Updated the docstring to clarify that the returned snapshot may have expired and snapshot_by_id can still return None.

Comment thread crates/iceberg/src/scan/mod.rs Outdated
}
assert_eq!(rows[0], vec![100]);
assert_eq!(rows[0], rows[1]);
assert_eq!(rows[2], vec![1; 2048]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2048 here is 2 live files × 1024 rows from the fixture — worth a named const or a short comment, otherwise this assertion fails opaquely if the fixture ever changes.

@dhruvarya-db

Copy link
Copy Markdown
Contributor Author

Thanks for the review @laskoviymishka!

Test gaps I'd close: the builder-level rollback test steps past the equal-timestamp case (timestamp_ms + 1000), so the one path where ties actually matter isn't covered end-to-end; and there's no case where every qualifying log entry points at an evicted snapshot

Addressed these test gaps and the other comments.

New public APIs but no CHANGELOG entry.

I didn't realize that we also add unreleased changes to the CHANGELOG (looks like a new process?), I have updated it with this change.

@xanderbailey

Copy link
Copy Markdown
Contributor

Just an idea, I don't love that we fail at runtime if the user configures both an as of timestamp and a snapshot id to read. Is there a world where we should have a delegate pattern and a new scan type for this that does the resolution and produces the existing struct with a pinned snapshot?

Or... and I appreciate it would be a break but rather than having snapshot or timestamp we have some sort of selector enum? This would be more idiomatic in my opinion and would make the invalid stat unrepresentable at compile time? What do you think?

@dhruvarya-db

Copy link
Copy Markdown
Contributor Author

@xanderbailey Do any of these make sense:

  1. Fail in snapshot_id() or as_of_time() itself if the other has already been called instead of failing during build()
  2. Let the second invocation simply override the first one (i.e. snapshot_id will clear the timestamp while as_of_time will clear the explicitly passed id. This one seems a bit risky.
  3. The enum approach from your suggestion:
enum SnapshotSelection {
    Current,
    SnapshotId(i64),
    AsOfTime(i64),
}
table.scan()
    .with_snapshot_selection(
        SnapshotSelection::AsOfTime(timestamp_ms)
    )
    .build()?;

A problem with this is that the user can first invoke with_snapshot_selection(SnapshotSelection::AsOfTime(timestamp_ms)) followed by with_snapshot_selection(SnapshotSelection::SnapshotId(id)) within the same build chain. So it doesn't really solve our issue.
4. The new scan type solves all of these issues but I am not sure whether it is the most ergonomic solution:

table.scan_as_of_time(timestamp_ms)
    .select(["x"])
    .build()?;
 table.scan_as_of_snapshot_id(snapshot_id)
    .select(["x"])
    .build()?;

Option 1 seems like the simplest one here but I have no strong opinion here. Option 4 seems to solve the main issues.

@xanderbailey

Copy link
Copy Markdown
Contributor

Yeah it's worth thinking about how all the options evolve as we add incremental and change log scans. To me option 4 looks like it would allow that? Or having .scan(ScanSelection) I think also works. I do appreciate either of this is a break but it does set us up for the future better than the current API. In https://github.com/apache/iceberg-rust/pull/2997/files which is a draft of incremental scans I added incremental_append_scan at the top level which is maybe correct? WDYT?

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for turning this around — the helper signature now matches its siblings (&TableMetadataRef + table_metadata), which was my one blocker last round, so that's fully resolved. The docstring caveat about a resolved id possibly being expired is in, the CHANGELOG entry landed under Added, and both gaps I flagged are now covered end-to-end: test_table_scan_as_of_time_after_rollback exercises the elapsed_ms == 0 equal-timestamp tie through the builder, and test_table_scan_as_of_time_rejects_all_qualifying_snapshots_expired covers the all-expired path.

A few things are still open from last round, none blocking. The tie-break is still leaning on the comment rather than the structure — that's the one I'd still like to see land, and there's a clean sort-key shape for it (left inline). The PyIceberg cross-engine tie divergence still isn't noted in the docstring, and vec![1; 2048] is still an unnamed literal decoupled from the fixture row count.

One new thing I noticed this pass: the i64::MIN test case diverges from Java's sentinel seed, so I'd soften the "matches exactly" framing there rather than leave it as an implied parity guarantee.

Overall it is LGTM, i'll leave it for a day or two to soak and sort this out and then - happy to merge.

Comment thread crates/iceberg/src/util/snapshot.rs Outdated
let best = table_metadata
.history()
.iter()
.filter(|entry| entry.timestamp_ms() <= timestamp_ms)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the tie-break restructure from last round — still leaning on the comment rather than the structure. A one-char >→>= here silently flips first-wins to last-wins and nothing but CI would catch it. I'd encode first-wins in the sort key so misreading the operator can't change semantics:

table_metadata
    .history()
    .iter()
    .enumerate()
    .filter(|(_, entry)| entry.timestamp_ms() <= timestamp_ms)
    .max_by_key(|(idx, entry)| (entry.timestamp_ms(), Reverse(*idx)))
    .map(|(_, entry)| entry.snapshot_id)

max_by_key on its own picks the last max on ties, so Reverse(idx) is doing the real work; the result is still an Option, so it feeds the existing .ok_or_else(...) unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Switched to max_by_key with Reverse(idx) to preserve first-entry-wins ties, keeping the Java compatibility comment directly above it

Comment thread crates/iceberg/src/util/snapshot.rs Resolved
Comment thread crates/iceberg/src/util/snapshot.rs Resolved
Comment thread crates/iceberg/src/scan/mod.rs Outdated
assert_eq!(rows[0], vec![100]);
assert_eq!(rows[0], rows[1]);
// Two live fixture files contain 1,024 rows each.
assert_eq!(rows[2], vec![1; 2048]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The 2048 is still decoupled from its source of truth — if write_parquet_data_files' vec![1; 1024] ever changes, this assertion silently goes stale. A const ROWS_PER_FIXTURE_FILE: usize = 1024; with vec![1; 2 * ROWS_PER_FIXTURE_FILE] ties them back together.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, updated to use ROWS_PER_FIXTURE_FILE

@xanderbailey

Copy link
Copy Markdown
Contributor

@laskoviymishka what do you think about my suggestions above?

@laskoviymishka

Copy link
Copy Markdown
Contributor

@xanderbailey I think you're right on the direction. If this can only fail at build(), we probably have the wrong API shape.

I'd lean toward separate entry points that resolve to a pinned snapshot and return the existing TableScan. That also lines up with incremental_append_scan in #2997.

I don't think a selector on the builder really fixes it, since it can still be called twice and overwritten. So to me it's mostly between:

  • scan_as_of_time / scan_as_of_snapshot_id
  • one scan(selection) entry point

I'd slightly prefer the separate constructors, but no strong opinion.

I also wouldn't block this PR on it. My only ask is that if we're changing the scan API, we try to do it once. This PR and #2997 touch the same surface, so it'd be good to settle them together before the next release.

@mbutrovich

Copy link
Copy Markdown
Collaborator

My only ask is that if we're changing the scan API, we try to do it once. This PR and #2997 touch the same surface, so it'd be good to settle them together before the next release.

Could we settle the builder side in this PR by keeping as_of_time on TableScanBuilder and storing the selection in one private field?

enum SnapshotSelection {
    SnapshotId(i64),
    AsOfTime(i64),
}

The builder would hold snapshot_selection: Option<SnapshotSelection>, and snapshot_id and as_of_time would each overwrite it. The builder can't represent the conflicting state, so the error in build() goes away, and the public change stays at the one new method. A branch or tag selector later becomes another variant.

@dhruvarya-db's concern with the enum was that a second call overwrites the first. This builder already works that way. select, select_all, and select_empty all write column_names (code), and this PR documents last-call-wins for repeated snapshot_id calls and for repeated as_of_time calls. Java resolves its mutually exclusive incremental start bounds the same way. fromSnapshotInclusive and fromSnapshotExclusive write the same fromSnapshotId field plus a flag, so the last call wins (TableScanContext). Java does reject a second snapshot selection on a table scan (SnapshotScan). Doing that here would mean changing snapshot_id to return a Result, which breaks every caller.

As I read Java, this shape also lines up with #2997 without new constructors. Point-in-time selection lives on the table scan (useSnapshot, useRef, asOfTime in the SnapshotScan link above), and incremental reads get their own entry point, Table.newIncrementalAppendScan(), which matches Table::incremental_append_scan in #2997. Separate scan_as_of_time and scan_as_of_snapshot_id constructors would also mean deprecating the existing TableScanBuilder::snapshot_id.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @dhruvarya-db. The test that reads real rows back through two distinct snapshots shows the time-travel path end to end. Before this merges, I'd like to settle the scan builder's shape together with #2997, since both PRs change the same API.

Comment thread crates/iceberg/src/scan/mod.rs Resolved
Comment thread crates/iceberg/src/util/snapshot.rs Resolved

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for turning the round-2 items around — the PyIceberg-divergence note is in the docstring now, ROWS_PER_FIXTURE_FILE replaced the magic vec![1; 2048], and the rollback / expired-prefix / schema-fallback tests all landed. I re-traced the selection against Java's SnapshotUtil once more and it still lines up (max timestamp, first-wins-on-ties), so the logic looks good to me.

On my end this is ready — but I'd hold the merge for @mbutrovich. He asked to settle the scan-builder shape jointly with #2997 (the ScanConfig / shared build_table_scan refactor), and whichever of the two lands first, the other's per-variant resolution in build() will want to move into that shape. I'd rather he takes it over the line once that's sorted than land ahead of it.

Left a few small non-blocking notes inline — an error that could carry the timestamp when a resolved snapshot has expired, plus the same tie-break/docstring wording nits from last round. None of them gate. Nice work.

Comment thread crates/iceberg/src/scan/mod.rs Outdated
let snapshot = match self.snapshot_id {
let snapshot_id = match self.snapshot_selection {
Some(SnapshotSelection::SnapshotId(id)) => Some(id),
Some(SnapshotSelection::AsOfTime(timestamp_ms)) => Some(snapshot_id_as_of_time(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When as_of_time resolves to a since-GC'd snapshot, build() fails with the bare Snapshot with id {X} not found from the id path — the timestamp that produced X is gone, so a caller can't tell an expired time-travel target from a stale snapshot_id. I'd wrap the not-found case in this branch to carry the timestamp, e.g. Snapshot ... resolved from timestamp {timestamp_ms} ms has expired. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, I have updated the snapshot_selection logic such that we create the error message in advance when the snapshot_id is resolved

Comment thread crates/iceberg/src/util/snapshot.rs Outdated
/// at or before `timestamp_ms` (milliseconds since the Unix epoch), taking the first
/// entry on ties.
///
/// This matches Java's ordering. PyIceberg takes the last qualifying log entry,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This still reads as a flat "matches Java's ordering" — the one spot it doesn't is i64::MIN, where Java's Long.MIN_VALUE seed plus strict > matches nothing but we return the entry. The test comment already notes this; I'd lift a sentence of it up into the docstring so API consumers see the sentinel edge. Same wording point from last round, still not gating.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Made the docstring more descriptive

Comment thread crates/iceberg/src/util/snapshot.rs Outdated
.enumerate()
.filter(|(_, entry)| entry.timestamp_ms() <= timestamp_ms)
// Keep the first entry on ties, matching Java's timestamp selection.
.max_by_key(|(idx, entry)| (entry.timestamp_ms(), Reverse(*idx)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still the one place the tie-break leans on the comment rather than the shape — Reverse(*idx) inside max_by_key is two hops to read as "first entry wins." Not gating, but if you're in here anyway, an explicit comparator says it directly:

.max_by(|(idx_a, a), (idx_b, b)| {
    a.timestamp_ms()
        .cmp(&b.timestamp_ms())
        .then(idx_b.cmp(idx_a)) // earlier index wins on ties
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated as per your suggestion

@dhruvarya-db
dhruvarya-db force-pushed the feat-timestamp-time-travel branch from f44caef to 387174e Compare October 12, 2026 18:39
durovo and others added 2 commits October 12, 2026 19:48
Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@dhruvarya-db -- can you plz fix conflicts?
Once fixed i think this is good to merge

…at-timestamp-time-travel

Resolve conflicts with apache#2904: keep both test sets and docstrings, and make
as_of_time scans use the selected snapshot's schema like snapshot_id scans.

Co-authored-by: Isaac <no-reply@databricks.com>
@dhruvarya-db

Copy link
Copy Markdown
Contributor Author

Hi @laskoviymishka , I have resolved the merge conflicts. There is one CI failure which is unrelated to this PR - I have raised this PR to resolve that issue #3496

…-travel

Co-authored-by: Isaac <no-reply@databricks.com>
@laskoviymishka

Copy link
Copy Markdown
Contributor

let's 🌬️ ⛵

@laskoviymishka
laskoviymishka added this pull request to the merge queue Oct 21, 2026
Merged via the queue into apache:main with commit b9d2855 Oct 21, 2026
24 checks passed
Perseus pushed a commit to Perseus/iceberg-rust that referenced this pull request Oct 30, 2026
Backport of apache#3424 (net diff of PR head 86e35d3
against its merge base beba39c), adapted to base 5b82ab9.

Adds TableScanBuilder::with_column_stats(names) and
FileScanTask::column_stats(). Tasks carry the data file's column sizes,
value/null/NaN counts and lower/upper bounds only for the named
primitive columns. By default no statistics are kept and behaviour is
unchanged.

Upstream-PR: apache#3424
Upstream-head: 86e35d3
Backport-notes: scan/mod.rs conflicts resolved for the older base:
 resolve_field_id keeps its Option return (no apache#3070 ambiguity check),
 TableScanBuilder keeps snapshot_id (no apache#3260 snapshot_selection), and
 apache#3070's ambiguity tests are not carried. Datum::promote_to (apache#3483) is
 inlined as the same int->long / float->double widening in task.rs.
 Tests use Type::Primitive(..) (pre-apache#3527 NestedField signature) and
 test_utils imports SchemaRef. Details in DFD_FORK.md.
Needed-by: https://github.com/Perseus/datafusion-distributed/tree/iceberg-dyn-filter/f2-fork-column-stats
Remove-when: the pinned iceberg-rust rev contains apache#3424's merge commit
Issue: apache#3422
Perseus pushed a commit to Perseus/iceberg-rust that referenced this pull request Oct 31, 2026
Backport of apache#3424 (net diff of PR head 86e35d3
against its merge base beba39c), adapted to base 5b82ab9.

Adds TableScanBuilder::with_column_stats(names) and
FileScanTask::column_stats(). Tasks carry the data file's column sizes,
value/null/NaN counts and lower/upper bounds only for the named
primitive columns. By default no statistics are kept and behaviour is
unchanged.

Upstream-PR: apache#3424
Upstream-head: 86e35d3
Backport-notes: scan/mod.rs conflicts resolved for the older base:
 resolve_field_id keeps its Option return (no apache#3070 ambiguity check),
 TableScanBuilder keeps snapshot_id (no apache#3260 snapshot_selection), and
 apache#3070's ambiguity tests are not carried. Datum::promote_to (apache#3483) is
 inlined as the same int->long / float->double widening in task.rs.
 Tests use Type::Primitive(..) (pre-apache#3527 NestedField signature) and
 test_utils imports SchemaRef. Details in DFD_FORK.md.
Needed-by: https://github.com/Perseus/datafusion-distributed/tree/iceberg-dyn-filter/f2-fork-column-stats
Remove-when: the pinned iceberg-rust rev contains apache#3424's merge commit
Issue: apache#3422
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants