Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ad493be
Critical Bugs (#2)
kael-developer[bot] Apr 14, 2026
9622ef8
sharedlib/docliteexport.go — UpdateOneDoc does not update, only delet…
kael-developer[bot] Apr 14, 2026
2555336
doclite/btree.go — diskInitBtree calculates wrong number of children …
kael-developer[bot] Apr 15, 2026
1fbbb3f
Pool-based ID reuse in Insert can cause duplicate root creation or da…
kael-developer[bot] Apr 16, 2026
f5f5bc5
doclite/utils.go — indexOfNodes panics when nodes slice is empty (#10)
kael-developer[bot] Apr 16, 2026
f292e21
doclite/cache.go — DeleteAll iterates one past the valid child rang (…
kael-developer[bot] Apr 16, 2026
85270e2
cutOverflowfile has a race condition with readWriteMutex (#14)
kael-developer[bot] Apr 16, 2026
756d74c
NextObject calls Next() instead of NextObject() recursively (#22)
kael-developer[bot] Apr 16, 2026
fd402ed
(FindNodes pointer aliasing) — Incorrect query results. (#24)
kael-developer[bot] Apr 16, 2026
cbec6ae
Resource exhaustion protection. (#26)
kael-developer[bot] Apr 17, 2026
3d7d51d
prevent nil pointer panic and index out of range in TestFile (#30)
kael-developer[bot] Apr 17, 2026
a34b859
Fix getMeta ignoring read errors (#39)
kael-developer[bot] Apr 18, 2026
e9c607d
Fix DeleteOne not committing changes (#37)
kael-developer[bot] Apr 18, 2026
d80f610
Fix Save() missing bringBackOverflow() call (#38)
kael-developer[bot] Apr 18, 2026
8e7a846
Fix DeleteAll not committing changes (#45)
kael-developer[bot] Apr 19, 2026
cc6c785
Fix toMap dropping float/uint zero-valued filter fields (#47)
kael-developer[bot] Apr 19, 2026
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
265 changes: 265 additions & 0 deletions Bugs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
# Doclite — Critical Bug Report

> Generated by reviewing all Go source files in `doclite/`, `doclite.go`, and `sharedlib/docliteexport.go`.

---

## Executive Summary

This report documents **13 bugs** discovered across the doclite embedded document database codebase. The bugs span data integrity issues, concurrency hazards, missing error handling, and API inconsistencies.

| Severity | Count | Key Impact |
|----------|-------|------------|
| **CRITICAL** | 3 | Silent data loss, incorrect API behavior, unbounded resource usage |
| **HIGH** | 5 | Panics, data corruption on reload, race conditions |
| **MEDIUM** | 3 | Silent failures, incomplete persistence, incorrect query results |
| **LOW** | 2 | Deprecated API usage, inconsistent API behavior |

**Most urgent fix:** Bug #1 (`UpdateOneDoc` in `sharedlib/docliteexport.go`) — any caller using the shared library's update function is silently deleting documents instead of updating them.

---

## Recommended Fix Priority

Given the interdependencies between bugs, the following order is recommended for fixes:

1. **Bug #1** (`UpdateOneDoc`) — Data loss; fix immediately as any shared-library consumer is affected.
2. **Bug #6** (`diskInitBtree`) — Data corruption on reload; affects data persistence reliability.
3. **Bug #7** (Pool ID reuse) — Data loss on insert; compounding issue with #6.
4. **Bug #4** (`indexOfNodes` panic) — Runtime crash; easy one-line guard clause.
5. **Bug #5** (`DeleteAll` range) — Data corruption; off-by-one on deletion boundary.
6. **Bug #8** (`cutOverflowfile` race) — Data corruption under concurrency.
7. **Bug #2** (`NextObject` recursion) — Incorrect query results for cursor users.
8. **Bug #11** (`FindNodes` pointer aliasing) — Incorrect query results.
9. **Bug #3** (`BtreeMaxSize`) — Resource exhaustion protection.
10. **Bug #9** (`getMeta` error) — Silent failure on corrupted files.
11. **Bug #10** (`Save` vs `Close`) — Incomplete persistence.
12. **Bug #13** (`DeleteOne` commit) — API inconsistency.
13. **Bug #12** (deprecated `os.SEEK_SET`) — Code hygiene.

---

## CRITICAL

### 1. `sharedlib/docliteexport.go` — `UpdateOneDoc` does not update, only deletes

- **Severity:** CRITICAL
- **Function:** `UpdateOneDoc(id int64, doc string, name string)`
- **Issue:** The function calls `collection.DeleteOne(id)` and completely ignores the `doc` parameter. It should unmarshal `doc` and call `collection.UpdateOneDoc` (or the underlying tree's `Update`) to persist the new document data. As written, the "update" operation silently deletes the document.
- **Fix:** Unmarshal `doc` into a `map[string]interface{}` and call `collection.UpdateOneDoc(id, document)` instead of `collection.DeleteOne(id)`.

```go
func UpdateOneDoc(id int64, doc string, name string) {
collection := getColFromName(name)
document := make(map[string]interface{})
err := json.Unmarshal([]byte(doc), &document)
if err != nil {
return
}
collection.UpdateOneDoc(id, document)
}
```

### 2. `doclite/cursor.go` — `NextObject` calls `Next()` instead of `NextObject()` recursively

- **Severity:** CRITICAL
- **Function:** `Cursor.NextObject(object interface{}) interface{}`
- **Issue:** When the current cache cursor is exhausted and there are more cache cursors to serve, the method calls `return c.Next()` instead of `return c.NextObject(object)`. This discards the `object` parameter across cache cursor boundaries, returning raw filter matches (maps) instead of unmarshaled struct instances.
- **Fix:** Change `return c.Next()` to `return c.NextObject(object)` on the recursive call.

```go
func (c *Cursor) NextObject(object interface{}) interface{} {
...
if doc == nil {
c.cacheCursors = c.cacheCursors[1:]
if len(c.cacheCursors) > 0 {
c.servingCacheCursor = c.cacheCursors[0]
return c.NextObject(object) // was: return c.Next()
}
}
...
}
```

### 3. `doclite/btree.go` — `BtreeMaxSize` constant defined but never enforced

- **Severity:** CRITICAL
- **Function:** `Btree` struct, constant `BtreeMaxSize = 10000000`
- **Issue:** The constant `BtreeMaxSize` is defined at the package level, suggesting a safety limit on B-tree size. However, it is never checked during `Insert` or `addRoot`. The B-tree can grow unboundedly, potentially causing disk exhaustion or integer overflow in offset calculations.
- **Fix:** Add a check in `Insert` or `addRoot` that returns an error when `NumDocuments` or number of roots approaches `BtreeMaxSize`.

```go
func (t *Btree) addRoot(node *Node) error {
if int(t.NumDocuments) >= BtreeMaxSize {
return errors.New("btree max size exceeded")
}
// ... existing logic
}
```

---

## HIGH

### 4. `doclite/utils.go` — `indexOfNodes` panics when nodes slice is empty

- **Severity:** HIGH
- **Function:** `indexOfNodes(key int64, nodes []*Node, nodesLen int) int`
- **Issue:** If `nodes` is empty and `nodesLen` is 0, the function sets `mid = 0` and then accesses `nodes[mid]` in the second `for` loop (`nodes[mid].document.id`), causing an index-out-of-range panic. This can be triggered via `findFitingNode` when a Btree has no roots.
- **Fix:** Add a guard clause at the top of the function:

```go
func indexOfNodes(key int64, nodes []*Node, nodesLen int) int {
if nodesLen == 0 {
return 0
}
// ... existing logic
}
```

### 5. `doclite/cache.go` — `DeleteAll` iterates one past the valid child range

- **Severity:** HIGH
- **Function:** `Cache.DeleteAll(filter interface{}, doc interface{}) []int64`
- **Issue:** The loop uses `for i := 0; i <= c.node.numChildren` (inclusive bound, starting at 0). When `i == 0`, it fetches `c.node.document.id + 0` which is the root node itself, potentially treating the root as a deletable document. The valid children are at offsets `1..numChildren`.
- **Fix:** Change the loop to start at `i := 1`:

```go
for i := 1; i <= c.node.numChildren; i++ {
n, err := c.get(c.node.document.id + int64(i))
// ...
}
```

### 6. `doclite/btree.go` — `diskInitBtree` calculates wrong number of children for last root when `NumDocuments % MinKeys == 0`

- **Severity:** HIGH
- **Function:** `Btree.diskInitBtree()`
- **Issue:** When `NumDocuments` is exactly divisible by `MinKeys`, the last root's `numChildren` is set to `int(t.NumDocuments % int64(MinKeys))` which equals `0`. However, the last root page should have `MinKeys` children in this case. This causes all documents in the last root page to be invisible on reload.
- **Fix:** Use `MinKeys` when the remainder is 0:

```go
if i+1 == t.NumRoots {
remainder := int(t.NumDocuments % int64(MinKeys))
if remainder == 0 {
node.numChildren = MinKeys
} else {
node.numChildren = remainder
}
}
```

### 7. `doclite/btree.go` — Pool-based ID reuse in `Insert` can cause duplicate root creation or data loss

- **Severity:** HIGH
- **Function:** `Btree.Insert(data []byte) int64`
- **Issue:** When an ID is reused from the pool and `(id-1) % MinKeys == 0`, the code calls `t.Update(id, data)` instead of `t.addRoot(node)`. However, if the original root for that slot was already split/replaced, the target root node may no longer exist, leading to a "not found" error or silent data loss.
- **Fix:** Add explicit error handling and validation when updating a reused root-slot ID. Ensure the target root still exists before calling `Update`, or recreate the root if needed.

```go
if fromPool {
err := t.Update(id, data)
if err != nil {
// Handle missing root — may need to create a new one
return -1
}
} else {
t.addRoot(node)
}
```

### 8. `doclite/files.go` — `cutOverflowfile` has a race condition with `readWriteMutex`

- **Severity:** HIGH
- **Function:** `Cache.cutOverflowfile(start, end int64)`
- **Issue:** The function calls `c.db.overflowfile.Seek(end, os.SEEK_SET)` before acquiring `readWriteMutex`. The subsequent `read()` and `write()` calls pass `lock=false`, so they don't re-acquire the lock. However, the `Seek` before the lock is not atomic with the subsequent reads/writes — another goroutine could seek the file to a different position in between, corrupting the overflow file. Additionally, since `read()` and `write()` use `ReadAt`/`WriteAt` with explicit offsets, the `Seek` call is redundant and potentially harmful.
- **Fix:** Acquire `readWriteMutex` before the `Seek` call, or refactor to remove the `Seek` call entirely since `read()`/`write()` use explicit offsets.

```go
func (c *Cache) cutOverflowfile(start, end int64) {
readWriteMutex.Lock()
defer readWriteMutex.Unlock()
// Remove the Seek call — read()/write() use ReadAt/WriteAt with explicit offsets
buf := make([]byte, 1000)
for {
r, err := read(c.db.overflowfile, end, buf, false)
// ...
}
}
```

---

## MEDIUM

### 9. `doclite/db.go` — `getMeta` ignores read errors

- **Severity:** MEDIUM
- **Function:** `DB.getMeta() *Meta`
- **Issue:** `db.file.Read(buf)` is called without checking its return value or error. A truncated or corrupted file would silently produce partial metadata, leading to undefined behavior throughout the application.
- **Fix:** Check the error return from `Read` and handle it:

```go
func (db *DB) getMeta() *Meta {
buf := make([]byte, metaDataLen)
db.file.Seek(0, os.SEEK_SET)
_, err := db.file.Read(buf)
if err != nil {
fmt.Printf("warning: failed to read metadata: %v\n", err)
}
db.metadata = &Meta{}
bson.Unmarshal(buf[:], db.metadata)
return db.metadata
}
```

### 10. `doclite/db.go` — `Save` method is missing `bringBackOverflow()` call present in `Close`

- **Severity:** MEDIUM
- **Function:** `DB.Save()` and `DB.Close()`
- **Issue:** Both methods contain nearly identical logic for saving the root tree and metadata. However, `Save()` is missing the `bringBackOverflow()` call that `Close()` has. This means data saved mid-session via `Save()` may not include overflow data, violating user expectations — users calling `Save()` expect a complete, consistent snapshot.
- **Fix:** Refactor `Save()` to also call `bringBackOverflow()`, or clearly document that `Save()` is incomplete and `Close()` should be used for a full persistent snapshot.

### 11. `doclite/cache.go` — `FindNodes` returns pointers to the same `object` variable

- **Severity:** MEDIUM
- **Function:** `Cache.FindNodes(filter, object interface{}, start int) ([]interface{}, int)`
- **Issue:** All matching documents are unmarshaled into the same `object` variable, and that same reference is appended to the `nodes` slice. This means the returned slice contains multiple pointers to the same (last unmarshaled) object. Users iterating cursor results will see identical data for every match.
- **Fix:** Create a new instance of the object type for each match using reflection, or return raw byte data (`[]byte`) instead of unmarshaled objects.

```go
// Create a new instance for each match
objType := reflect.TypeOf(object)
if objType.Kind() == reflect.Ptr {
objType = objType.Elem()
}
newObj := reflect.New(objType).Interface()
err = json.Unmarshal(buf, newObj)
// ...
nodes = append(nodes, newObj)
```

---

## LOW

### 12. `doclite/db.go`, `doclite/files.go` — Uses deprecated `os.SEEK_SET` constant

- **Severity:** LOW
- **Files:** `doclite/db.go`, `doclite/files.go`
- **Issue:** `os.SEEK_SET` has been deprecated since Go 1.9 in favor of `io.SeekStart`. While still functional, this generates compiler warnings and may break in future Go versions.
- **Fix:** Replace all occurrences of `os.SEEK_SET` with `io.SeekStart`.

### 13. `doclite.go` — `DeleteOne` does not commit changes

- **Severity:** LOW
- **Function:** `Collection.DeleteOne(id int64)`
- **Issue:** Unlike `Insert` which calls `defer c.Commit()`, `DeleteOne` does not call `Commit()`. Users must manually call `Commit()` after deletion, or changes may be lost if the process exits. This inconsistency is surprising and error-prone.
- **Fix:** Either add `defer c.Commit()` to `DeleteOne` for consistency with `Insert`, or clearly document in the API that `Commit()` must be called manually after delete operations.

```go
func (c *Collection) DeleteOne(id int64) {
c.tree.Delete(id)
c.Commit()
}
```
15 changes: 10 additions & 5 deletions doclite.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ func (c *Collection) GetCol() *doclite.Btree {
}

// Connect returns an instance of Doclite object database
func Connect(filename string) *Doclite {
db := doclite.OpenDB(filename)
return &Doclite{db: db}
func Connect(filename string) (*Doclite, error) {
db, err := doclite.OpenDB(filename)
if err != nil {
return nil, err
}
return &Doclite{db: db}, nil
}

/*
Expand Down Expand Up @@ -84,17 +87,19 @@ func (c *Collection) Insert(doc interface{}) (int64, error) {
return -1, err
}
defer c.Commit()
return c.tree.Insert(buf), nil
return c.tree.Insert(buf)
}

// DeleteOne deletes a document from the database
// When document is deleted a new document take up it space and id
func (c *Collection) DeleteOne(id int64) {
defer c.Commit()
c.tree.Delete(id)
}

// Delete remove all document matching filter from the database
func (c *Collection) Delete(filter, doc interface{}) {
defer c.Commit()
c.tree.DeleteAll(filter, doc)
}

Expand Down Expand Up @@ -157,4 +162,4 @@ func (c *Collection) UpdateOneDoc(id int64, doc interface{}) error {
return err
}
return c.tree.Update(id, buf)
}
}
Loading