feat(db2-delta): keys-resident updates append, indexes follow
- table.c: wo_row_update_field/_slot no longer refuse `resident: keys`, both converge on one new static row_apply_field_keys - borrows (folds), shadow-checks uniqueness, appends the delta with the row's current offset as back-pointer, commits, THEN idx_remove_row + idx_add_row + wo_row_set_offset — failure through commit leaves the row's offset and index untouched - nv decoded to a VM value before touching the materialised copy, since wo_row_release drops every slot through the runtime, not db_val_free - borrow released on every exit, including every failure arm - resident: all path (row_apply_field_slot) byte-for-byte unchanged; wal.c untouched — Tasks 1/2 already expose everything needed - test_wal.c: plain field update read back, and an indexed scalar column updated then found via wo_idx_probe by its new value, gone from its old — both verified failing pre-implementation, passing after - concern: idx_hash/idx_cols_equal/wo_idx_probe cast Text slots to db_text* unconditionally; a keys-resident borrow decodes Text to a VM wo_str* (different layout) — pre-existing, left untouched; tests use a scalar index to sidestep it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 89c56a13ed9f4abd082bfcabb46f46bb53d39faa)
This commit is contained in:
parent
b758f2978d
commit
7cb4bcf0c3
2 changed files with 262 additions and 17 deletions
|
|
@ -988,20 +988,34 @@ static int row_apply_field_slot(wo_db *db, db_table *t, const wo_classdesc *c,
|
|||
db_row *r, uint32_t class_id, uint64_t id,
|
||||
uint32_t field, uint64_t nv, const char **msg,
|
||||
int *err_kind);
|
||||
static int row_apply_field_keys(wo_db *db, uint32_t class_id, uint64_t id,
|
||||
uint32_t field, uint64_t nv, const char **msg,
|
||||
int *err_kind);
|
||||
|
||||
int wo_row_update_field(wo_db *db, uint32_t class_id, uint64_t id, uint32_t field,
|
||||
uint64_t vm_val, const char **msg, int *err_kind) {
|
||||
if (err_kind) *err_kind = DB_ERR_MISC;
|
||||
/* databasev2 2 (5d): a keys-resident row lives in the LOG, so there is no
|
||||
* slab slot to mutate — writing into the borrow's scratch would discard
|
||||
* the update silently, which is the one failure mode this iteration must
|
||||
* not ship. Updating such a row means read-modify-APPEND (a new record,
|
||||
* then re-point the offset), and that is not built yet. Refuse loudly.
|
||||
* The loader refuses `resident: keys` outright, so this is defence in
|
||||
* depth and a marker for the next implementer. */
|
||||
/* databasev2 3 (keys-resident delta updates): a keys-resident row lives
|
||||
* in the LOG, so there is no slab slot to mutate — row_apply_field_keys
|
||||
* does read-modify-APPEND instead of a slot swap. wo_row_offset1 is the
|
||||
* cheap existence check wo_row_ptr would otherwise give us. */
|
||||
if (wo_table_is_keys_resident(db, class_id)) {
|
||||
*msg = "update on a `resident: keys` table is not implemented";
|
||||
return -1;
|
||||
if (!wo_row_offset1(db, class_id, id)) {
|
||||
*msg = "no such row";
|
||||
return -1;
|
||||
}
|
||||
const wo_classdesc *kc = &db->classes[class_id];
|
||||
if (field >= kc->field_cnt) {
|
||||
*msg = "no such field";
|
||||
return -1;
|
||||
}
|
||||
int kok = 1;
|
||||
uint64_t knv = db_val_encode(db->classes, kc->kinds[field], vm_val, &kok, msg);
|
||||
if (!kok) {
|
||||
if (err_kind) *err_kind = DB_ERR_BADKIND;
|
||||
return -1;
|
||||
}
|
||||
return row_apply_field_keys(db, class_id, id, field, knv, msg, err_kind);
|
||||
}
|
||||
db_row *r = wo_row_ptr(db, class_id, id);
|
||||
if (!r) {
|
||||
|
|
@ -1100,17 +1114,117 @@ static int row_apply_field_slot(wo_db *db, db_table *t, const wo_classdesc *c,
|
|||
return 0;
|
||||
}
|
||||
|
||||
/* keys-resident counterpart of row_apply_field_slot. There is no slab slot
|
||||
* to swap — the row lives in the log — so the shape is read-modify-APPEND:
|
||||
* borrow (folds), append a delta with the row's current offset as the
|
||||
* back-pointer, commit, THEN move the id map to the new record. [nv] is
|
||||
* already engine-encoded (same convention as row_apply_field_slot);
|
||||
* consumed on every path.
|
||||
*
|
||||
* The borrow's materialised row holds VM values (wo_row_release drops every
|
||||
* slot through the runtime), so [nv] is decoded to a VM value up front and
|
||||
* that is what ever lands in r->slots[field] — the engine encoding is used
|
||||
* only for the WAL record and freed once logged.
|
||||
*
|
||||
* Ordering: the unique shadow-check (against a shadow of the row, mirroring
|
||||
* row_apply_field_slot's promise that a rejected update leaves the row
|
||||
* untouched) and the WAL append+commit both happen BEFORE either the index
|
||||
* or the id map move — so a failure at any point up to and including the
|
||||
* commit leaves the live row (offset AND index) exactly as it was. Only a
|
||||
* successful, durable commit is followed by the index swap and the offset
|
||||
* repoint, which — being pure RAM bookkeeping a replay rebuilds from the log
|
||||
* regardless — cannot itself meaningfully "fail" once reached. */
|
||||
static int row_apply_field_keys(wo_db *db, uint32_t class_id, uint64_t id,
|
||||
uint32_t field, uint64_t nv, const char **msg,
|
||||
int *err_kind) {
|
||||
const wo_classdesc *c = &db->classes[class_id];
|
||||
db_table *t = &db->tables[class_id];
|
||||
const char *bmsg = "no such row";
|
||||
db_row *r = wo_row_borrow(db, class_id, id, &bmsg);
|
||||
if (!r) {
|
||||
db_val_free(c->kinds[field], nv);
|
||||
*msg = bmsg;
|
||||
return -1;
|
||||
}
|
||||
/* successful borrow proves db->rt and db->rt->wal are both set */
|
||||
uint64_t back_off = wo_row_offset1(db, class_id, id) - 1;
|
||||
|
||||
int ok = 1;
|
||||
uint64_t nv_vm = wo_val_decode_vm(db, db->rt, c->kinds[field], nv, &ok, msg);
|
||||
if (!ok) {
|
||||
wo_row_release(db, class_id, r);
|
||||
db_val_free(c->kinds[field], nv);
|
||||
if (err_kind) *err_kind = DB_ERR_OOM;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* unique shadow-check: run with the NEW value before anything durable or
|
||||
indexed moves, exactly row_apply_field_slot's promise */
|
||||
uint64_t old_vm = r->slots[field];
|
||||
r->slots[field] = nv_vm;
|
||||
for (uint32_t x = 0; x < t->index_cnt; x++) {
|
||||
db_index *ix = &t->indexes[x];
|
||||
if (!(ix->flags & 1u)) continue;
|
||||
int touches = 0;
|
||||
for (uint32_t i = 0; i < ix->col_cnt; i++)
|
||||
if (ix->cols[i] == field) touches = 1;
|
||||
if (!touches) continue;
|
||||
db_ibucket *b = idx_bucket(ix, idx_hash(c, ix, r), 0);
|
||||
if (!b) continue;
|
||||
for (uint32_t i = 0; i < b->len; i++) {
|
||||
if (b->ids[i] == id) continue;
|
||||
const char *obmsg = "";
|
||||
db_row *other = wo_row_borrow(db, class_id, b->ids[i], &obmsg);
|
||||
int clash = other && idx_cols_equal(c, ix, r, other);
|
||||
wo_row_release(db, class_id, other);
|
||||
if (clash) {
|
||||
r->slots[field] = old_vm; /* untouched, promised */
|
||||
wo_row_release(db, class_id, r);
|
||||
wo_drop_kind(db->rt, c->kinds[field], nv_vm);
|
||||
db_val_free(c->kinds[field], nv);
|
||||
if (err_kind) *err_kind = DB_ERR_UNIQUE;
|
||||
*msg = "unique index violation";
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
r->slots[field] = old_vm; /* restored: still the OLD row until committed */
|
||||
|
||||
wo_wal *w = (wo_wal *)db->rt->wal;
|
||||
uint64_t new_off = wo_wal_next_offset(w);
|
||||
if (wo_wal_append_delta(w, db, class_id, id, field, back_off, nv) != 0) {
|
||||
wo_row_release(db, class_id, r);
|
||||
wo_drop_kind(db->rt, c->kinds[field], nv_vm);
|
||||
db_val_free(c->kinds[field], nv);
|
||||
if (err_kind) *err_kind = DB_ERR_OOM;
|
||||
*msg = "out of memory appending delta";
|
||||
return -1;
|
||||
}
|
||||
if (wo_wal_commit(w) != 0) {
|
||||
wo_row_release(db, class_id, r);
|
||||
wo_drop_kind(db->rt, c->kinds[field], nv_vm);
|
||||
db_val_free(c->kinds[field], nv);
|
||||
*msg = "wal commit failed";
|
||||
return -1;
|
||||
}
|
||||
db_val_free(c->kinds[field], nv); /* durable now; the engine copy served the log */
|
||||
|
||||
/* commit: the borrowed row is the OLD row — out of every index under the
|
||||
OLD value, then in again under the NEW one — then the id map moves */
|
||||
idx_remove_row(db, t, r);
|
||||
r->slots[field] = nv_vm;
|
||||
(void)idx_add_row(db, t, r); /* cannot violate uniqueness: the shadow
|
||||
check above already cleared it */
|
||||
wo_row_set_offset(db, class_id, id, new_off);
|
||||
wo_drop_kind(db->rt, c->kinds[field], old_vm);
|
||||
wo_row_release(db, class_id, r);
|
||||
if (err_kind) *err_kind = DB_ERR_NONE;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int wo_row_update_field_slot(wo_db *db, uint32_t class_id, uint64_t id, uint32_t field,
|
||||
uint64_t slot, const char **msg, int *err_kind) {
|
||||
if (err_kind) *err_kind = DB_ERR_MISC;
|
||||
/* databasev2 2 (5d): same reason as wo_row_update_field — a keys-resident
|
||||
* row has no slab slot to mutate, and writing into the borrow's scratch
|
||||
* would discard the update silently. Read-modify-APPEND is the shape that
|
||||
* works, and it is not built yet. */
|
||||
if (wo_table_is_keys_resident(db, class_id)) {
|
||||
*msg = "update on a `resident: keys` table is not implemented";
|
||||
return -1;
|
||||
}
|
||||
/* bounds first: the RPC requester validated cid/field to encode at all,
|
||||
so these are defensive; the slot's kind is unknowable on a class
|
||||
violation and the value leaks rather than dies by the wrong kind */
|
||||
|
|
@ -1123,6 +1237,10 @@ int wo_row_update_field_slot(wo_db *db, uint32_t class_id, uint64_t id, uint32_t
|
|||
*msg = "no such field";
|
||||
return -1;
|
||||
}
|
||||
/* databasev2 3 (keys-resident delta updates): a keys-resident row has no
|
||||
* slab slot to mutate — row_apply_field_keys does read-modify-APPEND. */
|
||||
if (wo_table_is_keys_resident(db, class_id))
|
||||
return row_apply_field_keys(db, class_id, id, field, slot, msg, err_kind);
|
||||
db_row *r = wo_row_ptr(db, class_id, id);
|
||||
if (!r) {
|
||||
db_val_free(c->kinds[field], slot);
|
||||
|
|
|
|||
|
|
@ -856,6 +856,131 @@ static void test_fold_refuses_forward_pointing_delta(void) {
|
|||
wo_rt_destroy(&rt);
|
||||
}
|
||||
|
||||
/* Task 3 (keys-resident delta updates): the plain case, through the real
|
||||
* API — wo_row_update_field, not a hand-rolled append+commit+set_offset like
|
||||
* the fold tests above. Before this task it refused outright with "update on
|
||||
* a `resident: keys` table is not implemented". */
|
||||
static void test_keys_resident_update_field(void) {
|
||||
char path[128];
|
||||
snprintf(path, sizeof path, "%s/keysupd.wal", g_dir);
|
||||
wo_rt rt;
|
||||
T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_CLASSES, 1), 0);
|
||||
wo_db db;
|
||||
T_EQ(wo_db_init(&db, KEYS_CLASSES, 1, 0, 1), 0);
|
||||
wo_wal w;
|
||||
T_EQ(wo_wal_open(&w, path, 1 << 16), 0);
|
||||
db.rt = &rt; rt.wal = &w; rt.db = &db;
|
||||
const char *msg = "";
|
||||
|
||||
wo_str *s = wo_str_new(&rt, "hello", 5);
|
||||
uint64_t vals[2] = {111, (uint64_t)(uintptr_t)s};
|
||||
uint64_t id = wo_row_insert(&db, 0, vals, &msg, NULL);
|
||||
T_CHECK(id != 0);
|
||||
uint64_t off = wo_wal_next_offset(&w);
|
||||
T_EQ(wo_wal_append_insert(&w, &db, 0, id), 0);
|
||||
T_EQ(wo_wal_commit(&w), 0);
|
||||
T_EQ(wo_row_drop_payload(&db, 0, id, off), 0);
|
||||
|
||||
int ek = 0;
|
||||
T_EQ(wo_row_update_field(&db, 0, id, 0, 999, &msg, &ek), 0);
|
||||
T_EQ(ek, DB_ERR_NONE);
|
||||
|
||||
db_row *r = wo_row_borrow(&db, 0, id, &msg);
|
||||
T_CHECK(r != NULL);
|
||||
T_CHECK(r->slots[0] == 999);
|
||||
wo_str *back = (wo_str *)(uintptr_t)r->slots[1];
|
||||
T_CHECK(back != NULL && back->len == 5 && memcmp(back->data, "hello", 5) == 0);
|
||||
wo_row_release(&db, 0, r);
|
||||
|
||||
/* the scratch must be free again — a release that skipped clearing
|
||||
scratch_busy would wedge this second borrow */
|
||||
db_row *again = wo_row_borrow(&db, 0, id, &msg);
|
||||
T_CHECK(again != NULL && again->slots[0] == 999);
|
||||
wo_row_release(&db, 0, again);
|
||||
|
||||
wo_wal_close(&w);
|
||||
wo_db_destroy(&db);
|
||||
wo_rt_destroy(&rt);
|
||||
}
|
||||
|
||||
/* class 0: Row { n: scalar @index(non-unique), label: Text } — a keys-resident
|
||||
* table with a secondary index on the scalar column, dedicated to the test
|
||||
* below. The design deliberately allows a delta to change an indexed column
|
||||
* (a catalogue indexes exactly the columns that change, like `price`), so
|
||||
* this is the realistic case. */
|
||||
static const uint8_t keys_idx_kinds[] = {WO_K_SCALAR, WO_K_TEXT};
|
||||
static const uint32_t keys_idx_meta[] = {0 /*non-unique*/, 1, 0 /*col: n*/};
|
||||
static const wo_classdesc KEYS_IDX_CLASSES[] = {
|
||||
{.name = 0, .flags = WO_CLASSF_RESIDENT_KEYS, .field_cnt = 2, .kinds = keys_idx_kinds,
|
||||
.idx_cnt = 1, .idx_meta = keys_idx_meta},
|
||||
};
|
||||
|
||||
/* Task 3, the test that matters: updating an INDEXED column on a
|
||||
* keys-resident row must move the row in the index too, not just in the
|
||||
* log — queried through wo_idx_probe, the row is found by its NEW value and
|
||||
* gone from its OLD one. */
|
||||
static void test_keys_resident_update_indexed(void) {
|
||||
char path[128];
|
||||
snprintf(path, sizeof path, "%s/keysidx.wal", g_dir);
|
||||
wo_rt rt;
|
||||
T_EQ(wo_rt_init(&rt, 1 << 20, KEYS_IDX_CLASSES, 1), 0);
|
||||
wo_db db;
|
||||
T_EQ(wo_db_init(&db, KEYS_IDX_CLASSES, 1, 0, 1), 0);
|
||||
wo_wal w;
|
||||
T_EQ(wo_wal_open(&w, path, 1 << 16), 0);
|
||||
db.rt = &rt; rt.wal = &w; rt.db = &db;
|
||||
const char *msg = "";
|
||||
|
||||
wo_str *sa = wo_str_new(&rt, "a", 1);
|
||||
wo_str *sb = wo_str_new(&rt, "b", 1);
|
||||
uint64_t va[2] = {100, (uint64_t)(uintptr_t)sa};
|
||||
uint64_t vb[2] = {200, (uint64_t)(uintptr_t)sb};
|
||||
uint64_t a = wo_row_insert(&db, 0, va, &msg, NULL);
|
||||
uint64_t b = wo_row_insert(&db, 0, vb, &msg, NULL);
|
||||
T_CHECK(a != 0 && b != 0);
|
||||
uint64_t off_a = wo_wal_next_offset(&w);
|
||||
T_EQ(wo_wal_append_insert(&w, &db, 0, a), 0);
|
||||
T_EQ(wo_wal_commit(&w), 0);
|
||||
T_EQ(wo_row_drop_payload(&db, 0, a, off_a), 0);
|
||||
uint64_t off_b = wo_wal_next_offset(&w);
|
||||
T_EQ(wo_wal_append_insert(&w, &db, 0, b), 0);
|
||||
T_EQ(wo_wal_commit(&w), 0);
|
||||
T_EQ(wo_row_drop_payload(&db, 0, b, off_b), 0);
|
||||
|
||||
/* before the update: probing 100 finds a */
|
||||
uint64_t *ids;
|
||||
uint32_t cnt;
|
||||
T_EQ(wo_idx_probe(&db, 0, 0, 100, NULL, 0, &ids, &cnt), 1);
|
||||
T_CHECK(cnt == 1 && ids[0] == a);
|
||||
free(ids);
|
||||
|
||||
int ek = 0;
|
||||
T_EQ(wo_row_update_field(&db, 0, a, 0, 150, &msg, &ek), 0);
|
||||
T_EQ(ek, DB_ERR_NONE);
|
||||
|
||||
/* found by the NEW value */
|
||||
T_EQ(wo_idx_probe(&db, 0, 0, 150, NULL, 0, &ids, &cnt), 1);
|
||||
T_CHECK(cnt == 1 && ids[0] == a);
|
||||
free(ids);
|
||||
|
||||
/* gone from the OLD one */
|
||||
T_EQ(wo_idx_probe(&db, 0, 0, 100, NULL, 0, &ids, &cnt), 1);
|
||||
T_CHECK(cnt == 0 && ids == NULL);
|
||||
|
||||
/* b, untouched, still finds by its own value */
|
||||
T_EQ(wo_idx_probe(&db, 0, 0, 200, NULL, 0, &ids, &cnt), 1);
|
||||
T_CHECK(cnt == 1 && ids[0] == b);
|
||||
free(ids);
|
||||
|
||||
db_row *r = wo_row_borrow(&db, 0, a, &msg);
|
||||
T_CHECK(r != NULL && r->slots[0] == 150);
|
||||
wo_row_release(&db, 0, r);
|
||||
|
||||
wo_wal_close(&w);
|
||||
wo_db_destroy(&db);
|
||||
wo_rt_destroy(&rt);
|
||||
}
|
||||
|
||||
/* databasev2 2 (5c): boot. A keys-resident store must come back from replay
|
||||
* with its rows readable FROM THE LOG — the map rebuilt to offsets, not slabs.
|
||||
* This is the half the round-trip test cannot cover: it runs in a fresh db,
|
||||
|
|
@ -1504,6 +1629,8 @@ int main(void) {
|
|||
test_delta_fold_same_field_newest_wins();
|
||||
test_fold_refuses_self_pointing_delta();
|
||||
test_fold_refuses_forward_pointing_delta();
|
||||
test_keys_resident_update_field();
|
||||
test_keys_resident_update_indexed();
|
||||
test_keys_resident_replay();
|
||||
test_keys_resident_survives_compaction();
|
||||
test_keys_resident_delete();
|
||||
|
|
|
|||
Loading…
Reference in a new issue