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

feat(hset_family): Add KEEPTTL support to HSetEx #4730

Merged
merged 8 commits into from
Mar 12, 2025

Conversation

abhijat
Copy link
Contributor

@abhijat abhijat commented Mar 7, 2025

The KEEPTTL option is added to HSetEx command.

This option if specified makes sure that TTL is preserved for existing members. It coexists along with the NX option and should be specified at the start of the command:

HSETEX key [NX] [KEEPTTL] seconds field value [field value ...]

fixes #4701

@abhijat abhijat force-pushed the abhijat/feat/add_keepttl_to_hsetex branch from fdf29dc to 24067a5 Compare March 7, 2025 14:40
@abhijat abhijat force-pushed the abhijat/feat/add_keepttl_to_hsetex branch 6 times, most recently from 6faf029 to 06c60e6 Compare March 9, 2025 14:03

Verified

This commit was signed with the committer’s verified signature.
dsanders11 David Sanders
An optional DensePtr is passed to DenseSet::AddOrReplaceObj

If set, this is used to avoid a search for existing object.

A protected helper method is also added to DenseSet which returns
the DensePtr and the associated raw pointer to caller. This is intended
for use in the string map, where the DensePtr will be passed to
AddOrReplaceObj, and the raw ptr will be used to fetch object expiry
time.

Signed-off-by: Abhijat Malviya <abhijat@dragonflydb.io>
@abhijat abhijat force-pushed the abhijat/feat/add_keepttl_to_hsetex branch from 06c60e6 to 3299f44 Compare March 9, 2025 14:05

Verified

This commit was signed with the committer’s verified signature.
dsanders11 David Sanders
When calling AddOrUpdate in string map, if keepttl flag is passed, a
search is performed for the field first. If the field is found, its ttl
is used for future operations. Also the found object is passed to dense
set DenseSet::AddOrReplaceObj to avoid extra searches.

Signed-off-by: Abhijat Malviya <abhijat@dragonflydb.io>
@abhijat abhijat force-pushed the abhijat/feat/add_keepttl_to_hsetex branch from 3299f44 to ef503f2 Compare March 9, 2025 14:27
abhijat added 2 commits March 9, 2025 20:10

Verified

This commit was signed with the committer’s verified signature.
dsanders11 David Sanders
The KEEPTTL option is parsed along with NX and passed to the string map
API. This option if specified makes sure that TTL is preserved for
existing members.

Partially verified

