File: //usr/share/rspamd/lualib/lua_hs_cache.lua
--[[
Copyright (c) 2026, Vsevolod Stakhov <vsevolod@rspamd.com>
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.
]]--
local logger = require "rspamd_logger"
local rspamd_util = require "rspamd_util"
local lua_redis = require "lua_redis"
local lua_util = require "lua_util"
local rspamd_http = require "rspamd_http"
local exports = {}
-- Use "hyperscan" module name so debug output is enabled together with C code
local N = "hyperscan"
-- File backend
local file_backend = {}
file_backend.__index = file_backend
-- Zstd magic number: 0xFD2FB528 (little-endian bytes: 28 B5 2F FD)
local ZSTD_MAGIC = string.char(0x28, 0xB5, 0x2F, 0xFD)
function file_backend.new(config)
local self = setmetatable({}, file_backend)
-- Store config for logging context
self.config = config.rspamd_config or config
-- Remove trailing slashes from cache_dir
local cache_dir = config.cache_dir or '/var/lib/rspamd/hs_cache'
self.cache_dir = cache_dir:gsub("/+$", "")
-- Default to flat directory structure for backward compatibility
self.platform_dirs = config.platform_dirs == true
-- Enable compression by default (consistent with redis/http backends)
local opts = config.file or config
self.use_compression = (opts.compression ~= false) and (config.compression ~= false)
lua_util.debugm(N, self.config, "file backend config: cache_dir=%s, platform_dirs=%s, compression=%s",
self.cache_dir, self.platform_dirs and "yes" or "no",
self.use_compression and "enabled" or "disabled")
return self
end
-- Get file extension based on compression setting
function file_backend:_get_extension()
return self.use_compression and '.hs.zst' or '.hs'
end
-- Get the path for a cache file
-- @param cache_key string cache key (hash that already includes platform info)
-- @param platform_id string platform identifier (unused in flat mode for backward compat)
-- @param ext string optional extension override (e.g., '.hs' or '.hs.zst')
function file_backend:_get_path(cache_key, platform_id, ext)
local extension = ext or self:_get_extension()
if self.platform_dirs then
-- Optional: use platform subdirectories (not default)
return string.format("%s/%s/%s%s", self.cache_dir, platform_id, cache_key, extension)
else
-- Default: flat structure matching original C code behavior
-- Platform info is already embedded in cache_key hash
return string.format("%s/%s%s", self.cache_dir, cache_key, extension)
end
end
-- Check if data starts with zstd magic bytes
function file_backend:_is_zstd(data)
if not data or #data < 4 then
return false
end
return data:sub(1, 4) == ZSTD_MAGIC
end
-- Find existing cache file, trying both compressed and uncompressed extensions
-- Returns: path, is_compressed (or nil if not found)
function file_backend:_find_existing_path(cache_key, platform_id)
-- Try compressed first if compression is enabled, otherwise try uncompressed first
local primary_ext = self:_get_extension()
local secondary_ext = self.use_compression and '.hs' or '.hs.zst'
local primary_path = self:_get_path(cache_key, platform_id, primary_ext)
-- rspamd_util.stat returns (err, stat_table) - check for no error AND valid stat
local err, stat = rspamd_util.stat(primary_path)
if not err and stat then
return primary_path, primary_ext == '.hs.zst'
end
local secondary_path = self:_get_path(cache_key, platform_id, secondary_ext)
err, stat = rspamd_util.stat(secondary_path)
if not err and stat then
return secondary_path, secondary_ext == '.hs.zst'
end
return nil, nil
end
function file_backend:_ensure_dir(path)
local dir = path:match("(.*/)")
if dir then
local ok, err = rspamd_util.mkdir(dir, true)
if not ok and err then
logger.warnx(N, "failed to create directory %s: %s", dir, err)
end
end
end
function file_backend:exists(cache_key, platform_id, callback)
local path, is_compressed = self:_find_existing_path(cache_key, platform_id)
if path then
local err, stat = rspamd_util.stat(path)
if not err and stat then
lua_util.debugm(N, self.config, "file exists check: %s found, size: %d, compressed: %s",
path, stat.size, is_compressed and "yes" or "no")
callback(nil, true, { size = stat.size, mtime = stat.mtime, compressed = is_compressed })
else
-- Race condition: file disappeared between _find_existing_path and stat
lua_util.debugm(N, self.config, "file exists check: %s disappeared (race)", path)
callback(nil, false, nil)
end
else
local expected_path = self:_get_path(cache_key, platform_id)
lua_util.debugm(N, self.config, "file exists check: %s not found (checked both extensions)", expected_path)
callback(nil, false, nil)
end
end
function file_backend:load(cache_key, platform_id, callback)
local path, expected_compressed = self:_find_existing_path(cache_key, platform_id)
if not path then
local expected_path = self:_get_path(cache_key, platform_id)
lua_util.debugm(N, self.config, "file load failed: %s not found (checked both extensions)", expected_path)
callback("file not found", nil)
return
end
lua_util.debugm(N, self.config, "file load from: %s (expected compressed: %s)", path, expected_compressed and "yes" or "no")
local f, err = io.open(path, "rb")
if not f then
lua_util.debugm(N, self.config, "file load failed from %s: %s", path, err or "open error")
callback(err or "open error", nil)
return
end
local data = f:read("*a")
f:close()
if not data then
lua_util.debugm(N, self.config, "file read failed from %s", path)
callback("read error", nil)
return
end
-- Check if data is actually zstd compressed (magic byte verification)
local is_zstd = self:_is_zstd(data)
lua_util.debugm(N, self.config, "file loaded %d bytes from %s, zstd magic: %s",
#data, path, is_zstd and "yes" or "no")
-- Notify hyperscan cache that this file is known (for cleanup tracking)
rspamd_util.hyperscan_notice_known(path)
if is_zstd then
-- Decompress the data
local decompress_err, decompressed = rspamd_util.zstd_decompress(data)
if not decompress_err and decompressed then
lua_util.debugm(N, self.config, "file decompressed %d -> %d bytes from %s (compression ratio: %.1f%%)",
#data, #decompressed, path, (1 - #data / #decompressed) * 100)
callback(nil, decompressed)
else
lua_util.debugm(N, self.config, "file decompression failed for %s: %s", path, decompress_err or "unknown error")
callback(decompress_err or "decompression failed", nil)
end
else
-- Data is not compressed, return as-is
if expected_compressed then
lua_util.debugm(N, self.config, "file %s has .zst extension but no zstd magic - treating as uncompressed", path)
end
callback(nil, data)
end
end
function file_backend:store(cache_key, platform_id, data, _ttl, callback)
local path = self:_get_path(cache_key, platform_id)
lua_util.debugm(N, self.config, "file store to: %s, original size: %d bytes, compression: %s",
path, #data, self.use_compression and "enabled" or "disabled")
self:_ensure_dir(path)
local store_data = data
-- Compress if enabled
if self.use_compression then
local compressed, compress_err = rspamd_util.zstd_compress(data)
if compressed then
lua_util.debugm(N, self.config, "file compressed %d -> %d bytes (%.1f%% size reduction) for %s",
#data, #compressed, (1 - #compressed / #data) * 100, path)
store_data = compressed
else
logger.warnx(N, "compression failed: %s, storing uncompressed to %s", compress_err, path)
end
end
-- Write to temp file first, then rename atomically
local tmp_path = path .. ".tmp." .. rspamd_util.random_hex(8)
-- store_data can be string or rspamd_text userdata
local ok, write_err
if type(store_data) == "userdata" and store_data.save_in_file then
ok, write_err = store_data:save_in_file(tmp_path)
else
local f, err = io.open(tmp_path, "wb")
if not f then
callback(err or "open failed")
return
end
ok, write_err = f:write(store_data)
f:close()
end
if not ok then
os.remove(tmp_path)
callback(write_err or "write failed")
return
end
do
local renamed, rename_err = os.rename(tmp_path, path)
if renamed then
lua_util.debugm(N, self.config, "stored %d bytes to %s", #store_data, path)
-- Notify hyperscan cache that this file is known (for cleanup tracking)
rspamd_util.hyperscan_notice_known(path)
-- Remove old file with opposite extension if it exists (migration cleanup)
local old_ext = self.use_compression and '.hs' or '.hs.zst'
local old_path = self:_get_path(cache_key, platform_id, old_ext)
local old_err, old_stat = rspamd_util.stat(old_path)
if not old_err and old_stat then
local removed = os.remove(old_path)
if removed then
lua_util.debugm(N, self.config, "removed old cache file %s (migrated to %s)", old_path, path)
end
end
callback(nil)
else
os.remove(tmp_path)
callback(rename_err or "rename failed")
end
end
end
function file_backend:delete(cache_key, platform_id, callback)
-- Try to delete both compressed and uncompressed versions
local deleted_any = false
local last_err = nil
for _, ext in ipairs({'.hs', '.hs.zst'}) do
local path = self:_get_path(cache_key, platform_id, ext)
local stat_err, stat = rspamd_util.stat(path)
if not stat_err and stat then
local ok, err = os.remove(path)
if ok then
lua_util.debugm(N, self.config, "deleted %s", path)
deleted_any = true
else
last_err = err
end
end
end
if deleted_any then
callback(nil)
else
callback(last_err or "file not found")
end
end
function file_backend:exists_sync(cache_key, platform_id)
local path, is_compressed = self:_find_existing_path(cache_key, platform_id)
if path then
lua_util.debugm(N, self.config, "file sync exists check: %s found (compressed: %s)",
path, is_compressed and "yes" or "no")
return true, nil
else
local expected_path = self:_get_path(cache_key, platform_id)
lua_util.debugm(N, self.config, "file sync exists check: %s not found (checked both extensions)", expected_path)
return false, nil
end
end
function file_backend:save_async(cache_key, platform_id, data, callback)
self:store(cache_key, platform_id, data, nil, callback)
end
function file_backend:load_async(cache_key, platform_id, callback)
self:load(cache_key, platform_id, callback)
end
function file_backend:exists_async(cache_key, platform_id, callback)
local exists, err = self:exists_sync(cache_key, platform_id)
callback(err, exists)
end
function file_backend:load_sync(cache_key, platform_id)
local path, expected_compressed = self:_find_existing_path(cache_key, platform_id)
if not path then
local expected_path = self:_get_path(cache_key, platform_id)
lua_util.debugm(N, self.config, "file sync load failed: %s not found (checked both extensions)", expected_path)
return nil, "file not found"
end
lua_util.debugm(N, self.config, "file sync load from: %s (expected compressed: %s)",
path, expected_compressed and "yes" or "no")
local f, err = io.open(path, "rb")
if not f then
lua_util.debugm(N, self.config, "file sync load failed from %s: %s", path, err or "open error")
return nil, err or "open error"
end
local data = f:read("*a")
f:close()
if not data then
lua_util.debugm(N, self.config, "file sync read failed from %s", path)
return nil, "read error"
end
-- Check if data is actually zstd compressed (magic byte verification)
local is_zstd = self:_is_zstd(data)
lua_util.debugm(N, self.config, "file sync loaded %d bytes from %s, zstd magic: %s",
#data, path, is_zstd and "yes" or "no")
-- Notify hyperscan cache that this file is known (for cleanup tracking)
rspamd_util.hyperscan_notice_known(path)
if is_zstd then
-- Decompress the data
local decompress_err, decompressed = rspamd_util.zstd_decompress(data)
if not decompress_err and decompressed then
lua_util.debugm(N, self.config, "file sync decompressed %d -> %d bytes from %s (compression ratio: %.1f%%)",
#data, #decompressed, path, (1 - #data / #decompressed) * 100)
return decompressed, nil
else
lua_util.debugm(N, self.config, "file sync decompression failed for %s: %s", path, decompress_err or "unknown error")
return nil, decompress_err or "decompression failed"
end
else
-- Data is not compressed, return as-is
if expected_compressed then
lua_util.debugm(N, self.config, "file %s has .zst extension but no zstd magic - treating as uncompressed", path)
end
return data, nil
end
end
function file_backend:save_sync(cache_key, platform_id, data)
local path = self:_get_path(cache_key, platform_id)
lua_util.debugm(N, self.config, "file sync save to: %s, original size: %d bytes, compression: %s",
path, #data, self.use_compression and "enabled" or "disabled")
self:_ensure_dir(path)
local store_data = data
-- Compress if enabled
if self.use_compression then
local compressed, compress_err = rspamd_util.zstd_compress(data)
if compressed then
lua_util.debugm(N, self.config, "file sync compressed %d -> %d bytes (%.1f%% size reduction) for %s",
#data, #compressed, (1 - #compressed / #data) * 100, path)
store_data = compressed
else
logger.warnx(N, "compression failed: %s, storing uncompressed to %s", compress_err, path)
end
end
local tmp_path = path .. ".tmp." .. rspamd_util.random_hex(8)
-- store_data can be string or rspamd_text userdata
local ok, write_err
if type(store_data) == "userdata" and store_data.save_in_file then
ok, write_err = store_data:save_in_file(tmp_path)
else
local f, err = io.open(tmp_path, "wb")
if not f then
lua_util.debugm(N, self.config, "file sync open failed for %s: %s", tmp_path, err)
return false, err
end
ok, write_err = f:write(store_data)
f:close()
end
if not ok then
lua_util.debugm(N, self.config, "file sync write failed to %s: %s", tmp_path, write_err)
os.remove(tmp_path)
return false, write_err
end
local renamed, rename_err = os.rename(tmp_path, path)
if not renamed then
lua_util.debugm(N, self.config, "file sync rename failed %s -> %s: %s", tmp_path, path, rename_err)
os.remove(tmp_path)
return false, rename_err
end
lua_util.debugm(N, self.config, "file sync stored %d bytes to %s", #store_data, path)
-- Notify hyperscan cache that this file is known (for cleanup tracking)
rspamd_util.hyperscan_notice_known(path)
-- Remove old file with opposite extension if it exists (migration cleanup)
local old_ext = self.use_compression and '.hs' or '.hs.zst'
local old_path = self:_get_path(cache_key, platform_id, old_ext)
local old_err, old_stat = rspamd_util.stat(old_path)
if not old_err and old_stat then
local removed = os.remove(old_path)
if removed then
lua_util.debugm(N, self.config, "removed old cache file %s (migrated to %s)", old_path, path)
end
end
return true, nil
end
-- Redis backend
local redis_backend = {}
redis_backend.__index = redis_backend
function redis_backend.new(config)
local self = setmetatable({}, redis_backend)
-- Redis config can be:
-- 1. In a 'redis' sub-section of hs_helper worker options
-- 2. Directly in the hs_helper worker options (servers, write_servers, etc.)
-- 3. Fallback to global 'redis' configuration section
local redis_opts = config.redis or config
self.redis_params = lua_redis.parse_redis_server(nil, redis_opts, true)
if not self.redis_params then
-- Fallback to global redis config
self.redis_params = lua_redis.parse_redis_server('redis')
end
if not self.redis_params then
logger.warnx(N, "redis backend: no redis configuration found in hs_helper worker or global redis section")
end
if config.ev_base and self.redis_params then
self.redis_params.ev_base = config.ev_base
end
if config.rspamd_config then
self.config = config.rspamd_config
else
self.config = config
end
-- Config options can be in redis sub-section or at top level
local opts = config.redis or config
self.default_ttl = opts.ttl or config.ttl or (86400 * 30) -- 30 days default
self.refresh_ttl = (opts.refresh_ttl ~= false) and (config.refresh_ttl ~= false)
self.use_compression = (opts.compression ~= false) and (config.compression ~= false)
-- Use different default prefix for compressed (rspamd_zhs) vs uncompressed (rspamd_hs)
local default_prefix = self.use_compression and 'rspamd_zhs' or 'rspamd_hs'
self.prefix = opts.prefix or config.prefix or default_prefix
lua_util.debugm(N, self.config, "redis backend config: prefix=%s, ttl=%s, refresh_ttl=%s, compression=%s",
self.prefix, self.default_ttl, self.refresh_ttl, self.use_compression)
return self
end
function redis_backend:_get_key(cache_key, platform_id)
return string.format("%s:%s:%s", self.prefix, platform_id, cache_key)
end
function redis_backend:exists(cache_key, platform_id, callback)
local key = self:_get_key(cache_key, platform_id)
if not self.redis_params then
callback("redis not configured", false, nil)
return
end
lua_util.debugm(N, self.config, "redis EXISTS check for key: %s", key)
local attrs = {
ev_base = self.redis_params.ev_base,
config = self.config,
callback = function(err, data)
if err then
lua_util.debugm(N, self.config, "redis EXISTS failed for key %s: %s", key, err)
callback(err, false, nil)
else
lua_util.debugm(N, self.config, "redis EXISTS result for key %s: %s", key, data == 1 and "found" or "not found")
callback(nil, data == 1, nil)
end
end
}
local req = {'EXISTS', key}
lua_redis.request(self.redis_params, attrs, req)
end
function redis_backend:load(cache_key, platform_id, callback)
local key = self:_get_key(cache_key, platform_id)
if not self.redis_params then
callback("redis not configured", nil)
return
end
-- Use GETEX to refresh TTL on read if enabled
local req
if self.refresh_ttl then
lua_util.debugm(N, self.config, "redis GETEX (with TTL refresh %d) for key: %s", self.default_ttl, key)
req = {'GETEX', key, 'EX', tostring(self.default_ttl)}
else
lua_util.debugm(N, self.config, "redis GET for key: %s", key)
req = {'GET', key}
end
local attrs = {
ev_base = self.redis_params.ev_base,
config = self.config,
callback = function(err, data)
if err then
lua_util.debugm(N, self.config, "redis GET failed for key %s: %s", key, err)
callback(err, nil)
elseif not data then
lua_util.debugm(N, self.config, "redis cache miss for key %s", key)
callback("not found", nil)
else
-- Decompress if needed
if self.use_compression then
-- Defensive check: ensure data is a string or text before decompression
local data_type = type(data)
if data_type ~= 'string' and data_type ~= 'userdata' then
logger.errx(self.config, "redis GET returned unexpected type %s for key %s, data=%s",
data_type, key, tostring(data))
callback("unexpected data type: " .. data_type, nil)
return
end
local ok, decompress_err, decompressed = pcall(rspamd_util.zstd_decompress, data)
if not ok then
-- pcall failed - decompress_err contains the error message
logger.errx(self.config, "zstd_decompress pcall failed for key %s: %s, data type=%s, len=%s",
key, tostring(decompress_err), data_type, data_type == 'string' and #data or 'N/A')
callback("decompression error: " .. tostring(decompress_err), nil)
return
end
if not decompress_err and decompressed then
lua_util.debugm(N, self.config, "redis loaded and decompressed %d -> %d bytes from key %s (compression ratio: %.1f%%)",
#data, #decompressed, key, (1 - #data / #decompressed) * 100)
callback(nil, decompressed)
else
lua_util.debugm(N, self.config, "redis decompression failed for key %s: %s", key, decompress_err)
callback(decompress_err or "decompression failed", nil)
end
else
lua_util.debugm(N, self.config, "redis loaded %d bytes (uncompressed) from key %s", #data, key)
callback(nil, data)
end
end
end
}
lua_redis.request(self.redis_params, attrs, req)
end
function redis_backend:store(cache_key, platform_id, data, ttl, callback)
local key = self:_get_key(cache_key, platform_id)
local actual_ttl = ttl or self.default_ttl
if not self.redis_params then
callback("redis not configured")
return
end
lua_util.debugm(N, self.config, "redis SETEX for key: %s, original size: %d bytes, TTL: %d, compression: %s",
key, #data, actual_ttl, self.use_compression and "enabled" or "disabled")
local store_data = data
-- Compress if enabled
if self.use_compression then
local compressed, compress_err = rspamd_util.zstd_compress(data)
if compressed then
lua_util.debugm(N, self.config, "redis compressed %d -> %d bytes (%.1f%% size reduction) for key %s",
#data, #compressed, (1 - #compressed / #data) * 100, key)
store_data = compressed
else
logger.warnx(N, "compression failed: %s, storing uncompressed", compress_err)
end
end
local attrs = {
ev_base = self.redis_params.ev_base,
config = self.config,
is_write = true,
callback = function(err)
if err then
lua_util.debugm(N, self.config, "redis SETEX failed for key %s: %s", key, err)
callback(err)
else
lua_util.debugm(N, self.config, "redis stored %d bytes to key %s with TTL %d",
#store_data, key, actual_ttl)
callback(nil)
end
end
}
local req = {'SETEX', key, tostring(actual_ttl), store_data}
lua_redis.request(self.redis_params, attrs, req)
end
function redis_backend:delete(cache_key, platform_id, callback)
local key = self:_get_key(cache_key, platform_id)
if not self.redis_params then
callback("redis not configured")
return
end
lua_util.debugm(N, self.config, "redis DEL for key: %s", key)
local attrs = {
ev_base = self.redis_params.ev_base,
config = self.config,
is_write = true,
callback = function(err)
if err then
lua_util.debugm(N, self.config, "redis DEL failed for key %s: %s", key, err)
callback(err)
else
lua_util.debugm(N, self.config, "redis deleted key %s", key)
callback(nil)
end
end
}
local req = {'DEL', key}
lua_redis.request(self.redis_params, attrs, req)
end
function redis_backend:save_async(cache_key, platform_id, data, callback)
self:store(cache_key, platform_id, data, nil, callback)
end
function redis_backend:load_async(cache_key, platform_id, callback)
self:load(cache_key, platform_id, callback)
end
function redis_backend:exists_async(cache_key, platform_id, callback)
self:exists(cache_key, platform_id, callback)
end
-- HTTP backend
local http_backend = {}
http_backend.__index = http_backend
function http_backend.new(config)
local self = setmetatable({}, http_backend)
-- Store config for logging context
self.config = config.rspamd_config or config
-- HTTP config can be in 'http' sub-section or at top level
local opts = config.http or config
self.base_url = opts.base_url or opts.url
self.timeout = opts.timeout or config.timeout or 30
self.auth_header = opts.auth_header or config.auth_header
self.auth_value = opts.auth_value or config.auth_value
self.use_compression = (opts.compression ~= false) and (config.compression ~= false)
return self
end
function http_backend:_get_url(cache_key, platform_id)
return string.format("%s/%s/%s", self.base_url, platform_id, cache_key)
end
function http_backend:_get_headers()
local headers = {}
if self.auth_header and self.auth_value then
headers[self.auth_header] = self.auth_value
end
return headers
end
function http_backend:exists(cache_key, platform_id, callback)
local url = self:_get_url(cache_key, platform_id)
lua_util.debugm(N, self.config, "http HEAD check for url: %s", url)
rspamd_http.request({
url = url,
method = 'HEAD',
headers = self:_get_headers(),
timeout = self.timeout,
callback = function(err, code, _, headers)
if err then
lua_util.debugm(N, self.config, "http HEAD failed for %s: %s", url, err)
callback(err, false, nil)
elseif code == 200 then
local size = headers and headers['content-length']
lua_util.debugm(N, self.config, "http HEAD found %s, size: %s", url, size or "unknown")
callback(nil, true, { size = tonumber(size) })
else
lua_util.debugm(N, self.config, "http HEAD not found %s (code: %d)", url, code)
callback(nil, false, nil)
end
end
})
end
function http_backend:load(cache_key, platform_id, callback)
local url = self:_get_url(cache_key, platform_id)
lua_util.debugm(N, self.config, "http GET for url: %s", url)
rspamd_http.request({
url = url,
method = 'GET',
headers = self:_get_headers(),
timeout = self.timeout,
callback = function(err, code, body, headers)
if err then
lua_util.debugm(N, self.config, "http GET failed for %s: %s", url, err)
callback(err, nil)
elseif code == 200 and body then
-- Check if content is compressed
local content_encoding = headers and headers['content-encoding']
if content_encoding == 'zstd' or self.use_compression then
local decompress_err, decompressed = rspamd_util.zstd_decompress(body)
if not decompress_err and decompressed then
lua_util.debugm(N, self.config, "http loaded and decompressed %d -> %d bytes from %s",
#body, #decompressed, url)
callback(nil, decompressed)
else
lua_util.debugm(N, self.config, "http loaded %d bytes (no decompression) from %s", #body, url)
callback(nil, body)
end
else
lua_util.debugm(N, self.config, "http loaded %d bytes from %s", #body, url)
callback(nil, body)
end
elseif code == 404 then
lua_util.debugm(N, self.config, "http cache miss (404) for %s", url)
callback("not found", nil)
else
lua_util.debugm(N, self.config, "http GET failed for %s: HTTP %d", url, code)
callback(string.format("HTTP %d", code), nil)
end
end
})
end
function http_backend:store(cache_key, platform_id, data, ttl, callback)
local url = self:_get_url(cache_key, platform_id)
local headers = self:_get_headers()
lua_util.debugm(N, self.config, "http PUT for url: %s, original size: %d bytes, compression: %s",
url, #data, self.use_compression and "enabled" or "disabled")
local store_data = data
if self.use_compression then
local compressed = rspamd_util.zstd_compress(data)
if compressed then
lua_util.debugm(N, self.config, "http compressed %d -> %d bytes (%.1f%% size reduction) for %s",
#data, #compressed, (1 - #compressed / #data) * 100, url)
store_data = compressed
headers['Content-Encoding'] = 'zstd'
end
end
if ttl then
headers['X-TTL'] = tostring(ttl)
end
rspamd_http.request({
url = url,
method = 'PUT',
headers = headers,
body = store_data,
timeout = self.timeout,
callback = function(err, code)
if err then
lua_util.debugm(N, self.config, "http PUT failed for %s: %s", url, err)
callback(err)
elseif code >= 200 and code < 300 then
lua_util.debugm(N, self.config, "http stored %d bytes to %s", #store_data, url)
callback(nil)
else
lua_util.debugm(N, self.config, "http PUT failed for %s: HTTP %d", url, code)
callback(string.format("HTTP %d", code))
end
end
})
end
function http_backend:delete(cache_key, platform_id, callback)
local url = self:_get_url(cache_key, platform_id)
lua_util.debugm(N, self.config, "http DELETE for url: %s", url)
rspamd_http.request({
url = url,
method = 'DELETE',
headers = self:_get_headers(),
timeout = self.timeout,
callback = function(err, code)
if err then
lua_util.debugm(N, self.config, "http DELETE failed for %s: %s", url, err)
callback(err)
elseif code >= 200 and code < 300 or code == 404 then
lua_util.debugm(N, self.config, "http deleted %s", url)
callback(nil)
else
lua_util.debugm(N, self.config, "http DELETE failed for %s: HTTP %d", url, code)
callback(string.format("HTTP %d", code))
end
end
})
end
-- Backend factory
-- Create a backend instance based on configuration
-- @param config table with:
-- - backend: "file"|"redis"|"http" (default: "file")
-- - cache_dir: directory for file backend
-- - redis: redis configuration table
-- - http: http configuration table
-- @return backend instance
function exports.create_backend(config)
local backend_type = config.backend or config.cache_backend or 'file'
local cfg = config.rspamd_config or config
lua_util.debugm(N, cfg, "creating hyperscan cache backend: %s", backend_type)
-- Always pass full config - backends will extract what they need
-- (config contains ev_base, rspamd_config at top level, plus optional
-- redis/http sub-sections for backend-specific settings)
if backend_type == 'file' then
local be = file_backend.new(config)
lua_util.debugm(N, be.config, "file backend created, cache_dir: %s, compression: %s",
be.cache_dir or "not set", be.use_compression and "enabled" or "disabled")
return be
elseif backend_type == 'redis' then
local be = redis_backend.new(config)
if be.redis_params then
lua_util.debugm(N, be.config, "redis backend created, prefix: %s, compression: %s",
be.prefix, be.use_compression and "enabled" or "disabled")
else
logger.errx(N, "redis backend created but no redis params - operations will fail!")
end
return be
elseif backend_type == 'http' then
local be = http_backend.new(config)
lua_util.debugm(N, be.config, "http backend created, base_url: %s", be.base_url or "not set")
return be
else
logger.errx(N, "unknown hyperscan cache backend: %s, falling back to file", backend_type)
return file_backend.new(config)
end
end
-- Export individual backend constructors for direct use
exports.file_backend = file_backend
exports.redis_backend = redis_backend
exports.http_backend = http_backend
return exports