• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

freeeve / tinykvs / 21180415886

20 Jan 2026 05:06PM UTC coverage: 70.81% (-0.1%) from 70.948%
21180415886

push

github

freeeve
fix(compaction): add SSTable reference counting to prevent use-after-close

Add reference counting to SSTables to prevent concurrent readers from
encountering "file already closed" errors during compaction.

Changes:
- Add refs and markedForRemoval fields to SSTable struct
- Add IncRef/DecRef/MarkForRemoval methods for safe lifecycle management
- Update reader.Get to hold refs while accessing SSTables
- Update ScanPrefix/ScanRange scanners to track and release refs
- Replace direct Close+Remove with MarkForRemoval in compaction

Fixes TestConcurrentReadsDuringCompaction race condition.

50 of 53 new or added lines in 3 files covered. (94.34%)

760 existing lines in 12 files now uncovered.

5594 of 7900 relevant lines covered (70.81%)

405174.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

57.79
/cmd/tinykvs/shell.go
1
package main
2

3
import (
4
        "fmt"
5
        "os"
6
        "path/filepath"
7
        "strconv"
8
        "strings"
9
        "time"
10

11
        "github.com/blastrain/vitess-sqlparser/sqlparser"
12
        "github.com/freeeve/tinykvs"
13
        "github.com/peterh/liner"
14
)
15

16
// Shell provides an interactive SQL-like query interface.
17
type Shell struct {
18
        store       *tinykvs.Store
19
        prompt      string
20
        historyFile string
21
        line        *liner.State
22
}
23

24
// NewShell creates a new shell instance.
25
func NewShell(store *tinykvs.Store) *Shell {
205✔
26
        // History file in user's home directory
205✔
27
        historyFile := ""
205✔
28
        if home, err := os.UserHomeDir(); err == nil {
410✔
29
                historyFile = filepath.Join(home, ".tinykvs_history")
205✔
30
        }
205✔
31

32
        return &Shell{
205✔
33
                store:       store,
205✔
34
                prompt:      "tinykvs> ",
205✔
35
                historyFile: historyFile,
205✔
36
        }
205✔
37
}
38

39
// Run starts the interactive shell.
40
func (s *Shell) Run() {
×
41
        s.line = liner.NewLiner()
×
42
        defer s.line.Close()
×
43

×
44
        s.line.SetCtrlCAborts(true)
×
45

×
46
        // Load history
×
47
        if s.historyFile != "" {
×
48
                if f, err := os.Open(s.historyFile); err == nil {
×
49
                        s.line.ReadHistory(f)
×
50
                        f.Close()
×
51
                }
×
52
        }
53

54
        fmt.Println("TinyKVS Shell " + versionString())
×
55
        fmt.Println("Type \\help for help, \\q to quit")
×
56
        fmt.Println()
×
57

×
58
        for {
×
59
                input, err := s.line.Prompt(s.prompt)
×
60
                if err != nil {
×
61
                        if err == liner.ErrPromptAborted {
×
62
                                fmt.Println("^C")
×
63
                                continue
×
64
                        }
65
                        // Ctrl+D (EOF) or other error - exit gracefully
66
                        fmt.Println()
×
67
                        break
×
68
                }
69

70
                input = strings.TrimSpace(input)
×
71
                if input == "" {
×
72
                        continue
×
73
                }
74

75
                // Add to history
76
                s.line.AppendHistory(input)
×
77

×
78
                if !s.execute(input) {
×
79
                        break
×
80
                }
81
        }
82

83
        // Save history
84
        if s.historyFile != "" {
×
85
                if f, err := os.Create(s.historyFile); err == nil {
×
86
                        s.line.WriteHistory(f)
×
87
                        f.Close()
×
88
                }
×
89
        }
90
}
91

