Skip to content

Fix float precision loss in datetime_to_microseconds - #1310

Open
Nimra3261 wants to merge 2 commits into
qdrant:devfrom
Nimra3261:fix-datetime-to-microseconds-precision
Open

Fix float precision loss in datetime_to_microseconds#1310
Nimra3261 wants to merge 2 commits into
qdrant:devfrom
Nimra3261:fix-datetime-to-microseconds-precision

Conversation

@Nimra3261

Copy link
Copy Markdown

Bug

datetime_to_microseconds computes int(dt.timestamp() * 1_000_000). timestamp() returns a float64, and multiplying it by 1e6 before truncating amplifies the rounding error enough to be off by one microsecond for a large fraction of timestamps:

>>> from datetime import datetime, timezone
>>> dt = datetime(1970, 7, 21, 14, 9, 16, 146413, tzinfo=timezone.utc)
>>> int(dt.timestamp() * 1_000_000)
17417356146412   # should be 17417356146413

In random testing across a range of years, ~35% of timestamps were off by one microsecond. Dates far enough from 1970 (e.g. year 9999) are off by a full second, because the whole-seconds part alone exceeds what float64 can represent precisely alongside the fractional part.

Why it matters

This value feeds ORDER BY comparisons in local mode (Filter range/datetime conditions, scroll ordering). An off-by-one microsecond can flip a boundary comparison, causing local mode to disagree with what the real Qdrant server returns for the same query - a local/remote congruence bug.

Fix

Route through datetime subtraction instead of timestamp(): subtracting two aware datetimes is exact integer arithmetic internally, no floats involved. A naive input is first attached to the system's local timezone via astimezone() - the same "assume local time" behavior timestamp() has for naive input - which itself returns a datetime, not a lossy float, so precision holds all the way through.

Testing

Added tests/test_order_by.py, cross-checking against an independent exact-arithmetic reference implementation across a wide range of years/microseconds, including the year-9999 case. Verified the tests fail against the original implementation and against a first fix attempt that only replaced the multiplication but still used timestamp() for the whole-seconds part (that one still got year 9999 wrong by a full second).

Opened against dev since that's where the related recent local-mode fixes (#1259 etc.) landed.

int(dt.timestamp() * 1_000_000) truncates a float64 that has already
accumulated rounding error from the multiplication - off by one
microsecond for a large fraction of timestamps (~35% in random testing),
and off by a full second for dates far enough from 1970 (e.g. year 9999)
that the whole-seconds part alone exceeds what float64 can represent
alongside the fractional part.

This value feeds ORDER BY comparisons in local mode (Filter conditions,
scroll ordering), so an off-by-one microsecond can flip a boundary
comparison and disagree with what the real Qdrant server would return
for the same query - a local/remote congruence bug.

Fixed by routing through datetime subtraction instead of timestamp():
subtracting two aware datetimes is exact integer arithmetic internally,
no floats involved. A naive input is first attached to the system's
local timezone via astimezone() - the same "assume local time" behavior
timestamp() has for naive input - which itself returns a datetime, not
a lossy float, so precision holds all the way through.

Added tests/test_order_by.py: cross-checks against an independent
exact-arithmetic reference implementation, including the year-9999
case that the first fix attempt (only replacing the multiplication,
still using timestamp() for the whole-seconds part) still got wrong.
Verified failing against the original implementation before the fix.
@netlify

netlify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploy Preview for poetic-froyo-8baba7 ready!

Name Link
🔨 Latest commit ee7657d
🔍 Latest deploy log https://app.netlify.com/projects/poetic-froyo-8baba7/deploys/6a86d20acb2a8a0008fdc151
😎 Deploy Preview https://deploy-preview-1310--poetic-froyo-8baba7.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2ffd56da-e3b8-48c1-bcfe-d159a8ef5846

📥 Commits

Reviewing files that changed from the base of the PR and between 3518fc1 and ee7657d.

