Skip to content
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

delay(Duration) improperly coerces nanoseconds and microseconds #3920

Closed
kevincianfarini opened this issue Oct 21, 2023 · 5 comments · Fixed by #3921
Closed

delay(Duration) improperly coerces nanoseconds and microseconds #3920

kevincianfarini opened this issue Oct 21, 2023 · 5 comments · Fixed by #3921
Labels

Comments

@kevincianfarini
Copy link
Contributor

Describe the bug

I am currently unsure if this is desired behavior, so if it is, please let me know.

When invoking a delay with a Duration, the value of that duration is coerced to milliseconds and then delegated to delay(Long). It is not documented behavior that delaying with a duration loses granularity for nanoseconds or microseconds. For example, delaying with 998.75909ms gets coerced to a call of 998ms when delegating to delay(Long).

The offending code is the following:

internal fun Duration.toDelayMillis(): Long =
    if (this > Duration.ZERO) inWholeMilliseconds.coerceAtLeast(1) else 0

What happened? What should have happened instead?

The symptom of this issue is that when working on implementing a cron-like API for delay based job scheduling, I was having jobs invoked up to 600us before they should have been invoked. The end result was that jobs scheduled to occur at the beginning of a second in a certain time zone would invoke early once and then as expected afterwards in quick succession.

The above function Duration.toDelaymillis should likely coerce durations which have a nanosecond component up duration.inWholeMilliseconds + 1.

Locally this implementation is working well for me.

internal fun Duration.myToMillis(): Long = if (this > Duration.ZERO) {
    val millis = inWholeMilliseconds
    if (millis * 1_000_000 < inWholeNanoseconds) millis + 1 else millis
} else 0

Provide a Reproducer

    @Test fun foo() = runTest(timeout = 1.days) {
        withContext(Dispatchers.Default) {
            while (true) {
                val expectedDuration = 998.milliseconds + 902752.nanoseconds
                val actualDuration = measureTime { delay(expectedDuration) }
                val difference = actualDuration - expectedDuration
                assertTrue(difference.isPositive()) // fails
            }
        }
    }
@kevincianfarini
Copy link
Contributor Author

I am happy to raise a PR if this is indeed a bug!

@dkhalanskyjb
Copy link
Collaborator

The proposal to round sub-millisecond components up sounds completely reasonable.

kevincianfarini added a commit to kevincianfarini/kotlinx.coroutines that referenced this issue Oct 23, 2023
Prior to this commit Durations used in for delays or timeouts
lost their nanosecond granularity when being converted to a
millisecond Long value. This effectively meant that delays could
resume prior to when they were scheduled to do so.

This commit solves this by rounding a Duration with nanosecond
components up to the next largest millisecond.

Closes Kotlin#3920
@kevincianfarini
Copy link
Contributor Author

When merged, #3921 should solve this bug, but it's worth considering that the delegation of delay(Duration) to delay(Long) is backwards. The Worker API supports scheduling execution to the microsecond granularity, for example.

I don't know if having nanosecond or microsecond granularity has a real use case, so maybe not worth the effort.

kevincianfarini added a commit to kevincianfarini/kotlinx.coroutines that referenced this issue Oct 27, 2023
Prior to this commit Durations used in for delays or timeouts
lost their nanosecond granularity when being converted to a
millisecond Long value. This effectively meant that delays could
resume prior to when they were scheduled to do so.

This commit solves this by rounding a Duration with nanosecond
components up to the next largest millisecond.

Closes Kotlin#3920
@SPC-code
Copy link

SPC-code commented Dec 1, 2023

Microseconds granularity has a use-case in device operation scheduling. It is not frequently used, but I see some cases, where it could appear.

@dkhalanskyjb
Copy link
Collaborator

@SPC-code, that's irrelevant to this PR, as the precision of delay was always only with millisecond granularity. The only thing that's changed is that with this PR, we guarantee that the sleep will last for at least this long.

Please file an issue if you have a use case for sub-millisecond scheduling precision.

kevincianfarini added a commit to kevincianfarini/cardiologist that referenced this issue Dec 11, 2023
This resolves a bug in kotlinx-coroutines that this
project exposed. See Kotlin/kotlinx.coroutines#3920
for more details.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants