Skip to content

gh-102402: Fix floating point math issue by using time.time_ns() in logging.LogRecord #102412

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Apr 16, 2024
Merged

gh-102402: Fix floating point math issue by using time.time_ns() in logging.LogRecord #102412

merged 24 commits into from
Apr 16, 2024

Conversation

dougthor42
Copy link
Contributor

@dougthor42 dougthor42 commented Mar 4, 2023

gh-102402: Fix floating point math issue by using time.time_ns() in logging.LogRecord.

  • Adjust logging.LogRecord to use time.time_ns() instead of time.time().
  • Change method of calculating LogRecord.msecs to be robust against floating point / binary representation arithmetic errors
  • Added tests for the changes.

Notes / Open Questions:

  1. LogRecord.msecs is a float so I kept it that way. Why is it a float even though it always has .0?
  2. In order to minimize impact, I kept LogRecord.created as a float in seconds.
  3. I didn't see any type annotations in logging.__init__ so I didn't add any for the new code. Would you like me to add some annotations?
  4. I opted to use string manipulation to grab the msecs. I have an integer math-based approach, but it's, IMO, more verbose and harder to read/understand. LMK if you want me to switch back to that (there are still some minor fixes to apply to it).
    • I was able to come up with a math approach that is easy enough to understand.
  5. I wasn't sure of the best place to put the tests, so I just built off the other msecs-related bug.

Fixes #102402.

@ghost
Copy link

ghost commented Mar 4, 2023

All commit authors signed the Contributor License Agreement.
CLA signed

Comment on lines 4328 to 4329
finally:
time.time_ns = og_time_ns
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Question: Is there a more preferred way to accomplish this other than monkeypatching time.time_ns?

Copy link
Member

Choose a reason for hiding this comment

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

You can use unittest.mock.patch as in this example:

import time
from unittest.mock import patch

for t in (123, 456, 789):
    with patch('time.time_ns') as patched_ns:
        patched_ns.return_value = t
        value = time.time_ns()
        print(f'patched: {value}')
    print(f'OG: {time.time_ns()}')

which should, when run, print something like this:

patched: 123
OG: 1681278183002693921
patched: 456
OG: 1681278183003199141
patched: 789
OG: 1681278183003610801

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, my assumption was that we probably didn't want to add imports (I don't know why I assumed that...).

Updated to patch with mock instead of manual patching.

@arhadthedev arhadthedev added the stdlib Python modules in the Lib dir label Mar 4, 2023
@dougthor42
Copy link
Contributor Author

I was able to come up with a math approach that is pretty easy to understand. It's also faster.

String manipulation:

$ python -m timeit -s "import time; ct=time.time_ns()" "float(str(ct)[-9:][:3])"
2000000 loops, best of 5: 162 nsec per loop

Math:

$ python -m timeit -s "import time; ct=time.time_ns()" "ct_in_msecs = ct // 1_000_000; ct_in_secs = (ct_in_msecs // 1_000) * 1_000; msecs = (ct_in_msecs - ct_in_secs) + 0.0"
5000000 loops, best of 5: 74.3 nsec per loop

This version is a little faster:

$ python -m timeit -s "import time; ct=time.time_ns()" "msecs = (ct - ((ct // 1_000_000_000) * 1_000_000_000)) // 1_000_000 + 0.0"
5000000 loops, best of 5: 59.1 nsec per loop

@arhadthedev
Copy link
Member

@vsajip (as a logging module expert)

Copy link
Member

@vsajip vsajip left a comment

Choose a reason for hiding this comment

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

Thanks for the PR - minor changes requested.

Comment on lines 4328 to 4329
finally:
time.time_ns = og_time_ns
Copy link
Member

Choose a reason for hiding this comment

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

You can use unittest.mock.patch as in this example:

import time
from unittest.mock import patch

for t in (123, 456, 789):
    with patch('time.time_ns') as patched_ns:
        patched_ns.return_value = t
        value = time.time_ns()
        print(f'patched: {value}')
    print(f'OG: {time.time_ns()}')

which should, when run, print something like this:

patched: 123
OG: 1681278183002693921
patched: 456
OG: 1681278183003199141
patched: 789
OG: 1681278183003610801

@bedevere-bot
Copy link

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

Copy link
Contributor Author

@dougthor42 dougthor42 left a comment

Choose a reason for hiding this comment

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

I have made the requested changes; please review again

Comment on lines 4328 to 4329
finally:
time.time_ns = og_time_ns
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, my assumption was that we probably didn't want to add imports (I don't know why I assumed that...).

Updated to patch with mock instead of manual patching.

Copy link
Contributor Author

@dougthor42 dougthor42 left a comment

Choose a reason for hiding this comment

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