92
// execute runs a command. Returns false to exit.
93
func (s *Shell) execute(line string) bool {
479✔
94
        // Handle shell commands
479✔
95
        if strings.HasPrefix(line, "\\") {
535✔
96
                return s.handleCommand(line)
56✔
97
        }
56✔
98

99
        // Remove trailing semicolon if present
100
        line = strings.TrimSuffix(line, ";")
423✔
101

423✔
102
        // Pre-process SQL functions (uint64_be, byte, fnv64, etc.) and concatenation
423✔
103
        line = preprocessFunctions(line)
423✔
104

423✔
105
        // Pre-process: convert "STARTS WITH x'...'" to "LIKE '$$HEX$$...%'"
423✔
106
        // and "STARTS WITH '...'" to "LIKE '...%'"
423✔
107
        line = preprocessStartsWith(line)
423✔
108

423✔
109
        // Extract ORDER BY before SQL parsing (parser doesn't support ORDER BY for kv)
423✔
110
        line, orderBy := ParseOrderBy(line)
423✔
111

423✔
112
        // Parse SQL
423✔
113
        stmt, err := sqlparser.Parse(line)
423✔
114
        if err != nil {
427✔
115
                fmt.Printf("Parse error: %v\n", err)
4✔
116
                return true
4✔
117
        }
4✔
118

119
        switch st := stmt.(type) {
419✔
120
        case *sqlparser.Select:
132✔
121
                s.handleSelect(st, orderBy)
132✔
122
        case *sqlparser.Insert:
268✔
123
                s.handleInsert(st)
268✔
124
        case *sqlparser.Update:
6✔
125
                s.handleUpdate(st)
6✔
126
        case *sqlparser.Delete:
13✔
127
                s.handleDelete(st)
13✔
128
        default:
×
129
                fmt.Printf("Unsupported statement type: %T\n", stmt)
×
130
        }
131

132
        return true
419✔
133
}
134

135
func (s *Shell) handleCommand(cmd string) bool {
56✔
136
        parts := strings.Fields(cmd)
56✔
137
        if len(parts) == 0 {
56✔
138
                return true
×
139
        }
×
140

141
        switch parts[0] {
56✔
142
        case "\\q", "\\quit", "\\exit":
6✔
143
                fmt.Println("Bye")
6✔
144
                return false
6✔
145
        case "\\help", "\\h", "\\?":
3✔
146
                s.printHelp()
3✔
147
        case "\\stats":
3✔
148
                s.printStats()
3✔
149
        case "\\compact":
3✔
150
                fmt.Println("Compacting...")
3✔
151
                if err := s.store.Compact(); err != nil {
4✔
152
                        fmt.Printf("Error: %v\n", err)
1✔
153
                } else {
3✔
154
                        fmt.Println("Done")
2✔
155
                }
2✔
156
        case "\\flush":
8✔
157
                if err := s.store.Flush(); err != nil {
9✔
158
                        fmt.Printf("Error: %v\n", err)
1✔
159
                } else {
8✔
160
                        fmt.Println("Flushed")
7✔
161
                }
7✔
162
        case "\\tables":
2✔
163
                fmt.Println("Table: kv (k TEXT, v TEXT)")
2✔
164
                fmt.Println("  - k: the key (string or hex with x'...')")
2✔
165
                fmt.Println("  - v: the value")
2✔
UNCOV
166
        case "\\explain":
×
UNCOV
167
                if len(parts) < 2 {
×
UNCOV
168
                        fmt.Println("Usage: \\explain <prefix>")
×
UNCOV
169
                        fmt.Println("  Shows which SSTables contain keys with the given prefix")
×
UNCOV
170
                        return true
×
UNCOV
171
                }
×
UNCOV
172
                s.explainPrefix(parts[1])
×
173
        case "\\export":
11✔
174
                if len(parts) < 2 {
12✔
175
                        fmt.Println("Usage: \\export <filename.csv>")
1✔
176
                        return true
1✔
177
                }
1✔
178
                s.exportCSV(parts[1])
10✔
179
        case "\\import":
18✔
180
                if len(parts) < 2 {
19✔
181
                        fmt.Println("Usage: \\import <filename.csv>")
1✔
182
                        return true
1✔
183
                }
1✔
184
                s.importCSV(parts[1])
17✔
185
        default:
2✔
186
                fmt.Printf("Unknown command: %s\n", parts[0])
2✔
187
                fmt.Println("Type \\help for help")
2✔
188
        }
189
        return true
48✔
190
}
191

