Skip to content

Commit 8a23470

Browse files
authored
PCBC-1030: Fix how SDK handles KV Expiry (#226)
1 parent 8761192 commit 8a23470

File tree

3 files changed

+202
-10
lines changed

3 files changed

+202
-10
lines changed

Couchbase/Collection.php

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use Couchbase\Exception\TimeoutException;
3030
use Couchbase\Exception\UnsupportedOperationException;
3131
use Couchbase\Management\CollectionQueryIndexManager;
32+
use Couchbase\Utilities\ExpiryHelper;
3233
use DateTimeInterface;
3334

3435
/**
@@ -196,11 +197,7 @@ public function getAndLock(string $id, int $lockTimeSeconds, ?GetAndLockOptions
196197
*/
197198
public function getAndTouch(string $id, $expiry, ?GetAndTouchOptions $options = null): GetResult
198199
{
199-
if ($expiry instanceof DateTimeInterface) {
200-
$expirySeconds = $expiry->getTimestamp();
201-
} else {
202-
$expirySeconds = (int)$expiry;
203-
}
200+
$expirySeconds = ExpiryHelper::parseExpiry($expiry);
204201
$function = COUCHBASE_EXTENSION_NAMESPACE . '\\documentGetAndTouch';
205202
$response = $function(
206203
$this->core,
@@ -434,11 +431,7 @@ public function unlock(string $id, string $cas, ?UnlockOptions $options = null):
434431
*/
435432
public function touch(string $id, $expiry, ?TouchOptions $options = null): MutationResult
436433
{
437-
if ($expiry instanceof DateTimeInterface) {
438-
$expirySeconds = $expiry->getTimestamp();
439-
} else {
440-
$expirySeconds = (int)$expiry;
441-
}
434+
$expirySeconds = ExpiryHelper::parseExpiry($expiry);
442435
$function = COUCHBASE_EXTENSION_NAMESPACE . '\\documentTouch';
443436
$response = $function(
444437
$this->core,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
namespace Couchbase\Utilities;
4+
5+
use Couchbase\Exception\InvalidArgumentException;
6+
use DateTimeImmutable;
7+
use DateTimeInterface;
8+
9+
class ExpiryHelper
10+
{
11+
private const THIRTY_DAYS_IN_SECONDS = 2592000;
12+
private const FIFTY_YEARS_IN_SECONDS = 1576800000;
13+
private const MAX_EXPIRY = 4294967295;
14+
15+
/**
16+
* @throws InvalidArgumentException
17+
*/
18+
public static function parseExpiry($expiry): int
19+
{
20+
if ($expiry === null || $expiry === 0 || $expiry === '0') {
21+
return 0;
22+
}
23+
if (!is_int($expiry) && !($expiry instanceof DateTimeInterface)) {
24+
throw new InvalidArgumentException(
25+
"Expected expiry to be an int or DateTimeInterface."
26+
);
27+
}
28+
29+
if ($expiry instanceof DateTimeInterface) {
30+
$timestamp = $expiry->getTimestamp();
31+
32+
if ($timestamp === self::zeroSecondDate()->getTimestamp()) {
33+
return 0;
34+
}
35+
36+
if (
37+
$timestamp < self::minExpiryDate()->getTimestamp() ||
38+
$timestamp > self::maxExpiryDate()->getTimestamp()
39+
) {
40+
throw new InvalidArgumentException(
41+
"Expiry date is out of range. Must be between " .
42+
self::minExpiryDate()->format(DateTimeInterface::ATOM) . " and " .
43+
self::maxExpiryDate()->format(DateTimeInterface::ATOM) . " But got " .
44+
$expiry->format(DateTimeInterface::ATOM)
45+
);
46+
}
47+
return $timestamp;
48+
}
49+
50+
if ($expiry < 0) {
51+
throw new InvalidArgumentException("Expiry cannot be negative, got $expiry");
52+
}
53+
54+
if ($expiry > self::MAX_EXPIRY) {
55+
throw new InvalidArgumentException("Expiry cannot be greater than " . self::MAX_EXPIRY . ", got $expiry");
56+
}
57+
58+
if ($expiry > self::FIFTY_YEARS_IN_SECONDS) {
59+
trigger_error(sprintf(
60+
"The specified expiry (%d) is greater than 50 years in seconds. "
61+
. "Unix timestamps passed directly as a number are not supported. "
62+
. "If you want an absolute expiry, construct a DateTime from the timestamp.",
63+
$expiry
64+
), E_USER_WARNING);
65+
}
66+
67+
if ($expiry < self::THIRTY_DAYS_IN_SECONDS) {
68+
return $expiry;
69+
}
70+
71+
// Relative expiry >= 30 days, convert to absolute expiry
72+
$unixTimeSecs = time();
73+
$maxExpiryDuration = self::MAX_EXPIRY - $unixTimeSecs;
74+
if ($expiry > $maxExpiryDuration) {
75+
throw new InvalidArgumentException(
76+
"Expected expiry duration to be less than " . $maxExpiryDuration .
77+
" but got $expiry"
78+
);
79+
}
80+
return $expiry + $unixTimeSecs;
81+
}
82+
83+
// The server treats values <= 259200 (30 days) as relative to the current time.
84+
// So, the minimum expiry date is 259201 which corresponds to 1970-01-31T00:00:01Z
85+
private static function minExpiryDate(): DateTimeImmutable
86+
{
87+
return new DateTimeImmutable('1970-01-31T00:00:01Z');
88+
}
89+
90+
private static function maxExpiryDate(): DateTimeImmutable
91+
{
92+
return new DateTimeImmutable('2106-02-07T06:28:15Z');
93+
}
94+
95+
private static function zeroSecondDate(): DateTimeImmutable
96+
{
97+
return new DateTimeImmutable('1970-01-31T00:00:00Z');
98+
}
99+
}

tests/ExpiryTest.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2014-Present Couchbase, Inc.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
declare(strict_types=1);
20+
21+
use Couchbase\Utilities\ExpiryHelper;
22+
use Couchbase\Exception\InvalidArgumentException;
23+
24+
include_once __DIR__ . "/Helpers/CouchbaseTestCase.php";
25+
26+
class ExpiryTest extends Helpers\CouchbaseTestCase
27+
{
28+
public function testNullExpiryReturnsZero()
29+
{
30+
$this->assertEquals(0, ExpiryHelper::parseExpiry(null));
31+
}
32+
33+
public function testZeroExpiryReturnsZero()
34+
{
35+
$this->assertEquals(0, ExpiryHelper::parseExpiry(0));
36+
$this->assertEquals(0, ExpiryHelper::parseExpiry('0'));
37+
}
38+
39+
public function testNegativeExpiryThrows()
40+
{
41+
$this->expectException(InvalidArgumentException::class);
42+
ExpiryHelper::parseExpiry(-1);
43+
}
44+
45+
public function testExpiryGreaterThanMaxThrows()
46+
{
47+
$this->expectException(InvalidArgumentException::class);
48+
ExpiryHelper::parseExpiry(4294967296); // MAX_EXPIRY + 1
49+
}
50+
51+
public function testRelativeExpiryUnderThirtyDays()
52+
{
53+
$expiry = 60; // 1 minute
54+
$result = ExpiryHelper::parseExpiry($expiry);
55+
$this->assertEquals($expiry, $result);
56+
}
57+
58+
public function testRelativeExpiryOverThirtyDaysIsConverted()
59+
{
60+
$expiry = 2592000 + 1; // 30 days + 1 second
61+
$before = time();
62+
$result = ExpiryHelper::parseExpiry($expiry);
63+
$after = time();
64+
$this->assertGreaterThanOrEqual($before + $expiry, $result);
65+
$this->assertLessThanOrEqual($after + $expiry, $result);
66+
}
67+
68+
public function testAbsoluteDateWithinRangeReturnsTimestamp()
69+
{
70+
$dt = new DateTimeImmutable('2025-01-01T00:00:00Z');
71+
$result = ExpiryHelper::parseExpiry($dt);
72+
$this->assertEquals($dt->getTimestamp(), $result);
73+
}
74+
75+
public function testAbsoluteDateBelowMinThrows()
76+
{
77+
$dt = new DateTimeImmutable('1969-12-31T23:59:59Z');
78+
$this->expectException(InvalidArgumentException::class);
79+
ExpiryHelper::parseExpiry($dt);
80+
}
81+
82+
public function testAbsoluteDateAboveMaxThrows()
83+
{
84+
$dt = new DateTimeImmutable('2200-01-01T00:00:00Z');
85+
$this->expectException(InvalidArgumentException::class);
86+
ExpiryHelper::parseExpiry($dt);
87+
}
88+
89+
public function testZeroSecondDateReturnsZero()
90+
{
91+
$dt = new DateTimeImmutable('1970-01-31T00:00:00Z');
92+
$this->assertEquals(0, ExpiryHelper::parseExpiry($dt));
93+
}
94+
95+
public function testInvalidTypeThrows()
96+
{
97+
$this->expectException(InvalidArgumentException::class);
98+
ExpiryHelper::parseExpiry('foo');
99+
}
100+
}

0 commit comments

Comments
 (0)