diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba5f3d..7994fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format of the files on disk (`-mwlock`, `-mwidx`, `-mw.N`, `-mw`) is the sam ## Unreleased +### Fixed +- A commit that an `fsync` already in flight covers is no longer refused when the log is failed meanwhile. The append of one commit failed with a later commit already behind it (the log is failed for good); a commit parked in the sync, whose record the running `fsync` was making durable, saw the failure first and was told that it had failed, and then the `fsync` finished and made it visible: it was refused and in the database (and in the log after a reopen). It now waits for the `fsync` that is running and is acknowledged if that one covers it; the ones that it does not cover are refused and stay invisible, as before. Found as a failure of `mw_rollbackrace` on a runner of two CPUs (2 of 150 runs on a machine of two, with and without the other changes); it is reproduced in every run by `mw_holesync`. + ### Changed - Documentation: the wording of the isolation limits in the README and in the changelog says what is prevented (the classic write skew, by the validation of the reads) and where it is still possible, instead of "write skew is possible". diff --git a/Makefile b/Makefile index ddb48ca..9ce1c3c 100644 --- a/Makefile +++ b/Makefile @@ -63,15 +63,44 @@ $(BUILD)/t_%.o: test/%.c $(HEADERS) test/mw_test.h $(DIST)/mw_%$(EXE): $(BUILD)/t_mw_%.o $(LIB_OBJ) @mkdir -p $(DIST) $(CC) $^ -o $@ $(LDFLAGS) +# Deterministic fault injection, confined to the translation unit under test. +$(BUILD)/inject_log.o: $(SRC_DIR)/multiwriter_log.c $(HEADERS) + @mkdir -p $(BUILD) + $(CC) $(CFLAGS) -Dpwritev=inject_pwritev -c $< -o $@ +$(BUILD)/inject_rowdiff.o: $(SRC_DIR)/multiwriter_rowdiff.c $(HEADERS) + @mkdir -p $(BUILD) + $(CC) $(CFLAGS) -Dmalloc=inject_malloc -Dcalloc=inject_calloc -Drealloc=inject_realloc -c $< -o $@ +$(BUILD)/inject_fsync.o: $(SRC_DIR)/multiwriter_log.c $(HEADERS) + @mkdir -p $(BUILD) + $(CC) $(CFLAGS) -Dfsync=inject_fsync -c $< -o $@ +$(DIST)/mw_holesync$(EXE): $(BUILD)/t_mw_holesync.o $(filter-out $(BUILD)/multiwriter_log.o,$(LIB_OBJ)) $(BUILD)/inject_fsync.o + @mkdir -p $(DIST) + $(CC) $^ -o $@ $(LDFLAGS) +$(DIST)/mw_stagednormal$(EXE): $(BUILD)/t_mw_stagednormal.o $(filter-out $(BUILD)/multiwriter_log.o,$(LIB_OBJ)) $(BUILD)/inject_log.o + @mkdir -p $(DIST) + $(CC) $^ -o $@ $(LDFLAGS) +$(DIST)/mw_rowdiffoom$(EXE): $(BUILD)/t_mw_rowdiffoom.o $(filter-out $(BUILD)/multiwriter_rowdiff.o,$(LIB_OBJ)) $(BUILD)/inject_rowdiff.o + @mkdir -p $(DIST) + $(CC) $^ -o $@ $(LDFLAGS) + .PRECIOUS: $(BUILD)/t_%.o -test: $(FAST_BIN) +# the exit status of a failing test must survive the sanitizer runner (test/sanitize.sh); not for Windows, where the runner is not used +.PHONY: test-sanitize-runner +test-sanitize-runner: +ifeq ($(PLATFORM),windows) + @echo "sanitizer runner regression skipped (Windows)" +else + @if command -v bash >/dev/null 2>&1; then bash test/sanitize_exit.sh; else echo "sanitizer runner regression skipped (bash unavailable)"; fi +endif + +test: $(FAST_BIN) test-sanitize-runner @set -e; for t in $(FAST_BIN); do echo "== $$t"; ./$$t; done; echo "== mw_rebase (a WITHOUT ROWID table)"; MW_TEST_WR=1 ./$(DIST)/mw_rebase$(EXE) # the suite of the CI legs that run it on shared machines: without mw_serial (about a minute and a half of stress, which has a threshold of events that a loaded machine can miss); `make test` runs everything CI_BIN := $(filter-out $(DIST)/mw_serial$(EXE),$(FAST_BIN)) .PHONY: test-ci -test-ci: $(CI_BIN) +test-ci: $(CI_BIN) test-sanitize-runner @set -e; for t in $(CI_BIN); do echo "== $$t"; ./$$t; done; echo "== mw_rebase (a WITHOUT ROWID table)"; MW_TEST_WR=1 ./$(DIST)/mw_rebase$(EXE) # errors of the file system: a fault at every n-th file call of a workload, and a real full disk (a small disk image) @@ -94,9 +123,9 @@ clean: rm -rf $(BUILD) $(DIST) # the transaction tests with the shared multi-process mode (mw_mp=1) in place of the thread mode -MP_TESTS := mw_rebase mw_rebasefk mw_rebasefkstress mw_rebasenoop mw_rebaseskew mw_rebasewr mw_serial mw_relocprep mw_shared +MP_TESTS := mw_strictany mw_rebasepolicy mw_rebase mw_rebasefk mw_rebasefkstress mw_rebasenoop mw_rebaseskew mw_rebasewr mw_serial mw_relocprep mw_shared ifeq ($(PLATFORM),windows) -MP_TESTS := mw_rebase mw_rebasefk mw_rebasefkstress mw_rebasenoop mw_rebaseskew mw_rebasewr mw_serial # (the others fork) +MP_TESTS := mw_strictany mw_rebasepolicy mw_rebase mw_rebasefk mw_rebasefkstress mw_rebasenoop mw_rebaseskew mw_rebasewr mw_serial # (the others fork) endif .PHONY: test-mp test-mp: $(addsuffix $(EXE),$(addprefix $(DIST)/,$(MP_TESTS))) diff --git a/src/multiwriter_catalog.c b/src/multiwriter_catalog.c index 13b3403..a51803f 100644 --- a/src/multiwriter_catalog.c +++ b/src/multiwriter_catalog.c @@ -126,13 +126,14 @@ static void tab_parse (sqlite3 *scratch, const srow *r, mw_tab *t) { t->without_rowid = strcasestr(r->sql, "WITHOUT ROWID") != NULL; // (the text: a default or a comment that says so fools it, and so does a second blank; below, SQLite says it) char *err = NULL; if (sqlite3_exec(scratch, r->sql, NULL, NULL, &err) != SQLITE_OK) { sqlite3_free(err); return; } - { // SQLite 3.37 and later: PRAGMA table_list has a column wr + bool strict = false; + { // SQLite 3.37 and later: PRAGMA table_list has columns wr and strict char *wq = sqlite3_mprintf("PRAGMA table_list(\"%w\")", r->name); sqlite3_stmt *ws = NULL; if (wq && sqlite3_prepare_v2(scratch, wq, -1, &ws, NULL) == SQLITE_OK) { - int wrcol = -1; for (int i = 0; i < sqlite3_column_count(ws); i++) if (!strcmp(sqlite3_column_name(ws, i), "wr")) wrcol = i; + int wrcol = -1, strictcol = -1; for (int i = 0; i < sqlite3_column_count(ws); i++) { if (!strcmp(sqlite3_column_name(ws, i), "wr")) wrcol = i; if (!strcmp(sqlite3_column_name(ws, i), "strict")) strictcol = i; } if (wrcol >= 0) while (sqlite3_step(ws) == SQLITE_ROW) { const char *nm = (const char *)sqlite3_column_text(ws, 1); - if (nm && !strcasecmp(nm, r->name)) { t->without_rowid = sqlite3_column_int(ws, wrcol) != 0; break; } + if (nm && !strcasecmp(nm, r->name)) { t->without_rowid = sqlite3_column_int(ws, wrcol) != 0; strict = strictcol >= 0 && sqlite3_column_int(ws, strictcol) != 0; break; } } } sqlite3_finalize(ws); sqlite3_free(wq); @@ -182,7 +183,7 @@ static void tab_parse (sqlite3 *scratch, const srow *r, mw_tab *t) { int i = order[j]; if (cols[i].hidden == 2) continue; // (generated, virtual: not stored) if (t->alias_pk && cols[i].pk == 1) t->alias_rec = pos; - if (t->noaff) t->noaff[pos] = type_has_no_affinity(cols[i].type); + if (t->noaff) t->noaff[pos] = type_has_no_affinity(cols[i].type) || (strict && !strcasecmp(cols[i].type, "ANY")); t->rec_name[pos++] = (cols[i].hidden == 3) ? NULL : strdup(cols[i].name); // (generated, stored: in the record, derived) } t->nrec = pos; diff --git a/src/multiwriter_catalog.h b/src/multiwriter_catalog.h index 08275a6..cea0134 100644 --- a/src/multiwriter_catalog.h +++ b/src/multiwriter_catalog.h @@ -24,7 +24,7 @@ typedef struct { int nrec; // columns stored in a record int alias_rec; // the record column of the rowid alias (-1: none) char **rec_name; // the name of the column stored in each record column; NULL for a generated one (derived: not written, not compared) - bool *noaff; // of each record column: it has no affinity (declared with no type, or BLOB): 1 and 1.0 are different values there, and an equality of values does not tell them apart + bool *noaff; // of each record column: it has no affinity (declared with no type, BLOB, or ANY of a STRICT table): 1 and 1.0 are different values there, and an equality of values does not tell them apart mw_val *dflt; // the default of each record column (what a row written before an ALTER TABLE ADD COLUMN has in the columns it lacks); NULL: all NULL int nfk; char **fk_parent, **fk_to; // foreign keys of this table: the parent table, and the parent column (NULL: its primary key) bool is_parent; // some foreign key refers to this table diff --git a/src/multiwriter_lane.c b/src/multiwriter_lane.c index cbba879..240615f 100644 --- a/src/multiwriter_lane.c +++ b/src/multiwriter_lane.c @@ -173,7 +173,17 @@ static bool multirow_insert (const char *sql) { } return false; } +static bool has_word (const char *sql, const char *w); static bool stmt_is_point (mw_lane *lane, sqlite3_stmt *st, const char *sql) { + // Row images do not retain a statement's OR policy, nor an UPSERT clause. Replaying it with + // the schema's policy could delete or ignore a concurrent writer's row. + const char *head = mw_sql_skip(sql); + if (sqlite3_strnicmp(head, "INSERT", 6) == 0 || sqlite3_strnicmp(head, "UPDATE", 6) == 0) { + const char *next = mw_sql_skip(head + 6); + if (sqlite3_strnicmp(next, "OR", 2) == 0 && !isalnum((unsigned char)next[2]) && next[2] != '_') return false; + } + // (ON CONFLICT ... DO NOTHING / DO UPDATE: a plain INSERT would take the schema's policy for the constraint instead; a match inside a string only makes a point statement look refused) + if (sqlite3_strnicmp(head, "INSERT", 6) == 0 && has_word(head, "CONFLICT")) return false; rd_ent *c = lane->rd_cache; if (!c) { c = lane->rd_cache = sqlite3_malloc64(RD_CACHE * sizeof *c); if (!c) return false; memset(c, 0, RD_CACHE * sizeof *c); } // The answer depends on the schema (the same text of an UPDATE ... WHERE email = ? is one row while a UNIQUE index exists and a scan of the table after it is dropped): it is kept for the @@ -687,6 +697,9 @@ static int pragma_is (const char *name, const char *want) { return name && sqlit static int lm_file_control (sqlite3_file *pf, int op, void *arg) { mw_lane *lane = FILE_LANE(pf); tls_lane = lane; + // SQLite supplies the owning connection when each b-tree opens, including + // ATTACH. Capture it here so PRAGMA checks do not depend on trace hooks. + if (op == SQLITE_FCNTL_PDB) { lane->rd_db = *(sqlite3 **)arg; return SQLITE_OK; } if (op == MW_FCNTL_TXINFO) { *(mw_tx_info *)arg = lane->tx; return SQLITE_OK; } if (op == MW_FCNTL_WRITESET) { mw_writeset *ws = (mw_writeset *)arg; @@ -719,12 +732,21 @@ static int lm_file_control (sqlite3_file *pf, int op, void *arg) { return SQLITE_ERROR; } if (pragma_is(name, "synchronous") && val) { // observed, not handled: SQLite still executes it - int lv = (int)strtol(val, NULL, 10); - if (!strcmp(val, "0") || !sqlite3_stricmp(val, "off")) lv = 0; - else if (!sqlite3_stricmp(val, "normal")) lv = 1; + // SQLite rejects this PRAGMA even after a BEGIN that has not + // acquired a snapshot yet. Do not change the engine's level. + if (lane->rd_db && !sqlite3_get_autocommit(lane->rd_db)) return SQLITE_NOTFOUND; + // Match SQLite's getSafetyLevel and its pager mask, including + // boolean names, unknown names, and out-of-range integers. + int lv = 1; + if (val[0] >= '0' && val[0] <= '9') { + int base = val[0] == '0' && (val[1] == 'x' || val[1] == 'X') && isxdigit((unsigned char)val[2]) ? 16 : 10; + unsigned long n = strtoul(val, NULL, base); + lv = n > 2147483647UL ? 0 : (int)(n & 255); + } else if (!sqlite3_stricmp(val, "off") || !sqlite3_stricmp(val, "no") || !sqlite3_stricmp(val, "false")) lv = 0; else if (!sqlite3_stricmp(val, "full")) lv = 2; else if (!sqlite3_stricmp(val, "extra")) lv = 3; - lane->sync_level = lv; + lv = (lv + 1) & 7; + lane->sync_level = (lv ? lv : 1) - 1; return SQLITE_NOTFOUND; } if (pragma_is(name, "mmap_size") && val) { az[0] = sqlite3_mprintf("0"); return SQLITE_OK; } // normalised to 0 diff --git a/src/multiwriter_log.c b/src/multiwriter_log.c index b631593..ea25dcc 100644 --- a/src/multiwriter_log.c +++ b/src/multiwriter_log.c @@ -612,6 +612,28 @@ void mw_log_stage_reset (mw_db *db, uint64_t off) { pthread_mutex_unlock(&db->log_mu); } +// NORMAL/OFF still require the record to leave process-private memory +// before acknowledgement. Another writer may be flushing an earlier prefix. +static int stage_wait_written (mw_db *db, uint64_t epoch) { + pthread_mutex_lock(&db->log_mu); + while (db->flushed_epoch < epoch) { + if (atomic_load(&db->failed)) { pthread_mutex_unlock(&db->log_mu); return SQLITE_IOERR_WRITE; } + if (!db->sync_running && !db->stage_writing && + (pipeline_on() || !db->stage_fsyncing) && db->written_end > db->flushed_off) { + pthread_mutex_unlock(&db->log_mu); + int rc = stage_flush(db, true); + if (rc != SQLITE_OK) return rc; + pthread_mutex_lock(&db->log_mu); + } else { + db->sync_waiters++; + pthread_cond_wait(&db->sync_cv, &db->log_mu); + db->sync_waiters--; + } + } + pthread_mutex_unlock(&db->log_mu); + return SQLITE_OK; +} + // Marks a finished record (epoch, ending at `end`) as copied: advances the contiguous prefix. log_mu held. Returns false on NOMEM. static bool mark_written (mw_db *db, uint64_t epoch, uint64_t end) { if (epoch == db->written_upto + 1) { @@ -702,7 +724,7 @@ static int append_staged (mw_db *db, uint64_t off, uint64_t epoch, uint32_t dbsi if (!mark_written(db, epoch, end)) { pthread_mutex_unlock(&db->log_mu); return SQLITE_NOMEM; } if (db->written_end != before && db->sync_waiters > 0 && !db->sync_running && !db->stage_writing) pthread_cond_broadcast(&db->sync_cv); // somebody may be able to write now pthread_mutex_unlock(&db->log_mu); - if (!sync) return stage_flush(db, true); // no durability wait: the record still goes to the file promptly (a killed process keeps it) + if (!sync) return stage_wait_written(db, epoch); // no fsync, but our record must be in the file before returning return SQLITE_OK; } @@ -717,7 +739,13 @@ static int sync_staged (mw_db *db, uint64_t my_epoch, uint64_t my_end) { // (done when the file is synced up to the record, or when everything up to its epoch is durable: a compaction that finds nothing in flight cuts the log back to its header and the // offsets restart, while a commit whose epoch the group leader has just made visible is still on its way out of this loop) while (db->synced_off < my_end && db->synced_upto < my_epoch) { - if (atomic_load(&db->failed)) { rc = SQLITE_IOERR_FSYNC; break; } + if (atomic_load(&db->failed)) { + // The log was failed (the append of an earlier commit did not make it, and a later one already has its place behind the hole). An fsync that is in flight covers what was written + // before it began, and its leader makes those commits durable and visible when it is done: a commit that it covers was not refused, and is not told that it was. What it does not + // cover is told, and stays invisible: no other fsync begins once the log is failed. + if (db->stage_fsyncing) { db->sync_waiters++; pthread_cond_wait(&db->sync_cv, &db->log_mu); db->sync_waiters--; continue; } + rc = SQLITE_IOERR_FSYNC; break; + } // Two roles, each held by one thread at a time, and (unless MW_NO_PIPELINE) working at the same time: the *writer* pwritev's the contiguous prefix of // finished records that is not in the file yet; the *syncer* fsyncs everything that was written before its fsync started. While one thread is in // fsync for group n another writes group n+1; group n+1 then needs its own fsync after the first one ends (an fsync covers only what was written before it began). diff --git a/src/multiwriter_rebase.c b/src/multiwriter_rebase.c index bb9a055..ee2c590 100644 --- a/src/multiwriter_rebase.c +++ b/src/multiwriter_rebase.c @@ -99,7 +99,7 @@ static int bind_col (sqlite3_stmt *st, int idx, const rec_t *r, int col) { uint64_t t = r->ty[col]; const uint8_t *p = r->rec + r->off[col]; uint32_t l = r->len[col]; if (t == 0) return sqlite3_bind_null(st, idx); if (t == 8 || t == 9) return sqlite3_bind_int64(st, idx, t == 9); - if (t >= 1 && t <= 6) { int64_t x = (int8_t)p[0]; for (uint32_t b = 1; b < l; b++) x = (x << 8) | p[b]; return sqlite3_bind_int64(st, idx, x); } + if (t >= 1 && t <= 6) { uint64_t x = (uint64_t)(int64_t)(int8_t)p[0]; for (uint32_t b = 1; b < l; b++) x = (x << 8) | p[b]; return sqlite3_bind_int64(st, idx, (int64_t)x); } if (t == 7) { uint64_t u = 0; for (int b = 0; b < 8; b++) u = (u << 8) | p[b]; double d; memcpy(&d, &u, 8); return sqlite3_bind_double(st, idx, d); } if (t >= 12 && (t & 1)) return sqlite3_bind_text(st, idx, l ? (const char *)p : "", (int)l, SQLITE_TRANSIENT); if (t >= 12) return sqlite3_bind_blob(st, idx, l ? (const void *)p : (const void *)"", (int)l, SQLITE_TRANSIENT); diff --git a/src/multiwriter_rowdiff.c b/src/multiwriter_rowdiff.c index 99e19c4..30770e4 100644 --- a/src/multiwriter_rowdiff.c +++ b/src/multiwriter_rowdiff.c @@ -83,9 +83,10 @@ static int freelist_of (mw_lane *lane, const uint8_t *const *imgs, bool new_stat // pgno -> root, for the children of the interior pages the transaction wrote (the owner of a page it allocated is the owner of the interior page that lists it) typedef struct { uint32_t *pg, *root; uint32_t cap, n; } ovmap; -static void ov_put (ovmap *m, uint32_t pg, uint32_t root) { +static bool ov_put (ovmap *m, uint32_t pg, uint32_t root) { if ((m->n + 1) * 2 > m->cap) { uint32_t nc = m->cap ? m->cap * 2 : 64; uint32_t *np = calloc(nc, sizeof *np), *nr = calloc(nc, sizeof *nr); + if (!np || !nr) { free(np); free(nr); return false; } for (uint32_t i = 0; i < m->cap; i++) if (m->pg[i]) { uint32_t h = (m->pg[i] * 2654435761u) & (nc - 1); while (np[h]) h = (h + 1) & (nc - 1); np[h] = m->pg[i]; nr[h] = m->root[i]; } free(m->pg); free(m->root); m->pg = np; m->root = nr; m->cap = nc; } @@ -93,6 +94,7 @@ static void ov_put (ovmap *m, uint32_t pg, uint32_t root) { while (m->pg[h] && m->pg[h] != pg) h = (h + 1) & (m->cap - 1); if (!m->pg[h]) { m->pg[h] = pg; m->n++; } m->root[h] = root; + return true; } static uint32_t ov_get (const ovmap *m, uint32_t pg) { if (!m->cap) return 0; @@ -143,7 +145,7 @@ static bool full_payload (mw_lane *lane, const uint8_t *const *imgs, bool new_st const uint8_t *pg = new_state ? ws_image(lane, imgs, ovfl) : NULL; if (!pg) { if (!mw_rd_snap_page(lane, ovfl, tmp)) { free(buf); free(tmp); return false; } pg = tmp; } size_t take = total - got < pgsz - 4 ? (size_t)(total - got) : pgsz - 4; - if (chain) { *chain = realloc(*chain, (size_t)(*nchain + 1) * sizeof **chain); (*chain)[(*nchain)++] = ovfl; } + if (chain) { uint32_t *next = realloc(*chain, (size_t)(*nchain + 1) * sizeof **chain); if (!next) { free(buf); free(tmp); return false; } *chain = next; (*chain)[(*nchain)++] = ovfl; } memcpy(buf + got, pg + 4, take); got += take; ovfl = be32(pg); } free(tmp); @@ -182,7 +184,7 @@ static int s_build (mw_lane *lane, const mw_cat *cat, ovmap *S, uint8_t *tmp) { uint32_t pgsz = (uint32_t)lane->db->store->pgsz; long reads = 0; for (int t = 0; t < cat->n; t++) { const mw_tab *tab = &cat->tabs[t]; if (!tab->without_rowid || tab->root <= 1) continue; - ov_put(S, tab->root, tab->root); + if (!ov_put(S, tab->root, tab->root)) return -1; int depth = 0; for (uint32_t pg = tab->root; depth < 40; depth++) { // how many levels of interior pages: down the right edge if (++reads > 200000 || !mw_rd_snap_page(lane, pg, tmp)) return -1; @@ -200,7 +202,7 @@ static int s_build (mw_lane *lane, const mw_cat *cat, ovmap *S, uint8_t *tmp) { for (int c = -1; c < nc; c++) { uint32_t ch; if (c < 0) ch = be32(tmp + 8); else { uint32_t off = (uint32_t)be16(tmp + 12 + 2 * c); if (off + 4 > pgsz) { free(lvl); free(nxt); return -1; } ch = be32(tmp + off); } - ov_put(S, ch, tab->root); + if (!ov_put(S, ch, tab->root)) { free(lvl); free(nxt); return -1; } if (d + 1 < depth) { if (nn == cn) { cn = cn ? cn * 2 : 64; uint32_t *np = realloc(nxt, cn * sizeof *np); if (!np) { free(lvl); free(nxt); return -1; } nxt = np; } nxt[nn++] = ch; } } } @@ -282,20 +284,20 @@ int mw_rowdiff_compute (mw_lane *lane, const uint8_t *const *imgs, const mw_cat const uint8_t *p1new = ws_image(lane, imgs, 1); bool touched = p1new != NULL; for (int i = 0; i < lane->ws_n && !touched && nfo; i++) { uint32_t pg = lane->ws_pgnos[i]; if (bsearch(&pg, fo, (size_t)nfo, sizeof *fo, cmp_u32)) touched = true; } - if (!touched) { if (nfo) { fn = malloc((size_t)nfo * sizeof *fn); if (fn) { memcpy(fn, fo, (size_t)nfo * sizeof *fn); nfn = nfo; } } } + if (!touched) { if (nfo) { fn = malloc((size_t)nfo * sizeof *fn); if (fn) { memcpy(fn, fo, (size_t)nfo * sizeof *fn); nfn = nfo; } else why = "no memory"; } } else if (freelist_of(lane, imgs, true, p1new ? p1new : p1old, &fn, &nfn, &cfn, oldimg) < 0) why = "the freelist cannot be read"; else qsort(fn, (size_t)nfn, sizeof *fn, cmp_u32); } // owners of the pages of the committed state that the interior pages written by the commit list (a few passes: an interior page may itself be new) ovmap ov = {0}; ovmap S = {0}, cm = {0}; // WITHOUT ROWID: page -> root at the snapshot (built when first needed), and child -> parent for the interior pages that the transaction wrote bool s_ready = false; - if (!why && cat->has_wr) for (int i = 0; i < lane->ws_n; i++) { + if (!why && cat->has_wr) for (int i = 0; i < lane->ws_n && !why; i++) { const uint8_t *pg = imgs[i]; if (lane->ws_pgnos[i] == 1 || (pg[0] != 0x05 && pg[0] != 0x02)) continue; int nc = be16(pg + 3); uint32_t hdr = 12; for (int c = -1; c < nc; c++) { uint32_t ch; if (c < 0) ch = be32(pg + 8); else { uint32_t off = (uint32_t)be16(pg + hdr + 2 * c); if (off + 4 > pgsz) continue; ch = be32(pg + off); } - ov_put(&cm, ch, lane->ws_pgnos[i]); + if (!ov_put(&cm, ch, lane->ws_pgnos[i])) { why = "no memory"; break; } } } #define ENSURE_S() do { if (!why && cat->has_wr && !s_ready) { s_ready = true; if (s_build(lane, cat, &S, t1) < 0) why = "the pages of the WITHOUT ROWID tables cannot be listed"; } } while (0) @@ -303,9 +305,9 @@ int mw_rowdiff_compute (mw_lane *lane, const uint8_t *const *imgs, const mw_cat #define OLD_OWNER(pgno, out) do { (out) = 0; for (int _q = 0; _q < noc; _q++) if (oc[_q].pg == (pgno)) (out) = oc[_q].root; if (!(out) && !why) { (out) = old_owner(lane, cat, (pgno), t1, t2); if ((out) && noc < 64) oc[noc++] = (own_cache){ (pgno), (out) }; } } while (0) #define NEW_OWNER(pgno, out) do { (out) = 0; if (mw_cat_by_root(cat, (pgno))) (out) = (pgno); else { (out) = ov_get(&ov, (pgno)); if (!(out)) OLD_OWNER((pgno), (out)); } } while (0) if (!why) { - for (int pass = 0; pass < 3; pass++) { + for (int pass = 0; pass < 3 && !why; pass++) { int unresolved = 0; uint32_t before = ov.n; - for (int i = 0; i < lane->ws_n; i++) { + for (int i = 0; i < lane->ws_n && !why; i++) { const uint8_t *pg = imgs[i]; if (lane->ws_pgnos[i] == 1 || pg[0] != 0x05) continue; uint32_t r; NEW_OWNER(lane->ws_pgnos[i], r); if (!r) { unresolved++; continue; } int nc = be16(pg + 3); @@ -313,7 +315,7 @@ int mw_rowdiff_compute (mw_lane *lane, const uint8_t *const *imgs, const mw_cat uint32_t ch; if (c < 0) ch = be32(pg + 8); else { uint32_t off = (uint32_t)be16(pg + 12 + 2 * c); if (off + 4 > pgsz) continue; ch = be32(pg + off); } - ov_put(&ov, ch, r); + if (!ov_put(&ov, ch, r)) { why = "no memory"; break; } } } if (!unresolved || ov.n == before) break; @@ -399,7 +401,7 @@ int mw_rowdiff_compute (mw_lane *lane, const uint8_t *const *imgs, const mw_cat // overflow pages written that no cell of the write set owns: an update that rewrites only the tail of a big value. Their owner is not known: unsupported. if (!why) { uint32_t *nch = NULL; int nnch = 0; - for (int k = 0; k < newr.n; k++) for (int q = 0; q < newr.a[k].nchain; q++) { nch = realloc(nch, (size_t)(nnch + 1) * sizeof *nch); nch[nnch++] = newr.a[k].chain[q]; } + for (int k = 0; k < newr.n && !why; k++) for (int q = 0; q < newr.a[k].nchain; q++) { uint32_t *next = realloc(nch, (size_t)(nnch + 1) * sizeof *nch); if (!next) { why = "no memory"; break; } nch = next; nch[nnch++] = newr.a[k].chain[q]; } qsort(nch, (size_t)nnch, sizeof *nch, cmp_u32); for (int i = 0; i < lane->ws_n && !why; i++) { uint32_t pg = lane->ws_pgnos[i]; @@ -432,6 +434,7 @@ int mw_rowdiff_compute (mw_lane *lane, const uint8_t *const *imgs, const mw_cat x->old_rec = dup_bytes(o->rec, o->reclen); x->old_len = o->reclen; x->new_rec = dup_bytes(w->rec, w->reclen); x->new_len = w->reclen; nout++; } } + if ((x->old_len && !x->old_rec) || (x->new_len && !x->new_rec)) { res->unsupported = "no memory"; break; } } res->chg = out; res->n = out ? nout : 0; rows_free(&oldr); rows_free(&newr); diff --git a/src/multiwriter_seglog.c b/src/multiwriter_seglog.c index 6d415a1..d0ca0d5 100644 --- a/src/multiwriter_seglog.c +++ b/src/multiwriter_seglog.c @@ -348,11 +348,13 @@ int mw_seglog_open (mw_db *db, mw_seglog_replay_fn fn, void *ctx, uint64_t *base else if (i == 0) { first_epoch_set(sh, ids[i].id, base + 1); good_seg = ids[i].id; good_end = MW_SEG_HDR; } else { stop = true; } // a segment without a valid first record: nothing after the previous one belongs to the log } + free(pg); free(locs); + // An unreadable record leaves the prefix unknown. Preserve every + // segment so that a later open can retry recovery without data loss. + if (rc != SQLITE_OK) { free(ids); return rc; } cur_seg = good_seg; cur_end = good_end; for (int j = 0; j < nids; j++) if (ids[j].id > cur_seg) { char p2[620]; seg_path(sl, ids[j].id, p2, sizeof p2); unlink(p2); } // segments after the end of the prefix for (int j = 0; j < nids; j++) if (ids[j].id > cur_seg) nids = j; - free(pg); free(locs); - if (rc != SQLITE_OK) { free(ids); return rc; } atomic_store(&sh->seg_min, ids[0].id); } if (nids > 0) { // the tail of the last segment may hold a torn record and what a crash left behind: clear it, so that stale records can never join the log again diff --git a/test/mw_attachsync.c b/test/mw_attachsync.c new file mode 100644 index 0000000..f781611 --- /dev/null +++ b/test/mw_attachsync.c @@ -0,0 +1,55 @@ +// MW_PORTABLE_TEST: ATTACH must honor synchronous even without the engine's trace hook. +#include "mw_test.h" +#include "multiwriter_internal.h" + +static void check_full(sqlite3 *db, mw_lane *lane) { + CHECK(mw_scalar(db, "PRAGMA aux.synchronous") == 2); + CHECK(lane->sync_level == 2); +} + +int main(void) { + for (int main_engine = 0; main_engine < 2; main_engine++) + for (int mp = 0; mp < 2; mp++) + for (int rebase = 0; rebase < 2; rebase++) + for (int replace_trace = 0; replace_trace < 2; replace_trace++) { + char mainpath[256], auxpath[256], uri[512]; + mw_tmpdb(mainpath, sizeof mainpath, "attach_main"); + mw_tmpdb(auxpath, sizeof auxpath, "attach_aux"); + snprintf(uri, sizeof uri, "file:%s?mw=%d&mw_rebase=%d&mw_log_max_mb=0", mainpath, main_engine, rebase); + sqlite3 *db = NULL; + CHECK_RC(sqlite3_open_v2(uri, &db, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL), SQLITE_OK); + if (replace_trace) CHECK_RC(sqlite3_trace_v2(db, 0, NULL, NULL), SQLITE_OK); + // Reuse the schema name after DETACH: the new lane must be bound too. + for (int round = 0; round < 2; round++) { + snprintf(uri, sizeof uri, "file:%s?mw=1&mw_mp=%d&mw_rebase=%d&mw_compact_ms=0&mw_log_max_mb=0", auxpath, mp, rebase); + char *sql = sqlite3_mprintf("ATTACH %Q AS aux", uri); + CHECK_RC(mw_exec(db, sql), SQLITE_OK); sqlite3_free(sql); + CHECK_RC(mw_exec(db, "CREATE TABLE IF NOT EXISTS aux.t(v); PRAGMA aux.synchronous=FULL"), SQLITE_OK); + mw_lane *lane = NULL; + CHECK_RC(sqlite3_file_control(db, "aux", MW_FCNTL_LANE_PTR, &lane), SQLITE_OK); + CHECK(lane != NULL); + if (!lane) { sqlite3_close(db); mw_rmdb(mainpath); mw_rmdb(auxpath); MW_DONE(); } + CHECK(lane->rd_db == db); + CHECK_RC(mw_exec(db, "BEGIN"), SQLITE_OK); + // SQLite rejects this even before a snapshot has been acquired. + CHECK_RC(mw_exec(db, "PRAGMA aux.synchronous=OFF"), SQLITE_ERROR); + check_full(db, lane); + CHECK_RC(mw_exec(db, "INSERT INTO aux.t VALUES(1)"), SQLITE_OK); + CHECK_RC(mw_exec(db, "PRAGMA aux.synchronous=OFF"), SQLITE_ERROR); + check_full(db, lane); + mw_db_stats before, after; + CHECK_RC(sqlite3_file_control(db, "aux", MW_FCNTL_DBSTATS, &before), SQLITE_OK); + CHECK_RC(mw_exec(db, "COMMIT"), SQLITE_OK); + CHECK_RC(sqlite3_file_control(db, "aux", MW_FCNTL_DBSTATS, &after), SQLITE_OK); + printf("ATTACH main=%d mp=%d rebase=%d trace_removed=%d round=%d: FULL syncs=%llu\n", + main_engine, mp, rebase, replace_trace, round, + (unsigned long long)(after.log_syncs - before.log_syncs)); + CHECK(after.log_syncs > before.log_syncs); + CHECK(mw_scalar(db, "SELECT count(*) FROM aux.t") == round + 1); + CHECK_RC(mw_exec(db, "DETACH aux"), SQLITE_OK); + } + CHECK_RC(sqlite3_close(db), SQLITE_OK); + mw_rmdb(mainpath); mw_rmdb(auxpath); + } + MW_DONE(); +} diff --git a/test/mw_holesync.c b/test/mw_holesync.c new file mode 100644 index 0000000..64a457e --- /dev/null +++ b/test/mw_holesync.c @@ -0,0 +1,72 @@ +// A commit that is told that it failed must not become visible. The append of one commit fails while another already has its place behind it (the log is failed: nothing can be cut in +// place), and an fsync that was already running covers a commit that is parked behind it: that fsync makes the commit durable and visible when it is done, so the commit must be +// acknowledged. Build multiwriter_log.c with -Dfsync=inject_fsync to hold that fsync. +#include "mw_test.h" +#include "multiwriter_internal.h" +#include +#include + +static pthread_mutex_t mu = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t cv = PTHREAD_COND_INITIALIZER; +static int armed, entered, released; +int inject_fsync (int fd) { + pthread_mutex_lock(&mu); + if (armed) { armed = 0; entered = 1; pthread_cond_broadcast(&cv); while (!released) pthread_cond_wait(&cv, &mu); } + pthread_mutex_unlock(&mu); + return fsync(fd); +} + +typedef struct { sqlite3 *db; const char *sql; int rc; } job; +static void *run (void *p) { job *j = p; j->rc = mw_exec(j->db, j->sql); return NULL; } +#define STEP(s) do { if (getenv("MW_TEST_TRACE")) fprintf(stderr, "step: %s\n", s); } while (0) +static void wait_epoch (mw_db *db, uint64_t e) { while (atomic_load(&db->next_epoch) < e) usleep(200); } + +int main (void) { + char path[256], uri[512]; mw_tmpdb(path, sizeof path, "holesync"); + snprintf(uri, sizeof uri, "file:%s?mw=1&mw_compact_ms=0&mw_log_max_mb=0", path); + pid_t child = fork(); + if (child == 0) { + alarm(30); + setenv("MW_FAULT_DELAY_US", "1000000", 1); + sqlite3 *c[4]; + for (int i = 0; i < 4; i++) if (sqlite3_open_v2(uri, &c[i], SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL)) _exit(2); + if (mw_exec(c[0], "CREATE TABLE tx(v); CREATE TABLE tw(v); CREATE TABLE tf(v); CREATE TABLE ts(v); INSERT INTO tx VALUES(0); INSERT INTO tw VALUES(0); INSERT INTO tf VALUES(0); INSERT INTO ts VALUES(0)")) _exit(3); + mw_lane *lane = NULL; if (sqlite3_file_control(c[0], "main", MW_FCNTL_LANE_PTR, &lane) != SQLITE_OK || !lane) _exit(4); + mw_db *db = lane->db; + uint64_t base = atomic_load(&db->next_epoch); + job j[4] = { { c[0], "UPDATE tx SET v=1", -1 }, { c[1], "UPDATE tw SET v=1", -1 }, { c[2], "UPDATE tf SET v=1", -1 }, { c[3], "UPDATE ts SET v=1", -1 } }; + pthread_t t[4]; + // 1. x has its place and is held before its bytes; w is behind it. Then x copies, and the two are written and one fsync starts for both: it is held. + armed = 1; mw_fault_arm(MW_FAULT_APPEND_STALL, 1); + STEP("x"); pthread_create(&t[0], NULL, run, &j[0]); wait_epoch(db, base + 1); usleep(100000); + STEP("w"); pthread_create(&t[1], NULL, run, &j[1]); wait_epoch(db, base + 2); + STEP("wait fsync"); pthread_mutex_lock(&mu); while (!entered) pthread_cond_wait(&cv, &mu); pthread_mutex_unlock(&mu); + // 2. f has its place and is held, s takes the place behind it; then the append of f fails: s is behind the hole, the log is failed. + mw_fault_arm(MW_FAULT_APPEND_STALL, 1); + STEP("f"); pthread_create(&t[2], NULL, run, &j[2]); wait_epoch(db, base + 3); usleep(100000); + STEP("s"); pthread_create(&t[3], NULL, run, &j[3]); wait_epoch(db, base + 4); usleep(100000); // (s appends at once: f is the first append that finds the fault) + mw_fault_arm(MW_FAULT_LOG_WRITE_ERR, 1); + STEP("wait failed"); + while (!atomic_load(&db->failed)) usleep(200); + STEP("failed"); + usleep(100000); // (the commit that waits for the fsync has seen it) + pthread_mutex_lock(&mu); released = 1; pthread_cond_broadcast(&cv); pthread_mutex_unlock(&mu); + STEP("join"); for (int i = 0; i < 4; i++) pthread_join(t[i], NULL); + mw_fault_disarm_all(); + long long v[4] = { mw_scalar(c[0], "SELECT v FROM tx"), mw_scalar(c[0], "SELECT v FROM tw"), mw_scalar(c[0], "SELECT v FROM tf"), mw_scalar(c[0], "SELECT v FROM ts") }; + const char *nm[4] = { "x", "w", "f", "s" }; + int code = 0; + for (int i = 0; i < 4; i++) { + fprintf(stderr, "%s: commit=%d, visible=%lld\n", nm[i], j[i].rc, v[i]); + if ((j[i].rc == SQLITE_OK) != (v[i] == 1)) code |= 1; // told that it failed and visible, or acknowledged and not there + } + if (j[0].rc || j[1].rc) code |= 2; // the commits that the running fsync covers are acknowledged + if (!j[2].rc || !j[3].rc) code |= 4; // the one that failed and the one behind the hole are refused + _exit(code); + } + int st = 0; CHECK(waitpid(child, &st, 0) == child); + printf("child: %s %d\n", WIFEXITED(st) ? "exit" : "signal", WIFEXITED(st) ? WEXITSTATUS(st) : WTERMSIG(st)); + CHECK(WIFEXITED(st) && WEXITSTATUS(st) == 0); + mw_rmdb(path); + MW_DONE(); +} diff --git a/test/mw_rebaseneg.c b/test/mw_rebaseneg.c new file mode 100644 index 0000000..717785a --- /dev/null +++ b/test/mw_rebaseneg.c @@ -0,0 +1,16 @@ +// The replay of a negative integer must not shift a negative value (undefined behaviour in C). The result is right either way: this test +// is a regression guard only under `test/sanitize.sh ubsan` (it is in its default list); in a plain build it passes on the old code too. +#include "mw_test.h" +#include "multiwriter.h" +int main(void){ + char p[256],u[512];mw_tmpdb(p,sizeof p,"negative");snprintf(u,sizeof u,"file:%s?mw=1&mw_rebase=1&mw_rebase_backoff=0",p); + sqlite3 *a=NULL,*b=NULL; + CHECK_RC(sqlite3_open_v2(u,&a,SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI,NULL),SQLITE_OK); + CHECK_RC(sqlite3_open_v2(u,&b,SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI,NULL),SQLITE_OK); + CHECK_RC(mw_exec(a,"CREATE TABLE t(id INTEGER PRIMARY KEY,v); INSERT INTO t VALUES(1,-129),(2,0)"),SQLITE_OK); + CHECK_RC(mw_exec(a,"BEGIN; UPDATE t SET v=-130 WHERE id=1"),SQLITE_OK); + CHECK_RC(mw_exec(b,"UPDATE t SET v=1 WHERE id=2"),SQLITE_OK); + CHECK_RC(mw_exec(a,"COMMIT"),SQLITE_OK); + CHECK(mw_scalar(b,"SELECT v FROM t WHERE id=1")==-130); + sqlite3_close(a);sqlite3_close(b);mw_rmdb(p);MW_DONE(); +} diff --git a/test/mw_rebasepolicy.c b/test/mw_rebasepolicy.c new file mode 100644 index 0000000..36908fd --- /dev/null +++ b/test/mw_rebasepolicy.c @@ -0,0 +1,63 @@ +// A statement's own conflict policy (OR ..., ON CONFLICT ...) is not in the row images: such a transaction is refused (BUSY), never replayed with the policy of the schema. +// IGNORE and REPLACE are refused too: that is the conservative answer, not a requirement of correctness there. +#include "mw_test.h" +#include "multiwriter.h" + +int main(void) { + const char *policies[] = {"ABORT", "FAIL", "ROLLBACK", "IGNORE", "REPLACE"}; + for (int wr = 0; wr < 2; wr++) for (int update = 0; update < 2; update++) + for (unsigned k = 0; k < sizeof policies / sizeof *policies; k++) { + char path[256], uri[512], sql[512]; + mw_tmpdb(path, sizeof path, "policy"); + snprintf(uri, sizeof uri, "file:%s?mw=1&mw_rebase=1&mw_rebase_backoff=0%s", + path, getenv("MW_TEST_MP") ? "&mw_mp=1" : ""); + sqlite3 *a = NULL, *b = NULL; + CHECK_RC(sqlite3_open_v2(uri, &a, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL), SQLITE_OK); + CHECK_RC(sqlite3_open_v2(uri, &b, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL), SQLITE_OK); + snprintf(sql, sizeof sql, "CREATE TABLE t(id INTEGER PRIMARY KEY,u TEXT UNIQUE ON CONFLICT REPLACE)%s; INSERT INTO t VALUES(1,'seed')", wr ? " WITHOUT ROWID" : ""); + CHECK_RC(mw_exec(a, sql), SQLITE_OK); + CHECK_RC(mw_exec(a, "BEGIN"), SQLITE_OK); + snprintf(sql, sizeof sql, update + ? "/* request */ UPDATE /* policy */ OR %s t SET u='same' WHERE id=1" + : "/* request */ INSERT /* policy */ OR %s INTO t VALUES(10,'same')", policies[k]); + CHECK_RC(mw_exec(a, sql), SQLITE_OK); + CHECK_RC(mw_exec(b, "INSERT OR ABORT INTO t VALUES(20,'same')"), SQLITE_OK); + int rc = mw_exec(a, "COMMIT"); + printf("%s OR %s, WITHOUT ROWID=%d: commit=%d\n", update ? "UPDATE" : "INSERT", policies[k], wr, rc); + CHECK((rc & 255) == SQLITE_BUSY); + CHECK(mw_scalar(b, "SELECT count(*) FROM t WHERE id=20 AND u='same'") == 1); + if (!sqlite3_get_autocommit(a)) CHECK_RC(mw_exec(a, "ROLLBACK"), SQLITE_OK); + CHECK(mw_scalar(b, "SELECT count(*) FROM t WHERE id=1 AND u='seed'") == 1); + sqlite3_close(a); sqlite3_close(b); mw_rmdb(path); + } + // The UPSERT clause is a policy of the statement too: a replay would give the schema's (REPLACE) and delete the row that the other writer committed. + const char *upserts[] = { + "INSERT INTO t VALUES(10,'same') ON CONFLICT(u) DO NOTHING", + "INSERT INTO t VALUES(10,'same') ON CONFLICT DO NOTHING", + "INSERT INTO t VALUES(10,'same') ON CONFLICT(u) DO UPDATE SET id=excluded.id", + "/* request */ INSERT /* upsert */ INTO t VALUES(10,'same') /* c */ ON /* c */ CONFLICT(u) DO NOTHING", + }; + for (int wr = 0; wr < 2; wr++) + for (unsigned k = 0; k < sizeof upserts / sizeof *upserts; k++) { + char path[256], uri[512], sql[512]; + mw_tmpdb(path, sizeof path, "upsert"); + snprintf(uri, sizeof uri, "file:%s?mw=1&mw_rebase=1&mw_rebase_backoff=0%s", + path, getenv("MW_TEST_MP") ? "&mw_mp=1" : ""); + sqlite3 *a = NULL, *b = NULL; + CHECK_RC(sqlite3_open_v2(uri, &a, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL), SQLITE_OK); + CHECK_RC(sqlite3_open_v2(uri, &b, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL), SQLITE_OK); + snprintf(sql, sizeof sql, "CREATE TABLE t(id INTEGER PRIMARY KEY,u TEXT UNIQUE ON CONFLICT REPLACE)%s; INSERT INTO t VALUES(1,'seed')", wr ? " WITHOUT ROWID" : ""); + CHECK_RC(mw_exec(a, sql), SQLITE_OK); + CHECK_RC(mw_exec(a, "BEGIN"), SQLITE_OK); + CHECK_RC(mw_exec(a, upserts[k]), SQLITE_OK); + CHECK_RC(mw_exec(b, "INSERT INTO t VALUES(20,'same')"), SQLITE_OK); + int rc = mw_exec(a, "COMMIT"); + printf("UPSERT #%u, WITHOUT ROWID=%d: commit=%d\n", k, wr, rc); + CHECK((rc & 255) == SQLITE_BUSY); + CHECK(mw_scalar(b, "SELECT count(*) FROM t WHERE id=20 AND u='same'") == 1); + if (!sqlite3_get_autocommit(a)) CHECK_RC(mw_exec(a, "ROLLBACK"), SQLITE_OK); + CHECK(mw_scalar(b, "SELECT count(*) FROM t") == 2); + sqlite3_close(a); sqlite3_close(b); mw_rmdb(path); + } + MW_DONE(); +} diff --git a/test/mw_recoverread.c b/test/mw_recoverread.c new file mode 100644 index 0000000..c84aaaa --- /dev/null +++ b/test/mw_recoverread.c @@ -0,0 +1,23 @@ +#include "mw_test.h" +#include "multiwriter.h" +#include +#include +static int op(const char *u,sqlite3 **d){return sqlite3_open_v2(u,d,SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI,NULL);} +int main(void){ + setenv("MW_SEG_MB","1",1); + for(int nth=1;nth<=24;nth++){ + char path[256],uri[512];mw_tmpdb(path,sizeof path,"recovery"); + snprintf(uri,sizeof uri,"file:%s?mw=1&mw_mp=1&mw_compact_ms=0&mw_log_max_mb=0",path); + pid_t p=fork();if(p==0){sqlite3*d=NULL;if(op(uri,&d)!=SQLITE_OK)_exit(2); + if(mw_exec(d,"CREATE TABLE t(v)")!=SQLITE_OK)_exit(3); + for(int i=0;i<4;i++)if(mw_exec(d,"INSERT INTO t VALUES(zeroblob(600000))")!=SQLITE_OK)_exit(4); + _exit(0); + } + int status;waitpid(p,&status,0);CHECK(WIFEXITED(status)&&WEXITSTATUS(status)==0); + sqlite3*d=NULL;mw_io_fault_arm(MW_IO_READ,nth,EIO,0,0);int rc=op(uri,&d);mw_io_fault_disarm();sqlite3_close(d);d=NULL; + int rr=op(uri,&d);long long count=rr==SQLITE_OK?mw_scalar(d,"SELECT count(*) FROM t"):-1; + printf("read failure #%d: first open=%d, retry=%d, rows=%lld (expected 4)\n",nth,rc,rr,count); + CHECK(rr==SQLITE_OK&&count==4);sqlite3_close(d);mw_rmdb(path); + } + MW_DONE(); +} diff --git a/test/mw_rowdiffoom.c b/test/mw_rowdiffoom.c new file mode 100644 index 0000000..a4c90af --- /dev/null +++ b/test/mw_rowdiffoom.c @@ -0,0 +1,59 @@ +// Compile multiwriter_rowdiff.c with -Dmalloc=inject_malloc +// -Dcalloc=inject_calloc -Drealloc=inject_realloc. +#include "mw_test.h" +#include +#include + +static int remaining, fired; +#define MAX_NTH 2000 // (the sweep ends at the first n-th allocation that does not exist; this only bounds a runaway) +static int fail_alloc(void) { + if (remaining > 0 && --remaining == 0) { fired = 1; return 1; } + return 0; +} +void *inject_malloc(size_t n) { return fail_alloc() ? NULL : malloc(n); } +void *inject_calloc(size_t n, size_t s) { return fail_alloc() ? NULL : calloc(n, s); } +void *inject_realloc(void *p, size_t n) { return fail_alloc() ? NULL : realloc(p, n); } + +int main(void) { + for (int layout = 0; layout < 3; layout++) { + int nth, exhausted = 0; + for (nth = 1; nth <= MAX_NTH && !exhausted; nth++) { + char path[256]; mw_tmpdb(path, sizeof path, "rowdiff_oom"); + pid_t child = fork(); CHECK(child >= 0); + if (child == 0) { + alarm(15); + char uri[512]; snprintf(uri, sizeof uri, + "file:%s?mw=1&mw_rebase=1&mw_rebase_backoff=0&mw_compact_ms=0&mw_log_max_mb=0", path); + sqlite3 *a = NULL, *b = NULL; + if (sqlite3_open_v2(uri, &a, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL)) _exit(2); + if (sqlite3_open_v2(uri, &b, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL)) _exit(3); + char seed[512]; + snprintf(seed, sizeof seed, "CREATE TABLE t(id INTEGER PRIMARY KEY,v)%s; INSERT INTO t VALUES(1,zeroblob(%d)),(2,0)", layout == 2 ? " WITHOUT ROWID" : "", layout == 1 ? 100 : 6000); + if (mw_exec(a, seed)) _exit(4); + if (layout && mw_exec(a, "WITH RECURSIVE n(i) AS (VALUES(3) UNION ALL SELECT i+1 FROM n WHERE i<300) INSERT INTO t SELECT i,zeroblob(100) FROM n")) _exit(4); + if (mw_exec(a, "BEGIN; UPDATE t SET v=zeroblob(6001) WHERE id=1")) _exit(5); + if (mw_exec(b, "UPDATE t SET v=1 WHERE id=2")) _exit(6); + remaining = nth; + int rc = mw_exec(a, "COMMIT"); + remaining = 0; + // Failure must refuse the transaction cleanly; it must not lose + // the other writer's change or acknowledge a partial replay. + if (rc != SQLITE_OK) mw_exec(a, "ROLLBACK"); + if (mw_scalar(b, "SELECT v FROM t WHERE id=2") != 1) _exit(7); + if (mw_scalar(b, "SELECT length(v) FROM t WHERE id=1") != (rc == SQLITE_OK ? 6001 : layout == 1 ? 100 : 6000)) _exit(8); + sqlite3_close(a); sqlite3_close(b); + _exit(fired ? 0 : 20); // (20: the commit made fewer than nth allocations: nothing was injected) + } + int status = 0; CHECK(waitpid(child, &status, 0) == child); + printf("rowdiff layout %d allocation #%d: exit=%d signal=%d\n", layout, nth, + WIFEXITED(status) ? WEXITSTATUS(status) : -1, + WIFSIGNALED(status) ? WTERMSIG(status) : 0); + if (WIFEXITED(status) && WEXITSTATUS(status) == 20) { exhausted = 1; CHECK(nth > 1); } // (n-th allocation not reached: every earlier one was failed in turn) + else CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0); + mw_rmdb(path); + } + CHECK(exhausted); + printf("rowdiff layout %d: %d allocations of the commit, each failed in turn\n", layout, nth - 2); + } + MW_DONE(); +} diff --git a/test/mw_stagednormal.c b/test/mw_stagednormal.c new file mode 100644 index 0000000..07424e8 --- /dev/null +++ b/test/mw_stagednormal.c @@ -0,0 +1,42 @@ +// Build multiwriter_log.c with -Dpwritev=inject_pwritev to pause its real write. +#include "mw_test.h" +#include "multiwriter_internal.h" +#include +#include +static pthread_mutex_t mu=PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t cv=PTHREAD_COND_INITIALIZER; +static int armed,entered,released; +ssize_t inject_pwritev(int fd,const struct iovec *iov,int n,off_t off){ + pthread_mutex_lock(&mu); + if(armed){armed=0;entered=1;pthread_cond_broadcast(&cv);while(!released)pthread_cond_wait(&cv,&mu);} + pthread_mutex_unlock(&mu);return pwritev(fd,iov,n,off); +} +typedef struct{sqlite3*d;const char*q;int rc;}job; +static void *run(void*p){job*j=p;j->rc=mw_exec(j->d,j->q);return NULL;} +int main(void){ + char path[256],uri[512];mw_tmpdb(path,sizeof path,"normal"); + snprintf(uri,sizeof uri,"file:%s?mw=1&mw_compact_ms=0&mw_log_max_mb=0",path); + pid_t child=fork();if(child==0){ + alarm(15);sqlite3*a=NULL,*b=NULL; + if(sqlite3_open_v2(uri,&a,SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI,NULL))_exit(2); + if(sqlite3_open_v2(uri,&b,SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI,NULL))_exit(3); + if(mw_exec(a,"CREATE TABLE a(v); CREATE TABLE b(v); INSERT INTO a VALUES(0); INSERT INTO b VALUES(0); PRAGMA synchronous=NORMAL"))_exit(4); + if(mw_exec(b,"PRAGMA synchronous=NORMAL"))_exit(5); + mw_lane *lane=NULL;sqlite3_file_control(a,"main",MW_FCNTL_LANE_PTR,&lane); + uint64_t epoch=atomic_load(&lane->db->epoch); + job ja={a,"UPDATE a SET v=1",-1},jb={b,"UPDATE b SET v=1",-1};pthread_t ta,tb; + armed=1;pthread_create(&ta,NULL,run,&ja); + pthread_mutex_lock(&mu);while(!entered)pthread_cond_wait(&cv,&mu);pthread_mutex_unlock(&mu); + pthread_create(&tb,NULL,run,&jb); + for(;;){pthread_mutex_lock(&lane->db->log_mu);int ready=lane->db->written_upto>=epoch+2 && (lane->db->sync_waiters>0 || atomic_load(&lane->db->vis_parked)>0);pthread_mutex_unlock(&lane->db->log_mu);if(ready)break;usleep(100);} + pthread_mutex_lock(&mu);released=1;pthread_cond_broadcast(&cv);pthread_mutex_unlock(&mu); + pthread_join(ta,NULL);pthread_join(tb,NULL); + fprintf(stderr,"acknowledged commits: A=%d B=%d; in-memory values=%lld,%lld\n",ja.rc,jb.rc,(long long)mw_scalar(a,"SELECT v FROM a"),(long long)mw_scalar(b,"SELECT v FROM b")); + _exit(ja.rc||jb.rc?6:0); + } + int st;waitpid(child,&st,0);CHECK(WIFEXITED(st)&&WEXITSTATUS(st)==0); + sqlite3*d=NULL;CHECK_RC(sqlite3_open_v2(uri,&d,SQLITE_OPEN_READWRITE|SQLITE_OPEN_URI,NULL),SQLITE_OK); + printf("after process exit: A=%lld B=%lld\n",(long long)mw_scalar(d,"SELECT v FROM a"),(long long)mw_scalar(d,"SELECT v FROM b")); + CHECK(mw_scalar(d,"SELECT v FROM a")==1);CHECK(mw_scalar(d,"SELECT v FROM b")==1); + sqlite3_close(d);mw_rmdb(path);MW_DONE(); +} diff --git a/test/mw_strictany.c b/test/mw_strictany.c new file mode 100644 index 0000000..5cb1d46 --- /dev/null +++ b/test/mw_strictany.c @@ -0,0 +1,24 @@ +#include "mw_test.h" +#include "multiwriter.h" + +int main(void) { + char path[256], uri[512]; + mw_tmpdb(path, sizeof path, "any"); + snprintf(uri, sizeof uri, "file:%s?mw=1&mw_rebase=1&mw_rebase_backoff=0%s", + path, getenv("MW_TEST_MP") ? "&mw_mp=1" : ""); + sqlite3 *a = NULL, *b = NULL; + CHECK_RC(sqlite3_open_v2(uri, &a, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL), SQLITE_OK); + CHECK_RC(sqlite3_open_v2(uri, &b, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL), SQLITE_OK); + CHECK_RC(mw_exec(a, "CREATE TABLE t(id INTEGER PRIMARY KEY,v ANY,n INTEGER) STRICT; INSERT INTO t VALUES(1,1,0)"), SQLITE_OK); + CHECK_RC(mw_exec(a, "BEGIN; UPDATE t SET n=length(typeof(v)) WHERE id=1"), SQLITE_OK); + CHECK_RC(mw_exec(b, "UPDATE t SET v=1.0,n=n*10 WHERE id=1"), SQLITE_OK); + /* Serial A,B gives n=70; serial B,A gives n=4. n=7 fits neither. */ + int rc = mw_exec(a, "COMMIT"); + printf("strict ANY: commit=%d, real=%lld, n=%lld\n", rc, + (long long)mw_scalar(b, "SELECT typeof(v)='real' FROM t"), + (long long)mw_scalar(b, "SELECT n FROM t")); + CHECK((rc & 255) == SQLITE_BUSY); + if (rc != SQLITE_OK) mw_exec(a, "ROLLBACK"); + sqlite3_close(a); sqlite3_close(b); mw_rmdb(path); + MW_DONE(); +} diff --git a/test/mw_syncpragma.c b/test/mw_syncpragma.c new file mode 100644 index 0000000..22fd1ab --- /dev/null +++ b/test/mw_syncpragma.c @@ -0,0 +1,28 @@ +#include "mw_test.h" +#include "multiwriter_internal.h" +int main(void) { + char path[256], uri[512]; mw_tmpdb(path, sizeof path, "sync"); + snprintf(uri, sizeof uri, "file:%s?mw=1&mw_log_max_mb=0", path); + sqlite3 *db = NULL; + CHECK_RC(sqlite3_open_v2(uri, &db, SQLITE_OPEN_READWRITE|SQLITE_OPEN_CREATE|SQLITE_OPEN_URI, NULL), SQLITE_OK); + mw_lane *probe=NULL; CHECK_RC(sqlite3_file_control(db,"main",MW_FCNTL_LANE_PTR,&probe),SQLITE_OK); CHECK(probe); + const char *values[]={"OFF","no","false","ON","yes","true","normal","FULL","extra","unknown","4","7","8","255","-1","2147483648","0xFFFFFFFF","0x2","002"}; + for(unsigned i=0;isync_level); + CHECK(probe->sync_level==mw_scalar(db,"PRAGMA synchronous")); + } + CHECK_RC(mw_exec(db, "CREATE TABLE t(v); PRAGMA synchronous=FULL; BEGIN; INSERT INTO t VALUES(1)"), SQLITE_OK); + CHECK_RC(mw_exec(db, "PRAGMA synchronous=OFF"), SQLITE_ERROR); + mw_lane *lane = NULL; CHECK_RC(sqlite3_file_control(db,"main",MW_FCNTL_LANE_PTR,&lane), SQLITE_OK); + printf("rejected OFF: SQLite=%lld engine=%d\n", (long long)mw_scalar(db,"PRAGMA synchronous"),lane->sync_level); + CHECK(lane->sync_level == mw_scalar(db,"PRAGMA synchronous")); + mw_db_stats before, after; + CHECK_RC(sqlite3_file_control(db,"main",MW_FCNTL_DBSTATS,&before),SQLITE_OK); + CHECK_RC(mw_exec(db,"COMMIT"), SQLITE_OK); + CHECK_RC(sqlite3_file_control(db,"main",MW_FCNTL_DBSTATS,&after),SQLITE_OK); + printf("FULL commit: log sync delta=%llu\n",(unsigned long long)(after.log_syncs-before.log_syncs)); + CHECK(after.log_syncs > before.log_syncs); + sqlite3_close(db); mw_rmdb(path); MW_DONE(); +} diff --git a/test/sanitize.sh b/test/sanitize.sh index 6293b1e..13e36c3 100755 --- a/test/sanitize.sh +++ b/test/sanitize.sh @@ -3,7 +3,7 @@ # test/sanitize.sh asan|ubsan|tsan [test ...] (from the repository root) # asan: memory errors; ubsan: undefined behaviour; tsan: data races (SQLite itself and the test programs are reported too: read the first frames, # only the multiwriter_*.c ones concern the engine). Default tests: the transaction ones. -set -e +set -euo pipefail KIND=${1:-asan}; shift || true case $KIND in asan) SAN="-fsanitize=address";; @@ -12,17 +12,32 @@ case $KIND in *) echo "usage: $0 asan|ubsan|tsan [tests]"; exit 2;; esac OUT=build/$KIND; BIN=dist/$KIND; mkdir -p $OUT $BIN; rm -f $OUT/*.o -CF="-O1 -g $SAN -fno-omit-frame-pointer -Isrc -Ithird_party/sqlite -DSQLITE_DISABLE_PAGECACHE_OVERFLOW_STATS" +CF="-O1 -g $SAN -fno-omit-frame-pointer -Itest -Isrc -Ithird_party/sqlite -DSQLITE_DISABLE_PAGECACHE_OVERFLOW_STATS" [ "$(uname)" = Linux ] && CF="$CF -D_GNU_SOURCE" for f in src/*.c; do cc -w $CF -c $f -o $OUT/$(basename $f .c).o; done cc -w -O1 -g $SAN -fno-omit-frame-pointer -Ithird_party/sqlite -DSQLITE_EXTRA_INIT=mw_extra_init -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_RTREE -DSQLITE_CORE -c third_party/sqlite/sqlite3.c -o $OUT/sqlite3.o LIBS=$(ls $OUT/*.o | grep -v "/t_") EXTRA=""; [ "$(uname)" = Darwin ] && EXTRA="-framework Security" -TESTS=${@:-"mw_serial mw_relocprep mw_reloc mw_lanes"} +TESTS=${@:-"mw_serial mw_relocprep mw_reloc mw_lanes mw_rebaseneg mw_attachsync"} +status=0 for t in $TESTS; do b=$(basename $t) cc -w $CF -c test/$t.c -o $OUT/t_$b.o - cc $OUT/t_$b.o $LIBS -o $BIN/$b $EXTRA $SAN -lpthread -lm + TEST_LIBS=$LIBS + case $b in + mw_stagednormal) + cc -w $CF -Dpwritev=inject_pwritev -c src/multiwriter_log.c -o $OUT/injected.o + TEST_LIBS="${LIBS//$OUT\/multiwriter_log.o/} $OUT/injected.o";; + mw_holesync) + cc -w $CF -Dfsync=inject_fsync -c src/multiwriter_log.c -o $OUT/injected.o + TEST_LIBS="${LIBS//$OUT\/multiwriter_log.o/} $OUT/injected.o";; + mw_rowdiffoom) + cc -w $CF -Dmalloc=inject_malloc -Dcalloc=inject_calloc -Drealloc=inject_realloc -c src/multiwriter_rowdiff.c -o $OUT/injected.o + TEST_LIBS="${LIBS//$OUT\/multiwriter_rowdiff.o/} $OUT/injected.o";; + esac + cc $OUT/t_$b.o $TEST_LIBS -o $BIN/$b $EXTRA $SAN -lpthread -lm echo "== $KIND: $t" - ./$BIN/$b 2>&1 | tail -${TAIL:-6} + r=0; ./$BIN/$b 2>&1 | tail -${TAIL:-6} || r=$? # (pipefail: the status of the test, not of tail; the other tests still run) + [ $r -eq 0 ] || { echo "== $KIND: $t FAILED ($r)"; [ $status -ne 0 ] || status=$r; } done +exit $status diff --git a/test/sanitize_exit.sh b/test/sanitize_exit.sh new file mode 100644 index 0000000..612b776 --- /dev/null +++ b/test/sanitize_exit.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# A failing executable must survive the sanitizer runner's output pipeline. +set -euo pipefail +runner=$(cd "$(dirname "$0")" && pwd)/sanitize.sh +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +mkdir -p "$scratch"/{src,test,third_party/sqlite,tools} +touch "$scratch/src/dummy.c" "$scratch/test/dummy.c" "$scratch/third_party/sqlite/sqlite3.c" +cat > "$scratch/tools/cc" <<'CC' +#!/bin/bash +while [[ $# -gt 0 ]]; do + if [[ $1 == -o ]]; then + shift + printf '#!/bin/sh\nexit 7\n' > "$1" + chmod +x "$1" + exit 0 + fi + shift +done +exit 2 +CC +chmod +x "$scratch/tools/cc" +set +e +(cd "$scratch" && PATH="$scratch/tools:$PATH" bash "$runner" ubsan dummy) +rc=$? +set -e +[[ $rc == 7 ]] || { echo "sanitizer runner masked exit 7: got $rc"; exit 1; } +echo 'sanitizer exit propagation: passed'