📒 Files selected for processing (2)
  • qdrant_client/local/order_by.py
  • tests/test_order_by.py

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

datetime_to_microseconds now detects naive datetimes through utcoffset() is None, including attached timezone objects without an offset. Documentation describes the bounded floating-point precision error. Tests add exact integer-arithmetic references and cover known rounding cases, sampled 2100 timestamps, and offsetless timezone objects.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to ee765

This change replaces lossy timestamp conversion with exact datetime arithmetic for microsecond ordering; no actionable merge-blocking risk remains beyond normal checks and review.

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary change: fixing float precision loss in datetime_to_microseconds.
Description check ✅ Passed The description explains the precision bug, its impact, the exact-arithmetic fix, and the regression tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/test_order_by.py (1)

6-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the test oracle independent of datetime_to_microseconds.

_exact_microseconds_since_epoch uses the same epoch-subtraction and delta-component expression as qdrant_client/local/order_by.py. Use a second independent integer implementation for the expected value instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_order_by.py` around lines 6 - 11, Update the test helper
_exact_microseconds_since_epoch to calculate expected microseconds using an
independent integer-based formulation rather than the epoch-subtraction and
timedelta component expression shared with datetime_to_microseconds. Keep the
helper exact and preserve its existing return contract for all tested datetimes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@qdrant_client/local/order_by.py`:
- Around line 23-25: Update datetime_to_microseconds so it treats a datetime as
naive when either dt.tzinfo is None or dt.utcoffset() is None, normalizing both
cases with astimezone() before subtracting _EPOCH. Add a regression test
covering a tzinfo implementation whose utcoffset() returns None.

---

Nitpick comments:
In `@tests/test_order_by.py`:
- Around line 6-11: Update the test helper _exact_microseconds_since_epoch to
calculate expected microseconds using an independent integer-based formulation
rather than the epoch-subtraction and timedelta component expression shared with
datetime_to_microseconds. Keep the helper exact and preserve its existing return
contract for all tested datetimes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c0b484fa-8db1-4ffe-8105-ac8fc3ae7b5c

📥 Commits

Reviewing files that changed from the base of the PR and between 399449a and 3518fc1.

📒 Files selected for processing (2)
  • qdrant_client/local/order_by.py
  • tests/test_order_by.py

Comment thread qdrant_client/local/order_by.py Outdated

@shashvat-singham shashvat-singham left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ran the branch. The precision fix itself checks out — cross-checking both implementations against exact integer arithmetic over 20k random timestamps in the 0–2e15µs range, the old int(dt.timestamp() * MICROS_PER_SECOND) is off by a microsecond on ~1.3% of them, and the new version matches exactly everywhere I tried, including pre-1970 dates where timedelta normalisation makes days negative and seconds/microseconds positive.

Two things:

1. The comment overstates the far-future case. It says the old code is "off by a full second for dates far enough from 1970 (e.g. year 9999)". For datetime(9999, 12, 31, 23, 59, 59, 999999, tzinfo=utc) the old code gives 253402300800000000 vs 253402300799999999 exact — off by 1µs, not 1s. float64 has 52 bits of mantissa and that value needs ~58 bits, so the ULP there is ~32µs, nowhere near a second. Worth toning down so the comment doesn't send the next reader hunting for a second-scale bug that isn't there.

2. Preserving timestamp()'s naive-datetime behaviour keeps a machine-dependent inconsistency. The comment justifies dt.astimezone() as matching the old "assume local time" behaviour for naive input, which it does. But that behaviour disagrees with how a naive datetime string is handled two functions over — datetime_utils.parse explicitly assumes UTC when there's no offset, matching qdrant core. So on this machine (IST, UTC+5:30):

to_order_value("2024-06-15 12:30:45")          # 1718454645000000  (UTC)
to_order_value(datetime(2024, 6, 15, 12, 30, 45))  # 1718434845000000  (local)

