Skip to content

GH-47400: [C++] Support adding/subtracting intervals to/from timestamps - #53325

Open
DeviousCardi wants to merge 4 commits into
apache:mainfrom
DeviousCardi:GH-47400-interval-arithmetic
Open

DeviousCardi wants to merge 4 commits into
apache:mainfrom
DeviousCardi:GH-47400-interval-arithmetic

Conversation

@DeviousCardi

@DeviousCardi DeviousCardi commented Oct 12, 2026 •

Copy link
Copy Markdown

Rationale for this change

There is currently no kernel for timestamp + interval, so e.g. "add one day to a zoned timestamp, keeping the wall clock time" requires a round trip through pandas (see #47400).

What changes are included in this PR?

New kernels for add, add_checked, subtract and subtract_checked:

  • timestamp(unit, tz) ± month_interval / day_time_interval / month_day_nano_interval -> timestamp(unit, tz), for all four units, with or without a timezone
  • add / add_checked also accept the interval as the first argument

Semantics follow the discussion in the issue (Pandas' DateOffset):

  • Months and days are calendar units applied to the local time of the timestamp, so 2025-03-08 09:30 EST + 1 day = 2025-03-09 09:30 EDT.
  • The sub-day part (milliseconds of a day_time_interval, nanoseconds of a month_day_nano_interval) is a fixed duration, so 2025-03-08 09:30 EST + 24h = 2025-03-09 10:30 EDT.
  • add applies months, then days, then the sub-day part. subtract applies the sub-day part first, so subtracting "1 day 24 hours" undoes adding it (the mixed-interval examples in the issue). Months are applied before days in both directions, as in DateOffset and PostgreSQL.
  • Adding months clamps the day of month (Jan 31 + 1 month = Feb 28/29).
  • If the resulting local time is ambiguous or doesn't exist (DST overlap or gap), an Invalid error is returned, with the same messages as the existing ZonedLocalizer::ConvertLocalToSys. This is also what pandas does for a tz-aware Series/DatetimeIndex + DateOffset (a scalar Timestamp silently resolves instead). The issue doesn't settle this point. If wanted, a follow-up could add an option to choose earliest/latest/shift-forward like AssumeTimezoneOptions.
  • A sub-day part that isn't a multiple of the timestamp unit (e.g. 1 ns added to timestamp[s]) returns Invalid.
  • The _checked variants return Invalid on int64 overflow; the unchecked variants wrap around like the other arithmetic kernels.

Calendar arithmetic uses 64-bit year arithmetic, so the full range of timestamp[s] is supported. The timezone lookups use the existing LocateZone/time_zone::get_info machinery from temporal_internal.h.

ArithmeticFunction::DispatchBest no longer applies implicit temporal promotion when one of the arguments is an interval. Otherwise date32 + interval would be silently cast to timestamp[s] + interval. Dates are not supported yet; I'd suggest doing them in a follow-up once we agree on what date + interval with a sub-day part should return.

Are these changes tested?

Yes:

  • C++ (scalar_temporal_test.cc): month-end clamping, leap years, negative intervals, nulls, all units and all three interval types, both argument orders. DST transitions are covered in America/New_York, Europe/London and Australia/Lord_Howe (30 min DST), including results that land just after a gap or overlap and results that fall inside one (errors), plus a fixed-offset timezone. Error tests cover sub-unit and overflow cases. The expected DST values were checked against pandas 3.0.6.
  • Python (test_compute.py): parity with pandas.DateOffset + Timedelta across timezones and units, and the same DST gap/overlap errors as pandas.
  • Range edge cases: checked and unchecked kernels at both int64 bounds, with and without fixed offsets, including calendar deltas whose length in the timestamp unit exceeds int64 while the result still fits (e.g. INT64_MIN ns + 106752 days), month deltas near the bounds, and real overflow still raising in the _checked kernels.
  • Named-timezone boundaries: equivalent numeric and named positive/negative offsets at both int64 bounds, all timestamp units, both day-interval types, checked and unchecked kernels, and true overflow one unit outside the valid range. The rebuilt C++ temporal and scalar-math targets pass (60 and 593 tests, with existing skips).
  • Paired mixed-interval subtraction/negative-addition results across DST.

Are there any user-facing changes?

Yes, new kernels for add/subtract (documented in compute.rst). Existing kernels are unchanged.

Was AI used for this PR?

In accordance to the AI generation guidelines, please disclose below whether and how AI was used in this PR.

PR code and description written by:

  • Human
  • AI

Reviewed before submission by:

  • Human
  • AI
  • Not reviewed

I used an AI coding assistant (Claude) for this PR. It drafted most of the kernel code, the C++ and Python tests, and the docs paragraph. I reviewed all of it, and the expected DST values in the tests were checked against pandas independently of the kernel. The 64-bit calendar helpers (DaysFromCivil/CivilFromDays) follow Howard Hinnant's public-domain date algorithms, which are linked in a comment.

The named-timezone boundary follow-up used OpenAI Codex (GPT-6.1) for the implementation and C++ regression tests; this follow-up was reviewed and tested by Codex.

…mestamps

Add add, add_checked, subtract and subtract_checked kernels for
timestamp +/- {month_interval, day_time_interval, month_day_nano_interval},
with and without a timezone (add also accepts the interval first).

Months and days are calendar units applied to the local time of the
timestamp, while the sub-day part of the interval is a fixed duration,
following pandas.DateOffset. add applies months, days, then the sub-day
part; subtract applies the sub-day part first. Adding months clamps the
day of month. Ambiguous or nonexistent resulting local times return an
error, and the checked variants detect overflow.

Implicit temporal promotion is no longer applied in arithmetic dispatch
when an argument is an interval, so that e.g. date32 + interval is not
silently cast to a timestamp.
@github-actions github-actions Bot added the awaiting review Awaiting review label Oct 12, 2026
@github-actions

Copy link
Copy Markdown

⚠️ GitHub issue #47400 has been automatically assigned in GitHub to PR creator.

@Reranko05

Copy link
Copy Markdown
Collaborator

@DeviousCardi Thanks for the PR, pls use the AI disclosure checklist as given in the Arrow PR template.

@github-actions

Copy link
Copy Markdown

⚠️ GitHub issue #47400 has been automatically assigned in GitHub to PR creator.

@DeviousCardi

Copy link
Copy Markdown
Author

@Reranko05 Thanks for the reminder. The PR description now includes the template's "Was AI used for this PR?" checklist and a description of the AI assistance, just above the issue link.

@github-actions

Copy link
Copy Markdown

⚠️ GitHub issue #47400 has been automatically assigned in GitHub to PR creator.

const int64_t offset = localizer.OffsetAtSys(FloorDiv(t, kUnitsPerSecond));
const int64_t local = Add(t, offset * kUnitsPerSecond, st);
int64_t day = FloorDiv(local, kUnitsPerDay);
const int64_t time_of_day = local - day * kUnitsPerDay;

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 intermediate calendar values can overflow even when the final timestamp fits.

For timestamp[ns], adding one day to INT64_MIN causes signed overflow here. Subtracting one day from INT64_MIN + 86400000000000 should return INT64_MIN, but the multiplication below reports overflow. Nonzero timezone offsets cause similar failures near the bounds.

Please avoid these intermediate overflows and add kernel regression tests for both bounds, including a nonzero timezone offset.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 46a2ad2. Calendar arithmetic now splits the timestamp into day and time before applying the timezone offset, then applies the calendar and offset deltas to the original timestamp. That avoids constructing an out-of-range local or day-to-unit intermediate when the final UTC value fits. Added checked and unchecked kernel tests at both INT64 bounds for UTC and fixed ±05:30 offsets. The rebuilt temporal test target passes.

// 2025-11-02 01:30 EST - 1 day = 2025-11-01 01:30 EDT
{"America/New_York", "2025-11-02T06:30:00", 0, 1, 0, "2025-11-01T05:30:00"},
// Subtraction undoes the mixed addition above: 24 hours, then 1 day
{"America/New_York", "2025-03-10T13:30:00", 0, 1, 24 * kHour,

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.

Could you add a paired test showing the difference between subtracting a mixed interval and adding its negative?

Starting at 2025-03-10 09:30 EDT:

  • Subtracting 1 day + 24 hours gives 2025-03-08 09:30 EST.
  • Adding -1 day -24 hours gives 2025-03-08 08:30 EST.

Please also show that subtracting one calendar day and adding -1 calendar day give the same result. This would make the component order and its effect across DST clear.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added the paired DST test in 46a2ad2. Starting at 2025-03-10 09:30 EDT, subtracting 1 calendar day plus 24 hours returns 2025-03-08 09:30 EST, while adding the negative mixed interval returns 08:30 EST. The test also confirms that subtracting one calendar day equals adding negative one day, across all timestamp units and both checked and unchecked kernels. The rebuilt temporal test target passes.

@github-actions

Copy link
Copy Markdown

⚠️ GitHub issue #47400 has been automatically assigned in GitHub to PR creator.

// Reconstructing day * kUnitsPerDay first can overflow near INT64_MIN.
const int64_t day_delta = Subtract(day, original_local_day, st);
const int64_t offset_delta = Subtract(offset, new_offset, st);
const int64_t delta = Add(Multiply(day_delta, kUnitsPerDay, st),

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 intermediate overflow is still there. Adding 106752 days to timestamp[ns](INT64_MIN) gives 763145224192, which fits, but add_checked returns overflow because this multiplication happens first.

The new tests cover the reported examples. Please also cover a calendar delta larger than int64 when the final timestamp still fits.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 66a6b80. AddCalendar no longer adds day_delta * kUnitsPerDay to the original timestamp. It now rebuilds the result from the shifted day and the UTC time of day, and for negative days it multiplies (day + 1) and adds (time_of_day - kUnitsPerDay), so day * kUnitsPerDay never has to fit on its own. The tz lookup's local-seconds computation uses the same helper. All steps still use the checked helpers, so a result that doesn't fit is still reported as overflow. INT64_MIN ns + 106752 days now returns 763145224192 in both add and add_checked.

New tests in TestTemporalTimestampAndIntervalErrors use the smallest day count longer than INT64_MAX in the unit (106752 for ns, 106751992 for us), with no tz, UTC, +05:30 and -05:30, as both day_time and month_day_nano, in checked and unchecked kernels: INT64_MIN + n days, interval + INT64_MIN, the subtract back to INT64_MIN, and the mirror cases from INT64_MAX. A month case also covers 3509 months from INT64_MIN and back from INT64_MAX in ns. A delta of 2 * 106752 days still raises overflow in add_checked/subtract_checked, and the unchecked kernels wrap. The new tests fail on 46a2ad2 and pass now. arrow-compute-scalar-temporal-test and arrow-compute-scalar-math-test both pass.

Rebuild the result from the shifted day and time of day instead of
adding days * units_per_day to the original timestamp, so that a
calendar delta longer than int64 in the timestamp unit does not
overflow when the final timestamp fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

⚠️ GitHub issue #47400 has been automatically assigned in GitHub to PR creator.

// Timezone transitions have second precision; the subsecond remainder
// does not affect which offset applies.
const int64_t local_seconds =
ComposeDays(day, time_of_day / kUnitsPerSecond, 86400, st);

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 local-seconds calculation can still overflow when the final UTC result fits, no?

For timestamp[s], add_checked(INT64_MAX - 86400, one_day) returns INT64_MAX with +05:00, but overflow with equivalent Etc/GMT-5. The reverse case fails at INT64_MIN with Etc/GMT+5.

How should we handle that?

Keep local calendar time as a day and seconds-of-day pair for timezone lookup. Near the int64-second limits, use the terminal UTC offset without materializing local seconds or overflowing the timezone library offset arithmetic. The recomposed UTC timestamp still determines checked overflow.

Add named and numeric offset regressions at both bounds across all timestamp units and day interval types, including results one unit outside the valid range.

Assisted-by: OpenAI Codex (GPT-6.1)
Signed-off-by: Aarav Jadav <aaravsjadav@gmail.com>
@github-actions

Copy link
Copy Markdown

⚠️ GitHub issue #47400 has been automatically assigned in GitHub to PR creator.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants