feat: compaction, page cache, WebSocket, rate limiter, TF-IDF, fuzzy search, regex, metadata filter — 73 tests

- SSTable compaction: size-tiered strategy, level-based scheduling
- Page cache: LRU eviction, hit rate tracking, capacity management
- WebSocket: full duplex streaming, frame encoding, ping/pong
- Rate limiter: token bucket + sliding window algorithms
- FTS: TF-IDF ranking, Levenshtein fuzzy matching, wildcard regex
- Vector: metadata filtering on HNSW search
- 16 new tests (73 total, all passing)
This commit is contained in:
2026-05-06 01:03:58 +03:00
parent 07a37d8e78
commit 67213826a8
7 changed files with 817 additions and 14 deletions
+12 -12
View File
@@ -65,8 +65,8 @@
- [x] Типова система (int, float, string, bool, bytes, uuid, datetime, json, vector) - [x] Типова система (int, float, string, bool, bytes, uuid, datetime, json, vector)
- [x] Сериялизация на записите - [x] Сериялизация на записите
- [x] B-Tree индекс за точкови заявки - [x] B-Tree индекс за точкови заявки
- [ ] Компактиране на SSTable (compaction strategies) - [x] Компактиране на SSTable (compaction strategies)
- [ ] Page cache и buffer pool - [x] Page cache и buffer pool (LRU)
### Фаза 2: Език за заявки — BaraQL 🟡 ### Фаза 2: Език за заявки — BaraQL 🟡
- [x] Лексер с Unicode поддръжка - [x] Лексер с Unicode поддръжка
@@ -109,9 +109,9 @@
- [x] HTTP/REST API (JSON) - [x] HTTP/REST API (JSON)
- [x] Connection pooling - [x] Connection pooling
- [x] Authentication (JWT, SCRAM-SHA-256) - [x] Authentication (JWT, SCRAM-SHA-256)
- [ ] WebSocket за streaming - [x] WebSocket за streaming
- [x] Rate limiting (token bucket, sliding window)
- [ ] TLS/SSL - [ ] TLS/SSL
- [ ] Rate limiting
### Фаза 6: Schema система ✅ ### Фаза 6: Schema система ✅
- [x] Декларативна schema (SDL) - [x] Декларативна schema (SDL)
@@ -128,7 +128,7 @@
- [x] IVF-PQ индекс (Inverted File + Product Quantization) - [x] IVF-PQ индекс (Inverted File + Product Quantization)
- [x] Дистанционни метрики (cosine, euclidean, dot product, Manhattan) - [x] Дистанционни метрики (cosine, euclidean, dot product, Manhattan)
- [x] Квантизация (scalar 8-bit/4-bit, product, binary) - [x] Квантизация (scalar 8-bit/4-bit, product, binary)
- [ ] Metadata filtering при vector search - [x] Metadata filtering при vector search
- [ ] Batch insert/update - [ ] Batch insert/update
- [ ] Автоматичен index rebuild при threshold - [ ] Автоматичен index rebuild при threshold
@@ -148,9 +148,9 @@
- [x] Токенизация (Unicode, stemming, stop words) - [x] Токенизация (Unicode, stemming, stop words)
- [x] BM25 ранкиране - [x] BM25 ранкиране
- [x] Highlight на резултати - [x] Highlight на резултати
- [ ] TF-IDF ранкиране - [x] TF-IDF ранкиране
- [ ] Fuzzy matching (Levenshtein) - [x] Fuzzy matching (Levenshtein)
- [ ] Regex търсене - [x] Regex търсене (wildcard patterns)
- [ ] Многоезикова поддръжка - [ ] Многоезикова поддръжка
### Фаза 10: Клиентски библиотеки и CLI ✅ ### Фаза 10: Клиентски библиотеки и CLI ✅
@@ -195,15 +195,15 @@
| Фаза | Статус | Напредък | | Фаза | Статус | Напредък |
|------|--------|----------| |------|--------|----------|
| 1. Ядро | ✅ Основно завършена | 85% | | 1. Ядро | ✅ Завършена | 95% |
| 2. BaraQL | 🟡 В процес | 60% | | 2. BaraQL | 🟡 В процес | 60% |
| 3. Мултимодален storage | 🟡 В процес | 75% | | 3. Мултимодален storage | 🟡 В процес | 75% |
| 4. Транзакции | ✅ Основно завършена | 85% | | 4. Транзакции | ✅ Основно завършена | 85% |
| 5. Протокол | 🟡 В процес | 70% | | 5. Протокол | 🟡 В процес | 85% |
| 6. Schema | ✅ Основно завършена | 75% | | 6. Schema | ✅ Основно завършена | 75% |
| 7. Векторен engine | ✅ Завършена | 85% | | 7. Векторен engine | ✅ Завършена | 95% |
| 8. Graph engine | ✅ Завършена | 90% | | 8. Graph engine | ✅ Завършена | 90% |
| 9. FTS | ✅ Завършена | 60% | | 9. FTS | ✅ Завършена | 85% |
| 10. Клиенти и CLI | 🟡 В процес | 50% | | 10. Клиенти и CLI | 🟡 В процес | 50% |
| 11. Кластер | ⬜ Не стартирана | 0% | | 11. Кластер | ⬜ Не стартирана | 0% |
| 12. Оптимизации | ⬜ Не стартирана | 0% | | 12. Оптимизации | ⬜ Не стартирана | 0% |
+130
View File
@@ -229,3 +229,133 @@ proc search*(idx: InvertedIndex, query: string, limit: int = 10,
proc termCount*(idx: InvertedIndex): int = idx.postings.len proc termCount*(idx: InvertedIndex): int = idx.postings.len
proc documentCount*(idx: InvertedIndex): int = idx.docCount proc documentCount*(idx: InvertedIndex): int = idx.docCount
# TF-IDF ranking
proc tfidfScore*(idx: InvertedIndex, term: string, docId: uint64): float64 =
if term notin idx.postings:
return 0.0
let df = idx.postings[term].len
let n = idx.docCount
if df == 0 or n == 0:
return 0.0
var tf = 0
for entry in idx.postings[term]:
if entry.docId == docId:
tf = entry.termFreq
break
let idf = ln(float64(n) / float64(df))
return float64(tf) * idf
proc searchTfidf*(idx: InvertedIndex, query: string, limit: int = 10,
config: TokenizerConfig = defaultTokenizerConfig()): seq[SearchResult] =
let queryTokens = tokenize(query, config)
if queryTokens.len == 0:
return @[]
var docScores = initTable[uint64, float64]()
for token in queryTokens:
if token notin idx.postings:
continue
for entry in idx.postings[token]:
let score = idx.tfidfScore(token, entry.docId)
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docScores[entry.docId] += score
var results: seq[SearchResult] = @[]
for docId, score in docScores:
results.add(SearchResult(docId: docId, score: score, highlights: @[]))
results.sort(proc(a, b: SearchResult): int = cmp(b.score, a.score))
if results.len > limit:
results = results[0..<limit]
return results
# Levenshtein distance for fuzzy matching
proc levenshtein*(a, b: string): int =
let m = a.len
let n = b.len
var d = newSeq[seq[int]](m + 1)
for i in 0..m:
d[i] = newSeq[int](n + 1)
d[i][0] = i
for j in 0..n:
d[0][j] = j
for i in 1..m:
for j in 1..n:
let cost = if a[i-1] == b[j-1]: 0 else: 1
d[i][j] = min(d[i-1][j] + 1, min(d[i][j-1] + 1, d[i-1][j-1] + cost))
return d[m][n]
proc fuzzySearch*(idx: InvertedIndex, query: string, maxDistance: int = 2,
limit: int = 10, config: TokenizerConfig = defaultTokenizerConfig()): seq[SearchResult] =
let queryTokens = tokenize(query, config)
if queryTokens.len == 0:
return @[]
var docScores = initTable[uint64, float64]()
for term in idx.postings.keys:
for queryToken in queryTokens:
let dist = levenshtein(term, queryToken)
if dist <= maxDistance:
let simScore = 1.0 - float64(dist) / float64(max(queryToken.len, term.len))
for entry in idx.postings[term]:
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docScores[entry.docId] += simScore * float64(entry.termFreq)
var results: seq[SearchResult] = @[]
for docId, score in docScores:
results.add(SearchResult(docId: docId, score: score, highlights: @[]))
results.sort(proc(a, b: SearchResult): int = cmp(b.score, a.score))
if results.len > limit:
results = results[0..<limit]
return results
# Regex search
proc regexSearch*(idx: InvertedIndex, pattern: string,
limit: int = 10): seq[SearchResult] =
var docScores = initTable[uint64, float64]()
for term in idx.postings.keys:
# Simple pattern matching: check if pattern is substring
if pattern.len > 0:
var match = false
# Check if pattern starts with/ends with or contains
if pattern.startsWith("*") and pattern.endsWith("*"):
let inner = pattern[1..^2]
if term.find(inner) >= 0:
match = true
elif pattern.startsWith("*"):
let suffix = pattern[1..^1]
if term.endsWith(suffix):
match = true
elif pattern.endsWith("*"):
let prefix = pattern[0..^2]
if term.startsWith(prefix):
match = true
else:
if term == pattern:
match = true
if match:
for entry in idx.postings[term]:
if entry.docId notin docScores:
docScores[entry.docId] = 0.0
docScores[entry.docId] += float64(entry.termFreq)
var results: seq[SearchResult] = @[]
for docId, score in docScores:
results.add(SearchResult(docId: docId, score: score, highlights: @[]))
results.sort(proc(a, b: SearchResult): int = cmp(b.score, a.score))
if results.len > limit:
results = results[0..<limit]
return results
+134
View File
@@ -0,0 +1,134 @@
## Rate Limiter — token bucket and sliding window algorithms
import std/tables
import std/monotimes
import std/locks
type
RateLimitAlgo* = enum
rlaTokenBucket
rlaSlidingWindow
rlaFixedWindow
TokenBucket* = ref object
tokens: float64
maxTokens: float64
refillRate: float64 # tokens per second
lastRefill: int64
SlidingWindow* = ref object
windowSize: int64 # nanoseconds
maxRequests: int
timestamps: seq[int64]
RateLimiter* = ref object
lock: Lock
algo: RateLimitAlgo
buckets: Table[string, TokenBucket]
windows: Table[string, SlidingWindow]
globalRate*: int
perClientRate*: int
proc newTokenBucket*(maxTokens: float64, refillRate: float64): TokenBucket =
TokenBucket(
tokens: maxTokens,
maxTokens: maxTokens,
refillRate: refillRate,
lastRefill: getMonoTime().ticks(),
)
proc consume*(bucket: TokenBucket, tokens: float64 = 1.0): bool =
let now = getMonoTime().ticks()
let elapsed = float64(now - bucket.lastRefill) / 1_000_000_000.0
bucket.tokens = min(bucket.maxTokens, bucket.tokens + elapsed * bucket.refillRate)
bucket.lastRefill = now
if bucket.tokens >= tokens:
bucket.tokens -= tokens
return true
return false
proc available*(bucket: TokenBucket): float64 =
let now = getMonoTime().ticks()
let elapsed = float64(now - bucket.lastRefill) / 1_000_000_000.0
return min(bucket.maxTokens, bucket.tokens + elapsed * bucket.refillRate)
proc newSlidingWindow*(windowSize: int64, maxRequests: int): SlidingWindow =
SlidingWindow(
windowSize: windowSize,
maxRequests: maxRequests,
timestamps: @[],
)
proc allow*(window: SlidingWindow): bool =
let now = getMonoTime().ticks()
let cutoff = now - window.windowSize
# Remove old timestamps
var newTs: seq[int64] = @[]
for ts in window.timestamps:
if ts > cutoff:
newTs.add(ts)
window.timestamps = newTs
if window.timestamps.len < window.maxRequests:
window.timestamps.add(now)
return true
return false
proc requestCount*(window: SlidingWindow): int = window.timestamps.len
proc newRateLimiter*(algo: RateLimitAlgo = rlaTokenBucket,
globalRate: int = 1000, perClientRate: int = 100): RateLimiter =
new(result)
initLock(result.lock)
result.algo = algo
result.buckets = initTable[string, TokenBucket]()
result.windows = initTable[string, SlidingWindow]()
result.globalRate = globalRate
result.perClientRate = perClientRate
proc allowRequest*(rl: RateLimiter, clientId: string): bool =
acquire(rl.lock)
case rl.algo
of rlaTokenBucket:
if clientId notin rl.buckets:
rl.buckets[clientId] = newTokenBucket(float64(rl.perClientRate),
float64(rl.perClientRate) / 60.0)
result = rl.buckets[clientId].consume()
of rlaSlidingWindow:
if clientId notin rl.windows:
rl.windows[clientId] = newSlidingWindow(60_000_000_000, rl.perClientRate)
result = rl.windows[clientId].allow()
of rlaFixedWindow:
if clientId notin rl.windows:
rl.windows[clientId] = newSlidingWindow(60_000_000_000, rl.perClientRate)
result = rl.windows[clientId].allow()
release(rl.lock)
proc remainingQuota*(rl: RateLimiter, clientId: string): int =
acquire(rl.lock)
case rl.algo
of rlaTokenBucket:
if clientId in rl.buckets:
result = int(rl.buckets[clientId].available())
else:
result = rl.perClientRate
of rlaSlidingWindow, rlaFixedWindow:
if clientId in rl.windows:
result = rl.perClientRate - rl.windows[clientId].requestCount()
else:
result = rl.perClientRate
release(rl.lock)
proc resetClient*(rl: RateLimiter, clientId: string) =
acquire(rl.lock)
rl.buckets.del(clientId)
rl.windows.del(clientId)
release(rl.lock)
proc clientCount*(rl: RateLimiter): int =
acquire(rl.lock)
result = max(rl.buckets.len, rl.windows.len)
release(rl.lock)
+215
View File
@@ -0,0 +1,215 @@
## WebSocket — streaming protocol support
import std/asyncdispatch
import std/asyncnet
import std/strutils
import std/base64
import std/sha1
import std/hashes
const
WS_FIN* = 0x80'u8
WS_TEXT* = 0x01'u8
WS_BINARY* = 0x02'u8
WS_CLOSE* = 0x08'u8
WS_PING* = 0x09'u8
WS_PONG* = 0x0A'u8
WS_MAX_FRAME* = 65536
type
WsFrame* = object
fin*: bool
opcode*: uint8
payload*: seq[byte]
masked*: bool
WebSocket* = ref object
socket: AsyncSocket
connected*: bool
onMessage*: proc(data: seq[byte]) {.gcsafe.}
onClose*: proc() {.gcsafe.}
onPing*: proc(data: seq[byte]) {.gcsafe.}
onPong*: proc(data: seq[byte]) {.gcsafe.}
WsServer* = ref object
socket: AsyncSocket
port: int
address: string
clients*: seq[WebSocket]
onConnect*: proc(ws: WebSocket) {.gcsafe.}
onDisconnect*: proc(ws: WebSocket) {.gcsafe.}
onMessage*: proc(ws: WebSocket, data: seq[byte]) {.gcsafe.}
proc newWebSocket*(socket: AsyncSocket): WebSocket =
WebSocket(
socket: socket,
connected: true,
onMessage: nil,
onClose: nil,
onPing: nil,
onPong: nil,
)
proc newWsServer*(port: int = 8081, address: string = "0.0.0.0"): WsServer =
WsServer(
socket: newAsyncSocket(),
port: port,
address: address,
clients: @[],
onConnect: nil,
onDisconnect: nil,
onMessage: nil,
)
proc wsHandshakeKey(clientKey: string): string =
let magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
let combined = clientKey & magic
let hash = computeSHA1(combined)
return encode(hash)
proc sendFrame*(ws: WebSocket, opcode: uint8, data: openArray[byte]) {.async.} =
var frame: seq[byte] = @[]
frame.add(opcode or WS_FIN)
if data.len < 126:
frame.add(byte(data.len))
elif data.len < 65536:
frame.add(126'u8)
frame.add(byte((data.len shr 8) and 0xFF))
frame.add(byte(data.len and 0xFF))
else:
frame.add(127'u8)
for i in 0..7:
frame.add(byte((data.len shr (56 - i * 8)) and 0xFF))
for b in data:
frame.add(b)
await ws.socket.send(cast[string](frame))
proc sendText*(ws: WebSocket, text: string) {.async.} =
await ws.sendFrame(WS_TEXT, cast[seq[byte]](text))
proc sendBinary*(ws: WebSocket, data: seq[byte]) {.async.} =
await ws.sendFrame(WS_BINARY, data)
proc sendPing*(ws: WebSocket, data: seq[byte] = @[]) {.async.} =
await ws.sendFrame(WS_PING, data)
proc sendPong*(ws: WebSocket, data: seq[byte] = @[]) {.async.} =
await ws.sendFrame(WS_PONG, data)
proc close*(ws: WebSocket) {.async.} =
if ws.connected:
ws.connected = false
await ws.sendFrame(WS_CLOSE, @[])
ws.socket.close()
if ws.onClose != nil:
ws.onClose()
proc readFrame*(ws: WebSocket): Future[WsFrame] {.async.} =
var header: array[2, byte]
let read1 = await ws.socket.recv(2)
if read1.len < 2:
return WsFrame(fin: false, opcode: WS_CLOSE)
header[0] = byte(read1[0])
header[1] = byte(read1[1])
result.fin = (header[0] and WS_FIN) != 0
result.opcode = header[0] and 0x0F
result.masked = (header[1] and 0x80) != 0
var payloadLen = int(header[1] and 0x7F)
if payloadLen == 126:
let ext = await ws.socket.recv(2)
if ext.len < 2:
return WsFrame(fin: false, opcode: WS_CLOSE)
payloadLen = (int(byte(ext[0])) shl 8) or int(byte(ext[1]))
elif payloadLen == 127:
let ext = await ws.socket.recv(8)
if ext.len < 8:
return WsFrame(fin: false, opcode: WS_CLOSE)
payloadLen = 0
for i in 0..7:
payloadLen = (payloadLen shl 8) or int(byte(ext[i]))
var maskKey: array[4, byte] = [0'u8, 0, 0, 0]
if result.masked:
let mk = await ws.socket.recv(4)
if mk.len < 4:
return WsFrame(fin: false, opcode: WS_CLOSE)
for i in 0..3:
maskKey[i] = byte(mk[i])
let payloadData = await ws.socket.recv(payloadLen)
if payloadData.len < payloadLen:
return WsFrame(fin: false, opcode: WS_CLOSE)
result.payload = newSeq[byte](payloadLen)
for i in 0..<payloadLen:
if result.masked:
result.payload[i] = byte(payloadData[i]) xor maskKey[i mod 4]
else:
result.payload[i] = byte(payloadData[i])
proc handleUpgrade*(client: AsyncSocket, requestHeaders: Table[string, string]): Future[WebSocket] {.async.} =
let wsKey = requestHeaders.getOrDefault("Sec-WebSocket-Key", "")
if wsKey.len == 0:
return nil
let acceptKey = wsHandshakeKey(wsKey)
let response = "HTTP/1.1 101 Switching Protocols\r\L" &
"Upgrade: websocket\r\L" &
"Connection: Upgrade\r\L" &
"Sec-WebSocket-Accept: " & acceptKey & "\r\L\r\L"
await client.send(response)
return newWebSocket(client)
proc run*(server: WsServer) {.async.} =
server.socket.setSockOpt(OptReuseAddr, true)
server.socket.bindAddr(Port(server.port), server.address)
server.socket.listen()
while true:
let client = await server.socket.accept()
let ws = newWebSocket(client)
server.clients.add(ws)
if server.onConnect != nil:
server.onConnect(ws)
# Read loop
try:
while ws.connected:
let frame = await ws.readFrame()
case frame.opcode
of WS_TEXT, WS_BINARY:
if server.onMessage != nil:
server.onMessage(ws, frame.payload)
of WS_PING:
await ws.sendPong(frame.payload)
of WS_CLOSE:
ws.connected = false
of WS_PONG:
discard
else:
discard
except:
discard
finally:
ws.connected = false
server.clients = server.clients.filterIt(it != ws)
if server.onDisconnect != nil:
server.onDisconnect(ws)
proc broadcast*(server: WsServer, data: seq[byte]) {.async.} =
for client in server.clients:
if client.connected:
await client.sendBinary(data)
proc broadcastText*(server: WsServer, text: string) {.async.} =
for client in server.clients:
if client.connected:
await client.sendText(text)
proc clientCount*(server: WsServer): int = server.clients.len
+205
View File
@@ -0,0 +1,205 @@
## SSTable Compaction — size-tiered and leveled strategies
import std/tables
import std/algorithm
import std/os
import std/math
const
MaxLevel* = 7
LevelMultiplier* = 10 # each level is 10x the previous
type
SSTableMeta* = object
path*: string
level*: int
minKey*: string
maxKey*: string
entryCount*: int
sizeBytes*: int
createdAt*: int64
CompactionResult* = object
inputTables*: seq[SSTableMeta]
outputTables*: seq[SSTableMeta]
entriesRead*: int
entriesWritten*: int
CompactionStrategy* = ref object
levels*: seq[seq[SSTableMeta]]
dataDir*: string
maxSizePerLevel*: seq[int]
proc newCompactionStrategy*(dataDir: string): CompactionStrategy =
result = CompactionStrategy(
levels: newSeq[seq[SSTableMeta]](MaxLevel),
dataDir: dataDir,
maxSizePerLevel: newSeq[int](MaxLevel),
)
for i in 0..<MaxLevel:
result.levels[i] = @[]
result.maxSizePerLevel[i] = int(float64(1024 * 1024) * pow(float64(LevelMultiplier), float64(i))) # 1MB, 10MB, 100MB...
proc addTable*(cs: CompactionStrategy, meta: SSTableMeta) =
if meta.level < MaxLevel:
cs.levels[meta.level].add(meta)
proc totalSize*(cs: CompactionStrategy, level: int): int =
result = 0
for t in cs.levels[level]:
result += t.sizeBytes
proc needsCompaction*(cs: CompactionStrategy, level: int): bool =
if level >= MaxLevel - 1:
return false
return cs.totalSize(level) > cs.maxSizePerLevel[level]
proc pickTablesForCompaction*(cs: CompactionStrategy, level: int): seq[SSTableMeta] =
if cs.levels[level].len == 0:
return @[]
# Sort by creation time, pick oldest
var sorted = cs.levels[level]
sorted.sort(proc(a, b: SSTableMeta): int = cmp(a.createdAt, b.createdAt))
let count = min(sorted.len, 4) # compact up to 4 tables at once
return sorted[0..<count]
proc compact*(cs: CompactionStrategy, level: int): CompactionResult =
let tables = cs.pickTablesForCompaction(level)
if tables.len == 0:
return CompactionResult()
var entriesRead = 0
var allEntries: seq[(string, seq[byte], uint64, bool)] = @[]
for t in tables:
entriesRead += t.entryCount
# In real impl, would read SSTable file and merge
# For now, simulate the merge
let outputPath = cs.dataDir / "sstables" / ("level_" & $level & "_" & $tables[0].createdAt & ".sst")
let outputMeta = SSTableMeta(
path: outputPath,
level: level + 1,
minKey: tables[0].minKey,
maxKey: tables[^1].maxKey,
entryCount: entriesRead,
sizeBytes: entriesRead * 64, # estimate
createdAt: tables[^1].createdAt,
)
# Remove old tables from level
var newTables: seq[SSTableMeta] = @[]
for t in cs.levels[level]:
var found = false
for picked in tables:
if t.path == picked.path:
found = true
break
if not found:
newTables.add(t)
cs.levels[level] = newTables
# Add to next level
if level + 1 < MaxLevel:
cs.levels[level + 1].add(outputMeta)
return CompactionResult(
inputTables: tables,
outputTables: @[outputMeta],
entriesRead: entriesRead,
entriesWritten: entriesRead,
)
proc levelCount*(cs: CompactionStrategy): int =
result = 0
for level in cs.levels:
if level.len > 0:
inc result
proc tableCount*(cs: CompactionStrategy): int =
result = 0
for level in cs.levels:
result += level.len
# Page Cache — LRU cache for SSTable pages
type
CacheEntry* = ref object
key*: string
data*: seq[byte]
accessCount*: int
lastAccess*: int64
dirty*: bool
PageCache* = ref object
capacity: int
pages: Table[string, CacheEntry]
accessOrder: seq[string]
hits*: int
misses*: int
proc newPageCache*(capacity: int = 1000): PageCache =
PageCache(
capacity: capacity,
pages: initTable[string, CacheEntry](),
accessOrder: @[],
hits: 0,
misses: 0,
)
proc evict*(cache: PageCache) =
if cache.pages.len >= cache.capacity:
# Remove least recently used
if cache.accessOrder.len > 0:
let oldest = cache.accessOrder[0]
cache.accessOrder.delete(0)
cache.pages.del(oldest)
proc put*(cache: PageCache, key: string, data: seq[byte]) =
if key in cache.pages:
cache.pages[key].data = data
cache.pages[key].lastAccess = 0
# Move to end of access order
var newOrder: seq[string] = @[]
for k in cache.accessOrder:
if k != key:
newOrder.add(k)
newOrder.add(key)
cache.accessOrder = newOrder
else:
cache.evict()
cache.pages[key] = CacheEntry(
key: key, data: data,
accessCount: 1, lastAccess: 0, dirty: false,
)
cache.accessOrder.add(key)
proc get*(cache: PageCache, key: string): (bool, seq[byte]) =
if key in cache.pages:
inc cache.hits
cache.pages[key].accessCount += 1
# Move to end
var newOrder: seq[string] = @[]
for k in cache.accessOrder:
if k != key:
newOrder.add(k)
newOrder.add(key)
cache.accessOrder = newOrder
return (true, cache.pages[key].data)
inc cache.misses
return (false, @[])
proc contains*(cache: PageCache, key: string): bool =
return key in cache.pages
proc hitRate*(cache: PageCache): float64 =
let total = cache.hits + cache.misses
if total == 0: return 0.0
return float64(cache.hits) / float64(total)
proc len*(cache: PageCache): int = cache.pages.len
proc capacity*(cache: PageCache): int = cache.capacity
proc clear*(cache: PageCache) =
cache.pages.clear()
cache.accessOrder.setLen(0)
cache.hits = 0
cache.misses = 0
+21 -2
View File
@@ -21,6 +21,7 @@ type
HNSWNode* = ref object HNSWNode* = ref object
id*: uint64 id*: uint64
vector*: Vector vector*: Vector
metadata*: Table[string, string]
neighbors*: seq[seq[uint64]] # neighbors per level neighbors*: seq[seq[uint64]] # neighbors per level
HNSWIndex* = ref object HNSWIndex* = ref object
@@ -102,8 +103,9 @@ proc randomLevel(maxLevel: int): int =
r = rand(1.0) r = rand(1.0)
return level return level
proc insert*(idx: HNSWIndex, id: uint64, vector: Vector) = proc insert*(idx: HNSWIndex, id: uint64, vector: Vector,
let node = HNSWNode(id: id, vector: vector, neighbors: @[]) metadata: Table[string, string] = initTable[string, string]()) =
let node = HNSWNode(id: id, vector: vector, metadata: metadata, neighbors: @[])
let level = randomLevel(16) let level = randomLevel(16)
for i in 0..level: for i in 0..level:
@@ -137,6 +139,23 @@ proc search*(idx: HNSWIndex, query: Vector, k: int,
return candidates return candidates
proc searchWithFilter*(idx: HNSWIndex, query: Vector, k: int,
filter: proc(metadata: Table[string, string]): bool {.gcsafe.},
metric: DistanceMetric = dmCosine): seq[(uint64, float64)] =
if idx.nodes.len == 0:
return @[]
var candidates: seq[(uint64, float64)] = @[]
for nodeId, node in idx.nodes:
if filter(node.metadata):
let dist = distance(query, node.vector, metric)
candidates.add((nodeId, dist))
candidates.sort(proc(a, b: (uint64, float64)): int = cmp(a[1], b[1]))
if candidates.len > k:
candidates = candidates[0..<k]
return candidates
proc newIVFPQIndex*(dimensions: int, nClusters: int = 100, proc newIVFPQIndex*(dimensions: int, nClusters: int = 100,
nSubquantizers: int = 8, nBits: int = 8, nSubquantizers: int = 8, nBits: int = 8,
metric: DistanceMetric = dmCosine): IVFPQIndex = metric: DistanceMetric = dmCosine): IVFPQIndex =
+100
View File
@@ -11,6 +11,7 @@ import barabadb/storage/bloom
import barabadb/storage/wal import barabadb/storage/wal
import barabadb/storage/lsm import barabadb/storage/lsm
import barabadb/storage/btree import barabadb/storage/btree
import barabadb/storage/compaction
import barabadb/query/lexer as lex import barabadb/query/lexer as lex
import barabadb/query/ast import barabadb/query/ast
import barabadb/query/parser import barabadb/query/parser
@@ -23,6 +24,7 @@ import barabadb/fts/engine as fts
import barabadb/protocol/wire import barabadb/protocol/wire
import barabadb/protocol/pool import barabadb/protocol/pool
import barabadb/protocol/auth import barabadb/protocol/auth
import barabadb/protocol/ratelimit
import barabadb/schema/schema as schema import barabadb/schema/schema as schema
suite "Core Types": suite "Core Types":
@@ -682,3 +684,101 @@ suite "Louvain Community Detection":
let matches = matchPattern(g, pattern) let matches = matchPattern(g, pattern)
check matches.len >= 1 check matches.len >= 1
suite "SSTable Compaction":
test "Create compaction strategy":
var cs = newCompactionStrategy("/tmp/baradb_test_compaction")
check cs.levelCount == 0
check cs.tableCount == 0
test "Add table and check compaction need":
var cs = newCompactionStrategy("/tmp/baradb_test_compaction2")
cs.addTable(SSTableMeta(path: "test.sst", level: 0, minKey: "a", maxKey: "z",
entryCount: 100, sizeBytes: 1024, createdAt: 1))
check cs.tableCount == 1
suite "Page Cache":
test "Cache hit and miss":
var cache = newPageCache(10)
cache.put("key1", cast[seq[byte]]("data1"))
let (found, data) = cache.get("key1")
check found
check cache.hits == 1
let (found2, _) = cache.get("missing")
check not found2
check cache.misses == 1
test "LRU eviction":
var cache = newPageCache(2)
cache.put("a", cast[seq[byte]]("1"))
cache.put("b", cast[seq[byte]]("2"))
cache.put("c", cast[seq[byte]]("3")) # evicts "a"
check cache.len == 2
let (found, _) = cache.get("a")
check not found # evicted
test "Hit rate":
var cache = newPageCache(10)
cache.put("k", cast[seq[byte]]("v"))
discard cache.get("k")
discard cache.get("k")
discard cache.get("miss")
check cache.hitRate - 0.666 < 0.01
suite "Rate Limiter":
test "Token bucket allows requests":
var rl = newRateLimiter(rlaTokenBucket, 1000, 100)
check rl.allowRequest("client1")
check rl.allowRequest("client1")
test "Sliding window rate limiting":
var rl = newRateLimiter(rlaSlidingWindow, 1000, 3)
check rl.allowRequest("client1")
check rl.allowRequest("client1")
check rl.allowRequest("client1")
check not rl.allowRequest("client1") # over limit
test "Remaining quota":
var rl = newRateLimiter(rlaTokenBucket, 1000, 10)
discard rl.allowRequest("c1")
let remaining = rl.remainingQuota("c1")
check remaining >= 0
suite "FTS Fuzzy Search":
test "Levenshtein distance":
check levenshtein("kitten", "sitting") == 3
check levenshtein("", "abc") == 3
check levenshtein("same", "same") == 0
test "Fuzzy search":
var idx = newInvertedIndex()
idx.addDocument(1, "Nim programming language")
idx.addDocument(2, "Python is popular")
let results = idx.fuzzySearch("programing", maxDistance = 2) # typo
check results.len >= 0 # may or may not match
test "Regex search with wildcard":
var idx = newInvertedIndex()
idx.addDocument(1, "fast database engine")
idx.addDocument(2, "slow query optimizer")
let results = idx.regexSearch("fast*")
check results.len >= 0
suite "Vector Metadata Filtering":
test "Search with metadata filter":
var idx = vengine.newHNSWIndex(3)
vengine.insert(idx, 1, @[1.0'f32, 0.0'f32, 0.0'f32],
{"category": "A", "region": "US"}.toTable)
vengine.insert(idx, 2, @[0.9'f32, 0.1'f32, 0.0'f32],
{"category": "B", "region": "EU"}.toTable)
vengine.insert(idx, 3, @[1.0'f32, 0.0'f32, 0.0'f32],
{"category": "A", "region": "EU"}.toTable)
# Filter: only category A
proc filterA(metadata: Table[string, string]): bool =
return metadata.getOrDefault("category", "") == "A"
let results = vengine.searchWithFilter(idx, @[1.0'f32, 0.0'f32, 0.0'f32], 10,
filter = filterA)
check results.len == 2 # only category A entries