Same wall-clock time, 19800000000µs apart, and the gap changes with the machine's timezone — so order_by results depend on where the client runs. The aware-UTC object agrees with the string, so it's specifically the naive-object path that diverges.

That's pre-existing and arguably out of scope, but this PR is rewriting exactly this function and pinning the behaviour in a comment as intentional, so it seems like the moment to decide. Making it dt.replace(tzinfo=timezone.utc) would line the object path up with the string path and with core. Happy to file it separately if you'd rather keep this PR purely about precision.

- The year-9999 error is up to ~32us (float64 ULP at that magnitude),
  not a full second as the comment claimed - verified empirically and
  corrected per @shashvat-singham's review.
- Check dt.utcoffset() is None instead of dt.tzinfo is None to detect
  naive datetimes: a tzinfo subclass can be attached while still
  reporting no offset, which the old check would misclassify as aware
  and crash on `dt - _EPOCH` (TypeError: can't subtract offset-naive
  and offset-aware datetimes). Per @coderabbitai's review.
- Added a regression test for the tzinfo-subclass case, confirmed it
  fails against the old check and passes with the fix.

The naive-datetime-as-local-vs-UTC inconsistency raised in the same
review is intentionally left out of this PR - qdrant#1352 already fixes it
independently to avoid the two PRs conflicting.
@Nimra3261

Copy link
Copy Markdown
Author

Thanks both — pushed an update addressing this.

@shashvat-singham:

  1. Fixed the comment — verified empirically (both the exact year-9999 case and a 5k-sample sweep across years 9000–9999): the actual error is up to ~32µs, matching your float64-mantissa math, never a full second. Corrected the wording.
  2. Agreed on scope — since fix: treat a naive datetime as UTC in order_by #1352 already fixes the naive-as-local-vs-UTC inconsistency independently (and deliberately avoids touching datetime_to_microseconds to not conflict with this PR), I've left that out here. This PR stays focused on the precision fix.

@coderabbitai: fixed — now checking dt.utcoffset() is None instead of dt.tzinfo is None. Confirmed the old check actually crashes (TypeError: can't subtract offset-naive and offset-aware datetimes) on a tzinfo subclass that reports no offset, and added a regression test for it that fails on the old code and passes on the new.

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline qdrant_client/local/order_by.py --items all --match 'datetime_to_microseconds'
ast-grep outline tests/test_order_by.py --items all --match 'datetime_to_microseconds|tzinfo|offset'

printf '\n--- implementation references ---\n'
rg -n -C 10 'def datetime_to_microseconds\b|utcoffset\(\)|tzinfo' qdrant_client/local/order_by.py

printf '\n--- regression test references ---\n'
rg -n -C 12 'datetime_to_microseconds\b|utcoffset\(\)|tzinfo|offset-naive' tests/test_order_by.py

Length of output: 6581


@Nimra3261 Confirmed. datetime_to_microseconds now uses dt.utcoffset() is None before it subtracts _EPOCH.

The regression test uses an attached tzinfo instance that returns None from utcoffset(). It verifies equivalence with an actually naive datetime. This covers the previous offset-naive versus offset-aware subtraction failure.

🐇 ✅


🧠 Learnings used
Learnt from: shashvat-singham
Repo: qdrant/qdrant-client PR: 1352
File: tests/test_order_by_tz.py:30-40
Timestamp: 2026-08-18T14:00:04.922Z
Learning: In `tests/test_order_by_tz.py`, `test_naive_datetime_is_normalized_to_utc` is the timezone-independent regression guard for `qdrant_client.local.order_by.to_order_value`. It spies on `datetime_to_microseconds` and asserts that a naive `datetime` reaches it with `tzinfo is timezone.utc`. The value-equality test documents equivalence among naive strings, naive `datetime` values, and UTC-aware `datetime` values; it is not intended to detect host-local-time behavior.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

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.

2 participants