Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ duckdb_unittest_tempdir/
testext
test/python/__pycache__/
.Rhistory
.env
.opencode/
AGENTS.md
vcpkg/
vcpkg_installed/
20 changes: 20 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Currently supported Redis operations:
- Hash operations: `HGET`, `HSET`, `HGETALL`, `HSCAN`, `HSCAN_OVER_SCAN`
- List operations: `LPUSH`, `LRANGE`, `LRANGE_TABLE`
- Key operations: `DEL`, `EXISTS`, `TYPE`, `SCAN`, `KEYS`
- TTL operations: `EXPIRE`, `TTL`, `EXPIREAT`
- Batch and discovery operations: `SCAN`, `HSCAN_OVER_SCAN`, `KEYS`

## Quick Reference: Available Functions
Expand All @@ -33,6 +34,9 @@ Currently supported Redis operations:
| `redis_del(key, secret)` | Scalar | Delete a key (returns TRUE if deleted) |
| `redis_exists(key, secret)` | Scalar | Check if a key exists (returns TRUE if exists) |
| `redis_type(key, secret)` | Scalar | Get the type of a key |
| `redis_expire(key, seconds, secret)` | Scalar | Set TTL in seconds (returns TRUE if set) |
| `redis_ttl(key, secret)` | Scalar | Get remaining TTL (-2=key not exists, -1=no expiry) |
| `redis_expireat(key, timestamp, secret)` | Scalar | Set expiry at Unix timestamp (returns TRUE if set) |
| `redis_scan(cursor, pattern, count, secret)` | Scalar | Scan keys (returns cursor:keys_csv) |
| `redis_hscan(key, cursor, pattern, count, secret)` | Scalar | Scan fields in a hash |
| `redis_keys(pattern, secret)` | Table | List all keys matching a pattern |
Expand Down Expand Up @@ -157,6 +161,22 @@ SELECT redis_exists('user:1', 'redis');
SELECT redis_type('user:1', 'redis');
```

### TTL Operations
```sql
-- Set a key to expire in 1 hour
SELECT redis_expire('session:123', 3600, 'redis');

-- Check remaining TTL
SELECT redis_ttl('session:123', 'redis') as remaining_seconds;

-- Set expiry at specific timestamp
SELECT redis_expireat('cache:item', 1736918400, 'redis');

-- Expire all session keys in a query
UPDATE sessions
SET expired = CASE WHEN redis_expire('session:' || id, 300, 'redis') THEN TRUE ELSE FALSE END;
```

### Batch and Discovery Operations
```sql
-- Get multiple keys at once
Expand Down
135 changes: 133 additions & 2 deletions src/redis_extension.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,35 @@ class RedisProtocol {
}
return cmd;
}

static std::string formatExpire(const std::string &key, int64_t seconds) {
return "*3\r\n$6\r\nEXPIRE\r\n$" + std::to_string(key.length()) + "\r\n" + key + "\r\n$" +
std::to_string(std::to_string(seconds).length()) + "\r\n" + std::to_string(seconds) + "\r\n";
}

static std::string formatExpireAt(const std::string &key, int64_t timestamp) {
return "*3\r\n$8\r\nEXPIREAT\r\n$" + std::to_string(key.length()) + "\r\n" + key + "\r\n$" +
std::to_string(std::to_string(timestamp).length()) + "\r\n" + std::to_string(timestamp) + "\r\n";
}

static std::string formatTtl(const std::string &key) {
return "*2\r\n$3\r\nTTL\r\n$" + std::to_string(key.length()) + "\r\n" + key + "\r\n";
}

static int64_t parseIntegerResponse(const std::string &response) {
if (response.empty() || response[0] != ':') {
throw InvalidInputException("Invalid Redis integer response");
}
size_t end = response.find("\r\n");
if (end == std::string::npos) {
throw InvalidInputException("Invalid Redis integer response");
}
try {
return std::stoll(response.substr(1, end - 1));
} catch (const std::exception &e) {
throw InvalidInputException("Failed to parse Redis integer response: %s", e.what());
}
}
};

// Redis connection class
Expand Down Expand Up @@ -845,6 +874,78 @@ static void RedisTypeFunction(DataChunk &args, ExpressionState &state, Vector &r
});
}

static void RedisExpireFunction(DataChunk &args, ExpressionState &state, Vector &result) {
auto &key_vector = args.data[0];
auto &seconds_vector = args.data[1];
auto &secret_vector = args.data[2];

// Extract secret once before executor loop (optimization)
string host, port, password;
if (!GetRedisSecret(state.GetContext(), secret_vector.GetValue(0).ToString(), host, port, password)) {
throw InvalidInputException("Redis secret not found");
}
auto conn = ConnectionPool::getInstance().getConnection(host, port, password);

BinaryExecutor::Execute<string_t, int64_t, bool>(
key_vector, seconds_vector, result, args.size(), [&](string_t key, int64_t seconds) {
try {
auto response = conn->execute(RedisProtocol::formatExpire(key.GetString(), seconds));
auto result_int = RedisProtocol::parseIntegerResponse(response);
// Returns 1 if TTL was set (key exists), 0 if key doesn't exist
return result_int == 1;
} catch (std::exception &e) {
throw InvalidInputException("Redis EXPIRE error: %s", e.what());
}
});
}

