← Back
Editing: fuzzy_update.lua
-- Copyright 2026 Vsevolod Stakhov -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. -- Fuzzy hash update script (per-hash, atomic) -- Handles ADD, DEL, and REFRESH operations including multi-flag merge and shingles -- -- KEYS[1] = hash_key (prefix + digest) -- KEYS[2] = count_key (prefix .. "_count") -- KEYS[3..] = shingle keys (0 or 32 of them) -- ARGV[1] = operation: "add", "del", "refresh" -- ARGV[2] = flag (string number) -- ARGV[3] = value (string number) -- ARGV[4] = expire (string number, seconds) -- ARGV[5] = timestamp (string number, calendar seconds) -- ARGV[6] = is_weak ("0" or "1") -- ARGV[7] = digest (raw bytes, used as value for shingle SETEX) local key = KEYS[1] local count_key = KEYS[2] local op = ARGV[1] local new_flag = tonumber(ARGV[2]) local new_value = tonumber(ARGV[3]) local expire = tonumber(ARGV[4]) local timestamp = ARGV[5] local is_weak = tonumber(ARGV[6]) local digest = ARGV[7] if op == "add" then -- Multi-flag merge logic: up to 8 flag slots (primary '' + extra '1'..'7') local data = redis.call('HGETALL', key) local fields = {} for i = 1, #data, 2 do fields[data[i]] = data[i+1] end local slots = {} local n_slots = 0 -- Check primary slot if fields['V'] and fields['F'] then slots[''] = {flag=tonumber(fields['F']), value=tonumber(fields['V'])} n_slots = n_slots + 1 end -- Check extra slots 1..7 for i = 1, 7 do local si = tostring(i) if fields['V'..si] and fields['F'..si] then slots[si] = {flag=tonumber(fields['F'..si]), value=tonumber(fields['V'..si])} n_slots = n_slots + 1 end end -- Try to find existing slot with same flag local found_slot = nil for slot, entry in pairs(slots) do if entry.flag == new_flag then found_slot = slot break end end if found_slot then -- Increment existing slot value redis.call('HINCRBY', key, 'V'..found_slot, new_value) elseif n_slots == 0 then -- Empty hash: create primary slot if is_weak == 1 then redis.call('HSETNX', key, 'F', new_flag) redis.call('HSETNX', key, 'V', new_value) else redis.call('HSET', key, 'F', new_flag, 'V', new_value) end slots[''] = {flag=new_flag, value=new_value} n_slots = 1 elseif n_slots < 8 then -- Find an empty slot and use it local empty_slot = nil if not slots[''] then empty_slot = '' else for i = 1, 7 do if not slots[tostring(i)] then empty_slot = tostring(i) break end end end if empty_slot then redis.call('HSET', key, 'F'..empty_slot, new_flag, 'V'..empty_slot, new_value) slots[empty_slot] = {flag=new_flag, value=new_value} n_slots = n_slots + 1 end else -- All 8 slots full: replace the minimum-value slot if new_value is larger if is_weak == 0 then local min_slot = nil local min_val = nil for slot, entry in pairs(slots) do if min_val == nil or entry.value < min_val then min_val = entry.value min_slot = slot end end if min_val ~= nil and new_value > min_val then redis.call('HSET', key, 'F'..min_slot, new_flag, 'V'..min_slot, new_value) slots[min_slot] = {flag=new_flag, value=new_value} end end end -- Ensure primary slot has the highest value (swap if needed) if n_slots > 1 then local max_val = nil local max_slot = nil for slot, _ in pairs(slots) do local v = tonumber(redis.call('HGET', key, 'V'..slot) or '0') if max_val == nil or v > max_val then max_val = v max_slot = slot end end if max_slot ~= nil and max_slot ~= '' and slots[''] then local pv = redis.call('HGET', key, 'V') local pf = redis.call('HGET', key, 'F') local bv = redis.call('HGET', key, 'V'..max_slot) local bf = redis.call('HGET', key, 'F'..max_slot) redis.call('HSET', key, 'V', bv, 'F', bf) redis.call('HSET', key, 'V'..max_slot, pv, 'F'..max_slot, pf) end end redis.call('HSETNX', key, 'C', timestamp) redis.call('EXPIRE', key, expire) redis.call('INCR', count_key) -- Handle shingles: SETEX each shingle key with expire and digest as value. -- Also persist the shingle set in the 'S' field of the digest hash -- ("idx_value" suffixes, comma separated), so digest-only deletes and -- refreshes can locate the slots without knowing the source message. if #KEYS > 2 then local suffixes = {} for i = 3, #KEYS do local suf = string.match(KEYS[i], '(%d+_%d+)$') if suf then suffixes[#suffixes + 1] = suf end redis.call('SETEX', KEYS[i], expire, digest) end if #suffixes > 0 then redis.call('HSET', key, 'S', table.concat(suffixes, ',')) end end elseif op == "del" then -- Reconstruct this digest's own shingle slots from the persisted set and -- delete those still owned by it: digest-only deletes (e.g. deletions by -- a hash list) carry no shingle keys, which used to leave orphaned slots local stored = redis.call('HGET', key, 'S') if stored then local prefix = string.sub(key, 1, #key - #digest) for suf in string.gmatch(stored, '[^,]+') do local skey = prefix .. '_' .. suf if redis.call('GET', skey) == digest then redis.call('DEL', skey) end end end redis.call('DEL', key) redis.call('DECR', count_key) for i = 3, #KEYS do redis.call('DEL', KEYS[i]) end elseif op == "refresh" then redis.call('EXPIRE', key, expire) -- Prefer the persisted shingle set: refresh the slots this digest still -- owns and reclaim expired ones, instead of touching the slots of the -- message that merely triggered the refresh local stored = redis.call('HGET', key, 'S') if stored then local prefix = string.sub(key, 1, #key - #digest) for suf in string.gmatch(stored, '[^,]+') do local skey = prefix .. '_' .. suf local owner = redis.call('GET', skey) if owner == digest then redis.call('EXPIRE', skey, expire) elseif not owner then redis.call('SETEX', skey, expire, digest) end end else for i = 3, #KEYS do redis.call('EXPIRE', KEYS[i], expire) end end end return 1
Save File
Cancel