192
func (s *Shell) printHelp() {
3✔
193
        fmt.Println(`SQL Commands:
3✔
194
  SELECT * FROM kv WHERE k = 'mykey'
3✔
195
  SELECT * FROM kv WHERE k LIKE 'prefix%'
3✔
196
  SELECT * FROM kv WHERE k BETWEEN 'a' AND 'z' LIMIT 10
3✔
197
  SELECT * FROM kv LIMIT 100
3✔
198
  SELECT v.name, v.age FROM kv WHERE k = 'user:1'    -- record fields
3✔
199
  SELECT v.address.city FROM kv WHERE k = 'user:1'   -- nested fields
3✔
200

3✔
201
  ORDER BY (buffers results for sorting):
3✔
202
  SELECT * FROM kv ORDER BY k DESC LIMIT 10
3✔
203
  SELECT v.name, v.age FROM kv ORDER BY v.age DESC, v.name
3✔
204
  SELECT * FROM kv WHERE k LIKE 'user:%' ORDER BY v.score LIMIT 100
3✔
205

3✔
206
  Aggregations (streaming):
3✔
207
  SELECT count() FROM kv
3✔
208
  SELECT count(), sum(v.age), avg(v.age) FROM kv
3✔
209
  SELECT min(v.score), max(v.score) FROM kv WHERE k LIKE 'user:%'
3✔
210

3✔
211
  INSERT INTO kv (k, v) VALUES ('mykey', 'myvalue')
3✔
212
  INSERT INTO kv VALUES ('mykey', 'myvalue')
3✔
213
  INSERT INTO kv VALUES ('user:1', '{"name":"Alice","age":30}')  -- JSON record
3✔
214
  INSERT INTO kv VALUES ('user:2', x'82a46e616d65a3426f62a361676514')  -- msgpack
3✔
215

3✔
216
  UPDATE kv SET v = 'newvalue' WHERE k = 'mykey'
3✔
217

3✔
218
  DELETE FROM kv WHERE k = 'mykey'
3✔
219
  DELETE FROM kv WHERE k LIKE 'prefix%'
3✔
220

3✔
221
Shell Commands:
3✔
222
  \help, \h, \?      Show this help
3✔
223
  \stats             Show store statistics
3✔
224
  \explain <prefix>  Show which SSTables contain a prefix
3✔
225
  \compact           Run compaction
3✔
226
  \flush             Flush memtable to disk
3✔
227
  \tables            Show table schema
3✔
228
  \export <file>     Export to CSV (key,value format)
3✔
229
  \import <file>     Import from CSV (auto-detects format)
3✔
230
  \q, \quit          Exit shell
3✔
231

3✔
232
CSV Import Formats:
3✔
233
  key,value              2 columns: key + value (auto-detects type)
3✔
234
  key,col1,col2,...      3+ columns: key + fields become a record
3✔
235
  key,name:string,age:int  Type hints: string, int, float, bool, json
3✔
236

3✔
237
Notes:
3✔
238
  - Use single quotes for strings: 'mykey'
3✔
239
  - Use x'...' for hex values: x'deadbeef'
3✔
240
  - JSON strings are auto-detected and stored as records
3✔
241
  - Hex values starting with msgpack map markers are stored as records
3✔
242
  - Access record fields with v.fieldname in SELECT
3✔
243
  - Nested fields: v.a.b (2 levels) or v.` + "`a.b.c`" + ` (deeper)
3✔
244
  - LIKE only supports prefix matching (trailing %)
3✔
245
  - All operations are on the virtual 'kv' table
3✔
246
  - Use up/down arrows to navigate command history`)
3✔
247
}
3✔
248