static void RedisTTLFunction(DataChunk &args, ExpressionState &state, Vector &result) {
auto &key_vector = args.data[0];
auto &secret_vector = args.data[1];

// Extract secret once before executor loop (optimization)
string host, port, password;
if (!GetRedisSecret(state.GetContext(), secret_vector.GetValue(0).ToString(), host, port, password)) {
throw InvalidInputException("Redis secret not found");
}
auto conn = ConnectionPool::getInstance().getConnection(host, port, password);

UnaryExecutor::Execute<string_t, int64_t>(key_vector, result, args.size(), [&](string_t key) {
try {
auto response = conn->execute(RedisProtocol::formatTtl(key.GetString()));
// Returns: -2 if key doesn't exist, -1 if key has no expiry, or positive TTL in seconds
return RedisProtocol::parseIntegerResponse(response);
} catch (std::exception &e) {
throw InvalidInputException("Redis TTL error: %s", e.what());
}
});
}

static void RedisExpireAtFunction(DataChunk &args, ExpressionState &state, Vector &result) {
auto &key_vector = args.data[0];
auto &timestamp_vector = args.data[1];
auto &secret_vector = args.data[2];

// Extract secret once before executor loop (optimization)
string host, port, password;
if (!GetRedisSecret(state.GetContext(), secret_vector.GetValue(0).ToString(), host, port, password)) {
throw InvalidInputException("Redis secret not found");
}
auto conn = ConnectionPool::getInstance().getConnection(host, port, password);

BinaryExecutor::Execute<string_t, int64_t, bool>(
key_vector, timestamp_vector, result, args.size(), [&](string_t key, int64_t timestamp) {
try {
auto response = conn->execute(RedisProtocol::formatExpireAt(key.GetString(), timestamp));
auto result_int = RedisProtocol::parseIntegerResponse(response);
// Returns 1 if expiry was set (key exists), 0 if key doesn't exist
return result_int == 1;
} catch (std::exception &e) {
throw InvalidInputException("Redis EXPIREAT error: %s", e.what());
}
});
}

static void LoadInternal(ExtensionLoader &loader) {
// Register the secret functions first!
CreateRedisSecretFunctions::Register(loader);
Expand Down Expand Up @@ -972,6 +1073,36 @@ static void LoadInternal(ExtensionLoader &loader) {
"stream) or 'none' if the key does not exist.",
{"key", "secret_name"}, {"SELECT redis_type('mykey', 'my_redis_secret');"});

// Register redis_expire scalar function
add_scalar_function(
ScalarFunction("redis_expire", {LogicalType::VARCHAR, LogicalType::BIGINT, LogicalType::VARCHAR},
LogicalType::BOOLEAN, RedisExpireFunction),
"Set a time-to-live (TTL) in seconds for a key. Returns true if the TTL was set, false if the key does not exist.",
{"key", "seconds", "secret_name"},
{"SELECT redis_expire('mykey', 3600, 'my_redis_secret');",
"SELECT redis_expire('session:' || id, 300, 'my_redis_secret') FROM users;"});

// Register redis_ttl scalar function
add_scalar_function(ScalarFunction("redis_ttl", {LogicalType::VARCHAR, LogicalType::VARCHAR}, LogicalType::BIGINT,
RedisTTLFunction),
"Get the remaining time-to-live (TTL) of a key in seconds. Returns -2 if the key does not exist, "
"-1 if the key exists but has no expiry set.",
{"key", "secret_name"},
{"SELECT redis_ttl('mykey', 'my_redis_secret');",
"SELECT key, redis_ttl(key, 'my_redis_secret') FROM (SELECT redis_get(key) as key FROM "
"redis_keys('*', 'my_redis_secret')); "});

// Register redis_expireat scalar function
add_scalar_function(
ScalarFunction("redis_expireat", {LogicalType::VARCHAR, LogicalType::BIGINT, LogicalType::VARCHAR},
LogicalType::BOOLEAN, RedisExpireAtFunction),
"Set an expiry time (Unix timestamp) for a key. Returns true if the expiry was set, false if the key does not "
"exist.",
{"key", "timestamp", "secret_name"},
{"SELECT redis_expireat('mykey', 1736918400, 'my_redis_secret');",
"SELECT redis_expireat('event:' || id, EXTRACT(EPOCH FROM (event_time + INTERVAL '1 day'))::BIGINT, "
"'my_redis_secret') FROM events;"});

// Register redis_keys table function
add_table_function(
TableFunction("redis_keys", {LogicalType::VARCHAR, LogicalType::VARCHAR}, RedisKeysFunction, RedisKeysBind),
Expand Down Expand Up @@ -1006,7 +1137,7 @@ static void LoadInternal(ExtensionLoader &loader) {
{"scan_pattern", "hscan_pattern", "count", "secret_name"},
{"SELECT * FROM redis_hscan_over_scan('user:*', '*', 100, 'my_redis_secret');"});

QueryFarmSendTelemetry(loader, "redis", "2025120401");
QueryFarmSendTelemetry(loader, "redis", "2026011401");
}

void RedisExtension::Load(ExtensionLoader &loader) {
Expand All @@ -1018,7 +1149,7 @@ std::string RedisExtension::Name() {
}

std::string RedisExtension::Version() const {
return "2025120401";
return "2026011401";
}

} // namespace duckdb
Expand Down
15 changes: 15 additions & 0 deletions test/sql/redis.test
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ SELECT * FROM redis_hscan_over_scan('*', '*', 100, 'nonexistent_secret');
----
Redis secret not found

statement error
SELECT redis_expire('mykey', 3600, 'nonexistent_secret');
----
Redis secret not found

statement error
SELECT redis_ttl('mykey', 'nonexistent_secret');
----
Redis secret not found

statement error
SELECT redis_expireat('mykey', 1736918400, 'nonexistent_secret');
----
Redis secret not found

# Test that we can create a redis secret
statement ok
CREATE SECRET my_test_secret (
Expand Down