This commit is signed with the committer’s verified signature.
lukekarrys’s contribution has been verified via SSH key.
We cannot verify signatures from co-authors, and some of the co-authors attributed to this commit require their commits to be signed.
Signed-off-by: Abhijat Malviya <abhijat@dragonflydb.io>
@abhijat abhijat force-pushed the abhijat/feat/add_keepttl_to_hsetex branch from ef503f2 to 328c96b Compare March 9, 2025 14:42
@abhijat abhijat changed the title [wip] feat(hset_family): Add KEEPTTL support to HSetEx feat(hset_family): Add KEEPTTL support to HSetEx Mar 10, 2025
Comment on lines 79 to 101
bool keepttl) {
void* dptr = nullptr;
void* raw = nullptr;
// If keepttl was specified, do a search. If the object is found pass it to AddOrReplaceObj
// to avoid another search. We might end up doing two searches if keepttl is specified and
// field is not present already.
if (keepttl) {
uint64_t hashcode = Hash(&field, 1);
// dense ptr for AddOrReplaceObj and sds for getting expire time.
std::tie(dptr, raw) = FindObjPtr(&field, hashcode, 1);

if (raw) {
if (const auto [val, val_ptr] = LoadValPtr(raw); val & kValTtlBit) {
ttl_sec = GetExpiry(val_ptr);
}
}
}

// 8 additional bytes for a pointer to value.
auto [newkey, sdsval_tag] = CreateEntry(field, value, time_now(), ttl_sec);

// Replace the whole entry.
sds prev_entry = (sds)AddOrReplaceObj(newkey, sdsval_tag & kValTtlBit);
if (prev_entry) {
if (sds prev_entry = (sds)AddOrReplaceObj(newkey, sdsval_tag & kValTtlBit, dptr); prev_entry) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe I am missing something but I think we don't really need two searches in any case since we can update the ttl field of dptr directly. So why can't we simply do:

if (sds prev_entry = (sds)AddOrReplaceObj(newkey, sdsval_tag & kValTtlBit, dptr); prev_entry) {
  // We know that there was a `prev_entry` so `keepttl` is relevant here. 
  if(keepttl) {
    auto old_ttl = ObjExpireTime(prev_entry);
    if(old_ttl != UINT_MAX) {
      ObjUpdateExpireTime(dptr, old_ttl);
    }
  }
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Unless I am missing something then:

  1. We don't need this search pass what found to AddOrReplaceObj dance.
  2. It works because HSETEX will always add a ttl field so CreateEntry above will always "reserve" space for the ttl field anyway

Copy link
Contributor

@kostasrim kostasrim Mar 10, 2025

Choose a reason for hiding this comment

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

all in all we can avoid this alltogether basically

Passing the result of search to AddOrReplaceObj as currently implemented in this PR to avoid two lookups

So there should be no extra overhead associated with the two searches

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To update the ttl of dptr after adding the object as you proposed, we need it to be initialized and pointing to the set member.

But we only get it from the first search added in this PR at

std::tie(dptr, raw) = FindObjPtr(&field, hashcode, 1);

If you compare with the main branch, we did not originally have access to dptr in this method, so it wouldn't be possible to update the ttl without the first search.

I had also thought of adding a call to ObjUpdateExpireTime inside DenseSet::AddOrReplaceObj after we find the prev. entry, this way there is only one search and the code is much simpler (a flag has to be passed into AddOrReplaceObj to preserve ttl), something like the following in DenseSet::AddOrReplaceObj:

void* res = dptr->Raw();
if (has_ttl && keepttl) {
  if (const auto ttl = ObjExpireTime(res); ttl != UINT32_MAX) {
    ObjUpdateExpireTime(res, ttl);
  }  
}

but that will change the behavior for all subclasses of DenseSet instead of just the string map class. Maybe the keepttl flag can be false by default so it doesn't change the behavior of other subclasses.

Copy link
Contributor

Choose a reason for hiding this comment

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

dptr was a typo. You don't need the denseptr to update the object. I will ping to explain internally.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

made changes as discussed

Comment on lines 729 to 748
OpSetParams LoadHSetExParams(CmdArgParser& parser) {
OpSetParams op_sp{};

auto res = parser.TryMapNext("NX"sv, HSetExOpts::NX, "KEEPTTL"sv, HSetExOpts::KEEPTTL);
while (res.has_value()) {
switch (res.value()) {
case HSetExOpts::NX:
op_sp.skip_if_exists = true;
break;
case HSetExOpts::KEEPTTL:
op_sp.keepttl = true;
break;
}
res = parser.TryMapNext("NX"sv, HSetExOpts::NX, "KEEPTTL"sv, HSetExOpts::KEEPTTL);
}

// Conversion errors are handled by caller
op_sp.ttl = parser.Next<uint32_t>();
return op_sp;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we really need all of this ? Why not bool skip_if_exists = static_cast<bool>(parser.Check("KEEPTTL"sv)); similarly to what we have on line 729 ? (see skip_if_exists)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I originally tried with Check but it seems to only work for a fixed order, eg if we have:

bool keepttl = parser.Check("KEEPTTL"sv);
bool skip = parser.Check("NX"sv);

This works if

  • only KEEPTTL is passed
  • only NX is passed
  • both are passed in the exact order KEEPTTL NX

But it rejects the command if we call HSETEX key NX KEEPTTL ..., so we have the order hardcoded.

If we reverse the order of parser.Check calls then NX KEEPTTL will work but KEEPTTL NX will not work.

So I used a loop so the current version of the code handles the options in any order.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the order of options is guaranteed to be fixed eg NX is always before KEEPTTL then I can add the two parser.Check calls in the same order.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

switched to use Check in a loop as discussed

@abhijat abhijat requested a review from kostasrim March 11, 2025 05:35
Signed-off-by: Abhijat Malviya <abhijat@dragonflydb.io>
Comment on lines 726 to 735
while (true) {
if (parser.Check("NX")) {
op_sp.skip_if_exists = true;
} else if (parser.Check("KEEPTTL")) {
op_sp.keepttl = true;
} else {
break;
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Thinking about this again why don't we just enforce an order ? NX should go first, followed by KEEPTTL if it exists.

So we rewrite this as:

if (parser.Check("NX")) {
  op_sp.skip_i_exists = true;
}
if (parser.Check("KEEPTTL")) {
      op_sp.keepttl = true;
}

And we are done.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

change to something similar

Copy link
Collaborator

Choose a reason for hiding this comment

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

I dont think this is a good decision to enforce the order. We dont do this in other commands afaik. This is actually more of a product decision of to enforce order of commands.
Please create a fix PR to not enforce the order @abhijat

IntArg(1));
EXPECT_EQ(Run({"HGET", "k", "afield"}), "aval");
// make afield expire
AdvanceTime(1000);
Copy link
Contributor

Choose a reason for hiding this comment

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

Before we advance time it would worth to assert that:

EXPECT_EQ(Run({"FIELDTTL", "k", "kttlfield"}).GetInt(), long_time);
EXPECT_EQ(Run({"FIELDTTL", "k", "afield"}).GetInt(), long_time);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added. Usually I'm a little wary of adding assertions where we check exact time values, but in this case I guess the test runs fast enough so it wouldn't fail.

Copy link
Contributor

Choose a reason for hiding this comment

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

If we don't advance time, it will remain the same. We have full control over this :)

Signed-off-by: Abhijat Malviya <abhijat@dragonflydb.io>
@abhijat abhijat requested a review from kostasrim March 11, 2025 14:24
kostasrim
kostasrim previously approved these changes Mar 11, 2025
Copy link
Contributor

@kostasrim kostasrim left a comment

Choose a reason for hiding this comment

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

LGTM 🚢 🇮🇹 👨‍🍳

Signed-off-by: Abhijat Malviya <abhijat@dragonflydb.io>
Signed-off-by: Abhijat Malviya <abhijat@dragonflydb.io>
Comment on lines +74 to +79
if (sds prev_entry = (sds)AddOrReplaceObj(newkey, sdsval_tag & kValTtlBit); prev_entry) {
const bool prev_has_ttl =
absl::little_endian::Load64(prev_entry + sdslen(prev_entry) + 1) & kValTtlBit;
if (keepttl && prev_has_ttl) {
SdsUpdateExpireTime(newkey, ObjExpireTime(prev_entry), 8);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I see. Ouch. We had a DCHECK in ObjExpireTime which we triggered right ? And then, ObjUpdateExpireTime used now() which changed the TTL. IMO I think the DCHECK in ObjExpireTime is a mistake but let's keep it as is for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The DCHECK wasn't triggered earlier as we had no asserts which go through the code path where KEEPTTL is passed but object has no expiry, so the object always had a valid expiry, the new assert added at the end of test now does check this code path.

Earlier what happened during test failure was:

  1. add object with ttl=100, expiry calculated to 103 (because now() == 3)
  2. add object again with keepttl, which ended up setting expiry to 103 in error.
  3. check failed because 100 != FIELDTTL


// kttlfield is still present although with updated value
EXPECT_EQ(Run({"HGET", "k", "kttlfield"}), "resetvalue");
EXPECT_EQ(Run({"FIELDTTL", "k", "kttlfield"}).GetInt(), 99);
Copy link
Contributor

@kostasrim kostasrim Mar 11, 2025

Choose a reason for hiding this comment

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

Now the math adds up. 100 second ttl, advance by 1 second. afield expired and kttlfield now requires 99 more seconds.

@abhijat abhijat merged commit 611e9b3 into main Mar 12, 2025
10 checks passed
@abhijat abhijat deleted the abhijat/feat/add_keepttl_to_hsetex branch March 12, 2025 05:53
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.

Add support for KEEPTTL argument in SADDEX and HSETEX
3 participants