249
func (s *Shell) printStats() {
3✔
250
        stats := s.store.Stats()
3✔
251

3✔
252
        fmt.Printf("Memtable: %d keys, %s\n", stats.MemtableCount, formatBytes(stats.MemtableSize))
3✔
253
        fmt.Printf("Cache: %d entries, %s (%.1f%% hit rate)\n",
3✔
254
                stats.CacheStats.Entries, formatBytes(stats.CacheStats.Size),
3✔
255
                cacheHitRate(stats.CacheStats))
3✔
256

3✔
257
        var totalKeys uint64
3✔
258
        var totalSize int64
3✔
259
        for _, level := range stats.Levels {
24✔
260
                if level.NumTables > 0 {
22✔
261
                        fmt.Printf("L%d: %d tables, %d keys, %s\n",
1✔
262
                                level.Level, level.NumTables, level.NumKeys, formatBytes(level.Size))
1✔
263
                        totalKeys += level.NumKeys
1✔
264
                        totalSize += level.Size
1✔
265
                }
1✔
266
        }
267
        fmt.Printf("Total: %d keys, %s\n", totalKeys, formatBytes(totalSize))
3✔
268
}
269

UNCOV
270
func (s *Shell) explainPrefix(prefixStr string) {
×
UNCOV
271
        prefix := parseHexPrefix(prefixStr)
×
UNCOV
272

×
273
        tables := s.store.ExplainPrefix(prefix)
×
274

×
UNCOV
275
        if len(tables) == 0 {
×
UNCOV
276
                fmt.Printf("No tables have prefix %x in their key range\n", prefix)
×
UNCOV
277
                return
×
UNCOV
278
        }
×
279

280
        levelTables := groupTablesByLevel(tables)
×
281

×
282
        fmt.Printf("Tables with prefix %x in range:\n", prefix)
×
283
        fmt.Println()
×
284

×
285
        var totalInRange, totalWithMatch int
×
UNCOV
286
        for level := 0; level < 7; level++ {
×
287
                lt := levelTables[level]
×
UNCOV
288
                if len(lt) == 0 {
×
UNCOV
289
                        continue
×
290
                }
291

UNCOV
292
                inRange, withMatch := printLevelDetails(level, lt)
×
UNCOV
293
                totalInRange += inRange
×
UNCOV
294
                totalWithMatch += withMatch
×
295
        }
296

297
        fmt.Println()
×
298
        fmt.Printf("Summary: %d tables in range, %d with actual matches\n", totalInRange, totalWithMatch)
×
299
}
300

301
// parseHexPrefix parses a prefix string, handling hex formats (0x..., x'...').
302
func parseHexPrefix(prefixStr string) []byte {
×
303
        if strings.HasPrefix(prefixStr, "0x") || strings.HasPrefix(prefixStr, "0X") {
×
304
                return parseHexString(prefixStr[2:])
×
305
        }
×
UNCOV
306
        if strings.HasPrefix(prefixStr, "x'") && strings.HasSuffix(prefixStr, "'") {
×
UNCOV
307
                return parseHexString(prefixStr[2 : len(prefixStr)-1])
×
UNCOV
308
        }
×
UNCOV
309
        return []byte(prefixStr)
×
310
}
311

312
// parseHexString converts a hex string to bytes.
UNCOV
313
func parseHexString(hexStr string) []byte {
×
UNCOV
314
        prefix := make([]byte, len(hexStr)/2)
×
UNCOV
315
        for i := 0; i < len(prefix); i++ {
×
UNCOV
316
                fmt.Sscanf(hexStr[i*2:i*2+2], "%02x", &prefix[i])
×
UNCOV
317
        }
×
UNCOV
318
        return prefix
×
319
}
320