Thanks @serhiy-storchaka! I've addressed most items but would like more information on the request for additional testing.

Copy link
Member

@serhiy-storchaka serhiy-storchaka left a comment

Choose a reason for hiding this comment

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

LGTM. I left only one minor suggestion, which can be ignored if you have other reasons.

Thank you for your contribution, @dougthor42. For a first time contributor, this PR has surprisingly high quality, you did not even make common errors. And I did not expect such good responsibility after months of inactivity. I will be glad to see your other PRs.

Comment on lines 4336 to 4341
with patch("time.time_ns") as patched_ns:
orig_modules = import_helper._save_and_remove_modules(['logging'])
try:
# mock for module import
patched_ns.return_value = ns
import logging
Copy link
Member

Choose a reason for hiding this comment

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

I think that it can be out of the loop. You only need the following code in the loop:

new_ns = ns + offset_ns
patched_ns.return_value = new_ns
record = logging.makeLogRecord({'msg': 'test'})
self.assertAlmostEqual(record.created, new_ns / 1e9, places=6)
self.assertAlmostEqual(record.relativeCreated, offset_ns / 1e6, places=7)

It is just an optimization.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. It may be "just an optimization" but I wouldn't consider it a premature optimization. Updated.

Copy link
Contributor Author

@dougthor42 dougthor42 left a comment

Choose a reason for hiding this comment

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

For a first time contributor, this PR has surprisingly high quality, you did not even make common errors. And I did not expect such good responsibility after months of inactivity. I will be glad to see your other PRs.

Thanks! I do hope to find more PRs that I can help with (and have the time to help with...)

Comment on lines 4336 to 4341
with patch("time.time_ns") as patched_ns:
orig_modules = import_helper._save_and_remove_modules(['logging'])
try:
# mock for module import
patched_ns.return_value = ns
import logging
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. It may be "just an optimization" but I wouldn't consider it a premature optimization. Updated.

@vsajip
Copy link
Member

vsajip commented Apr 15, 2024

Closing and reopening, as there seems to have been a spurious error.

@vsajip vsajip closed this Apr 15, 2024
@vsajip vsajip reopened this Apr 15, 2024
@vsajip vsajip merged commit 1316692 into python:main Apr 16, 2024
@vsajip
Copy link
Member

vsajip commented Apr 16, 2024

Thank you @dougthor42 for your patience - sorry it dropped off my radar for so long.

diegorusso pushed a commit to diegorusso/cpython that referenced this pull request Apr 17, 2024
@vstinner
Copy link
Member

This change introduced reference leaks:

$ ./python -m test -R 3:3 test_logging -m test_relativeCreated_has_higher_precision
(...)
test_logging leaked [2535, 2531, 2535] references, sum=7601
test_logging leaked [1600, 1598, 1600] memory blocks, sum=4798

@vstinner
Copy link
Member

I suggest to not reimport the logging module in the test:

    def test_relativeCreated_has_higher_precision(self):
        # See issue gh-102402
        ns = 1_677_903_920_000_998_503  # approx. 2023-03-04 04:25:20 UTC
        offsets_ns = (200, 500, 12_354, 99_999, 1_677_903_456_999_123_456)

        with (patch("time.time_ns") as time_ns_mock,
              support.swap_attr(logging, '_startTime', ns)):
            for offset_ns in offsets_ns:
                # mock for log record creation
                new_ns = ns + offset_ns
                time_ns_mock.return_value = new_ns

                record = logging.makeLogRecord({'msg': 'test'})
                self.assertAlmostEqual(record.created, new_ns / 1e9, places=6)

                # After PR gh-102412, precision (places) increases from 3 to 7
                self.assertAlmostEqual(record.relativeCreated, offset_ns / 1e6, places=7)

@vstinner
Copy link
Member

vstinner commented Apr 17, 2024

Change method of calculating LogRecord.msecs to be robust against floating point / b

I'm happy that my PEP 564 – Add new time functions with nanosecond resolution is used to fix very concrete time rounding issues ;-)

@serhiy-storchaka
Copy link
Member

Could you please open a new issue for the leak? I tried to find the cause, and still have not found it. It is a puzzle.

@vstinner
Copy link
Member

Could you please open a new issue for the leak? I tried to find the cause, and still have not found it. It is a puzzle.

I proposed a fix: #117985

@dougthor42
Copy link
Contributor Author

Oh no! Sorry that my change caused more work for people 😞.

TIL about -R / --huntrleaks - I'll keep it mind for next time!

@vstinner
Copy link
Member

Buildbots were broken for 1 day. I merged my fix since no better fix was proposed: 8f25cc9

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
stdlib Python modules in the Lib dir
Projects
Development

Successfully merging this pull request may close these issues.

Logging's msecs doesn't handle "100ms" well.
6 participants