321
// groupTablesByLevel organizes tables into a map by their level.
UNCOV
322
func groupTablesByLevel(tables []tinykvs.PrefixTableInfo) map[int][]tinykvs.PrefixTableInfo {
×
UNCOV
323
        levelTables := make(map[int][]tinykvs.PrefixTableInfo)
×
UNCOV
324
        for _, t := range tables {
×
UNCOV
325
                levelTables[t.Level] = append(levelTables[t.Level], t)
×
UNCOV
326
        }
×
UNCOV
327
        return levelTables
×
328
}
329

330
// printLevelDetails prints information about tables at a given level and returns counts.
UNCOV
331
func printLevelDetails(level int, tables []tinykvs.PrefixTableInfo) (inRange, withMatch int) {
×
UNCOV
332
        matchCount := 0
×
UNCOV
333
        for _, t := range tables {
×
UNCOV
334
                if t.HasMatch {
×
UNCOV
335
                        matchCount++
×
UNCOV
336
                }
×
337
        }
338

UNCOV
339
        fmt.Printf("L%d: %d tables in range, %d with matching keys\n", level, len(tables), matchCount)
×
UNCOV
340

×
UNCOV
341
        shown := 0
×
UNCOV
342
        for _, t := range tables {
×
UNCOV
343
                if t.HasMatch && shown < 10 {
×
UNCOV
344
                        fmt.Printf("  [%d] minKey=%x maxKey=%x firstMatch=%x (%d keys)\n",
×
UNCOV
345
                                t.TableID, t.MinKey, t.MaxKey, t.FirstMatch, t.NumKeys)
×
UNCOV
346
                        shown++
×
UNCOV
347
                }
×
348
        }
UNCOV
349
        if matchCount > 10 {
×
UNCOV
350
                fmt.Printf("  ... and %d more tables with matches\n", matchCount-10)
×
UNCOV
351
        }
×
352

UNCOV
353
        return len(tables), matchCount
×
354
}
355

356
func cacheHitRate(cs tinykvs.CacheStats) float64 {
5✔
357
        total := cs.Hits + cs.Misses
5✔
358
        if total == 0 {
9✔
359
                return 0
4✔
360
        }
4✔
361
        return float64(cs.Hits) / float64(total) * 100
1✔
362
}
363

364
// formatIntCommas formats an integer with comma separators for readability.
365
func formatIntCommas(n int64) string {
106✔
366
        if n < 0 {
106✔
UNCOV
367
                return "-" + formatIntCommas(-n)
×
UNCOV
368
        }
×
369
        s := strconv.FormatInt(n, 10)
106✔
370
        if len(s) <= 3 {
212✔
371
                return s
106✔
372
        }
106✔
373
        // Insert commas from right to left
UNCOV
374
        var result strings.Builder
×
UNCOV
375
        for i, c := range s {
×
UNCOV
376
                if i > 0 && (len(s)-i)%3 == 0 {
×
UNCOV
377
                        result.WriteByte(',')
×
UNCOV
378
                }
×
UNCOV
379
                result.WriteRune(c)
×
380
        }
UNCOV
381
        return result.String()
×
382
}
383

384
// formatDuration formats a duration in human-readable form (e.g., "3m 7s", "2.3s").
385
func formatDuration(d time.Duration) string {
106✔
386
        if d < time.Second {
212✔
387
                return fmt.Sprintf("%dms", d.Milliseconds())
106✔
388
        }
106✔
UNCOV
389
        if d < time.Minute {
×
UNCOV
390
                return fmt.Sprintf("%.1fs", d.Seconds())
×
UNCOV
391
        }
×
UNCOV
392
        minutes := int(d.Minutes())
×
UNCOV
393
        seconds := int(d.Seconds()) % 60
×
UNCOV
394
        if minutes < 60 {
×
UNCOV
395
                return fmt.Sprintf("%dm %ds", minutes, seconds)
×
UNCOV
396
        }
×
UNCOV
397
        hours := minutes / 60
×
UNCOV
398
        minutes = minutes % 60
×
UNCOV
399
        return fmt.Sprintf("%dh %dm %ds", hours, minutes, seconds)
×
400
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc