From a8232abe41cc168fb1596eb16047133fddd7a9ad Mon Sep 17 00:00:00 2001 From: eunhhu Date: Mon, 28 Sep 2026 09:37:36 +0100 Subject: Save a list as its items and a journal of its changes since --- core/module-list.c | 222 ++++++++++++++++++++++----------------- core/module-list.h | 29 +++-- core/module-save.c | 149 +++++++++++++++++++------- core/module-save.h | 16 +-- core/module.c | 35 +++--- core/module.h | 4 +- core/test-module.c | 85 ++++++++++++--- docs/design/008-module-format.md | 4 +- 8 files changed, 358 insertions(+), 186 deletions(-) diff --git a/core/module-list.c b/core/module-list.c index 456a72e..4d90d32 100644 --- a/core/module-list.c +++ b/core/module-list.c @@ -50,8 +50,11 @@ struct PureList { Layout layout; Node *root; /* NULL when the list has no item */ size_t count; - PureDraft *draft; /* the stored copy, or NULL */ - size_t header; /* draft bytes before the first item */ + /* A stored list's changes since they were last taken, as records + * (pure_list_replay); none are kept while journaled is false. */ + bool journaled; + unsigned char *journal; + size_t journal_size, journal_capacity; uint64_t id, version; /* which list, and a number every change moves on */ uint64_t shares; /* copies made so far: a node or text made before the last one is shared */ PureRetired *retired; /* where shared nodes and texts go when the list lets them go */ @@ -193,12 +196,6 @@ static void drop_node(PureList *list, Node *node) { static size_t cell_bytes(const Layout *l, unsigned field, PureCell cell) { return l->text_fields[field] ? 4 + cell.size : 8; } -static size_t item_bytes(const Layout *l, const Node *leaf, unsigned i) { - size_t size = l->number_bytes; - for (unsigned t = 0; t < l->texts; ++t) size += 4 + texts(l, leaf, l->text_field[t])[i].size; - return size; -} - /* A node's summary, from its items or children. */ static void sum(const PureList *list, Node *node) { const Layout *l = &list->layout; @@ -241,27 +238,6 @@ static const Node *leaf_of(const PureList *list, size_t at, size_t *within) { return node; } -/* Where an item starts in the draft: the bytes of the children before it, - * level by level. */ -static size_t item_offset(const PureList *list, size_t at) { - const Layout *l = &list->layout; - size_t offset = list->header; - const Node *node = list->root; - if (!node) return offset; - while (!node->leaf) { - unsigned i = 0; - while (i + 1 < node->count && at >= child(node, i)->items) { - at -= child(node, i)->items; - offset += child(node, i)->bytes; - ++i; - } - node = child(node, i); - } - if (!l->texts) return offset + (at < node->count ? at : node->count) * l->number_bytes; - for (unsigned i = 0; i < at && i < node->count; ++i) offset += item_bytes(l, node, i); - return offset; -} - /* The node at *place, the list's own to change: a copy (the node retired) * while a shared copy of the list may read it. NULL without memory. A copy * moves the version on even when the change it is for does not happen: the @@ -481,6 +457,7 @@ void pure_list_free(PureList *list) { if (list->copy) { if (__atomic_sub_fetch(&list->refs, 1, __ATOMIC_ACQ_REL)) return; } else { + free(list->journal); let_go_tree(list, list->root); for (unsigned kind = 0; kind < 2; ++kind) while (list->spare[kind]) { @@ -553,17 +530,66 @@ static unsigned char *encode_cell(const Layout *l, unsigned field, PureCell cell return out; } -/* Replace remove bytes of the draft, within bytes into the item at `at` (or - * after the last item), with bytes; nothing to do without a draft. */ -static PureResult mirror(PureList *list, size_t at, size_t within, size_t remove, const unsigned char *bytes, - size_t size) { - if (!list->draft) return PURE_OK; - return pure_draft_splice(list->draft, item_offset(list, at) + within, remove, (PureSpan){bytes, size}); +/* A stored cell of field at *at in bytes (a text's bytes borrowed from + * them): false when it is cut short, not finite or not UTF-8. */ +static bool decode_cell(const Layout *l, unsigned field, const unsigned char *bytes, size_t size, size_t *at, + PureCell *cell) { + size_t width = l->text_fields[field] ? 4 : 8; + if (size - *at < width) return false; + uint64_t bits = 0; + for (size_t i = 0; i < width; ++i) bits |= (uint64_t)bytes[*at + i] << (8 * i); + *at += width; + *cell = (PureCell){0}; + if (!l->text_fields[field]) { + memcpy(&cell->num, &bits, sizeof(bits)); + return isfinite(cell->num); + } + if (bits > size - *at || !pure_utf8_valid((PureSpan){bytes + *at, (size_t)bits})) return false; + *cell = (PureCell){0, (unsigned char *)bytes + *at, (size_t)bits}; + *at += (size_t)bits; + return true; } -void pure_list_mirror(PureList *list, PureDraft *draft, size_t header) { - list->draft = draft; - list->header = header; +/* ---- The journal: records of a stored list's changes, in the order they + * were made. Each is a u8 kind and the u32le position, then for an insert + * the item's fields in stored form, for a set the u8 field and its cell. */ + +enum { RECORD_INSERT = 1, RECORD_REMOVE = 2, RECORD_SET = 3, RECORD_HEAD = 5, JOURNAL_KEPT = 1 << 20 }; + +/* Room for size more bytes at the journal's end, or NULL without memory. */ +static unsigned char *journal_room(PureList *list, size_t size) { + if (list->journal_capacity - list->journal_size < size) { + size_t capacity = list->journal_capacity ? 2 * list->journal_capacity : 256; + while (capacity - list->journal_size < size) capacity *= 2; + unsigned char *grown = realloc(list->journal, capacity); + if (!grown) return NULL; + list->journal = grown; + list->journal_capacity = capacity; + } + return list->journal + list->journal_size; +} + +static unsigned char *record_head(unsigned char *out, unsigned kind, size_t at) { + *out++ = (unsigned char)kind; + for (unsigned i = 0; i < 4; ++i) *out++ = (unsigned char)(at >> (8 * i)); + return out; +} + +void pure_list_journal(PureList *list, bool journaled) { + list->journaled = journaled; + if (!journaled) pure_list_forget(list, 0); +} + +PureSpan pure_list_changes(const PureList *list) { return (PureSpan){list->journal, list->journal_size}; } + +void pure_list_forget(PureList *list, size_t size) { + if (size >= list->journal_size) return; + list->journal_size = size; + if (!size && list->journal_capacity > JOURNAL_KEPT) { /* a bulk load's room goes back */ + free(list->journal); + list->journal = NULL; + list->journal_capacity = 0; + } } /* Every leaf in order, to visit (false stops it). */ @@ -584,44 +610,58 @@ static bool encode_leaf(void *context, const Node *leaf) { return true; } -PURE_COLD PureResult pure_list_write(PureList *list) { - size_t size = list->root ? list->root->bytes : 0; - unsigned char *bytes = size ? malloc(size) : NULL; - if (size && !bytes) return PURE_ALLOCATION; - Encoding encoding = {&list->layout, bytes}; +size_t pure_list_size(const PureList *list) { return list->root ? list->root->bytes : 0; } + +void pure_list_encode(const PureList *list, unsigned char *out) { + Encoding encoding = {&list->layout, out}; visit_leaves(list->root, encode_leaf, &encoding); - PureResult result = mirror(list, 0, 0, 0, bytes, size); - free(bytes); - return result; +} + +/* An item's cells in stored form at *at in bytes. */ +static bool decode_item(const Layout *l, const unsigned char *bytes, size_t size, size_t *at, PureCell *cells) { + for (unsigned f = 0; f < l->fields; ++f) + if (!decode_cell(l, f, bytes, size, at, &cells[f])) return false; + return true; } PURE_COLD PureResult pure_list_read(PureList *list, const unsigned char *bytes, size_t size) { - const Layout *l = &list->layout; PureCell cells[16]; for (size_t at = 0; at < size;) { - for (unsigned f = 0; f < l->fields; ++f) { - size_t width = l->text_fields[f] ? 4 : 8; - if (size - at < width) return PURE_STORE_ERROR; - uint64_t bits = 0; - for (size_t i = 0; i < width; ++i) bits |= (uint64_t)bytes[at + i] << (8 * i); - at += width; - if (!l->text_fields[f]) { - cells[f] = (PureCell){0}; - memcpy(&cells[f].num, &bits, sizeof(bits)); - if (!isfinite(cells[f].num)) return PURE_STORE_ERROR; - continue; - } - if (bits > size - at || !pure_utf8_valid((PureSpan){bytes + at, (size_t)bits})) - return PURE_STORE_ERROR; - cells[f] = (PureCell){0, (unsigned char *)bytes + at, (size_t)bits}; - at += (size_t)bits; - } + if (!decode_item(&list->layout, bytes, size, &at, cells)) return PURE_STORE_ERROR; PureResult result = pure_list_insert(list, list->count, cells, NULL); if (result != PURE_OK) return result; } return PURE_OK; } +PURE_COLD PureResult pure_list_replay(PureList *list, const unsigned char *bytes, size_t size) { + const Layout *l = &list->layout; + bool journaled = list->journaled; + PureResult result = PURE_OK; + list->journaled = false; + for (size_t at = 0; at < size && result == PURE_OK;) { + if (size - at < RECORD_HEAD) { result = PURE_STORE_ERROR; break; } + unsigned kind = bytes[at]; + size_t position = 0; + for (unsigned i = 0; i < 4; ++i) position |= (size_t)bytes[at + 1 + i] << (8 * i); + at += RECORD_HEAD; + PureCell cells[16]; + if (kind == RECORD_INSERT) + result = position <= list->count && decode_item(l, bytes, size, &at, cells) ? + pure_list_insert(list, position, cells, NULL) : PURE_STORE_ERROR; + else if (kind == RECORD_REMOVE) + result = position < list->count ? pure_list_remove(list, position, NULL) : PURE_STORE_ERROR; + else if (kind == RECORD_SET) { + unsigned field = at < size ? bytes[at++] : l->fields; + result = field < l->fields && position < list->count && decode_cell(l, field, bytes, size, &at, &cells[0]) ? + pure_list_set(list, position, field, cells[0], NULL) : PURE_STORE_ERROR; + } else + result = PURE_STORE_ERROR; + } + list->journaled = journaled; + return result; +} + PureResult pure_list_insert(PureList *list, size_t at, const PureCell *cells, PureUndo *undo) { const Layout *l = &list->layout; if (at > list->count || list->count == PURE_LIST_ITEMS) return PURE_LIMIT; @@ -629,29 +669,23 @@ PureResult pure_list_insert(PureList *list, size_t at, const PureCell *cells, Pu unsigned owned = 0; for (; owned < l->fields; ++owned) if (!own_cell(list, owned, cells[owned], &item[owned])) break; - size_t size = 0; - for (unsigned f = 0; list->draft && f < l->fields; ++f) size += cell_bytes(l, f, cells[f]); - unsigned char *bytes = size ? malloc(size) : NULL, *end = bytes; + size_t size = RECORD_HEAD; + for (unsigned f = 0; list->journaled && f < l->fields; ++f) size += cell_bytes(l, f, cells[f]); + unsigned char *record = list->journaled ? journal_room(list, size) : NULL; Place p; - if (owned < l->fields || (size && !bytes) || !prepare(list, at, &p)) { - free(bytes); + if (owned < l->fields || (list->journaled && !record) || !prepare(list, at, &p)) { for (unsigned f = 0; f < owned; ++f) free_text(item[f].text); return PURE_ALLOCATION; } - for (unsigned f = 0; bytes && f < l->fields; ++f) end = encode_cell(l, f, item[f], end); if (undo && !log_change(undo, (Entry){.change = INSERTED, .list = list, .at = at})) { release_extras(list, &p); - free(bytes); for (unsigned f = 0; f < l->fields; ++f) free_text(item[f].text); return PURE_ALLOCATION; } - PureResult result = mirror(list, at, 0, 0, bytes, size); - free(bytes); - if (result != PURE_OK) { - if (undo) --undo->count; - release_extras(list, &p); - for (unsigned f = 0; f < l->fields; ++f) free_text(item[f].text); - return result; + if (record) { + unsigned char *end = record_head(record, RECORD_INSERT, at); + for (unsigned f = 0; f < l->fields; ++f) end = encode_cell(l, f, item[f], end); + list->journal_size += size; } place(list, &p, item); return PURE_OK; @@ -677,15 +711,16 @@ PureResult pure_list_remove(PureList *list, size_t at, PureUndo *undo) { unsigned within, depth = descend(list, at, path, &within); if (!depth) return PURE_ALLOCATION; Node *leaf = path[depth - 1].node; + unsigned char *record = list->journaled ? journal_room(list, RECORD_HEAD) : NULL; PureCell *cells = undo ? log_cells(undo, l->fields) : NULL, kept[16]; - if (undo && (!cells || !log_change(undo, (Entry){.change = REMOVED, .list = list, .at = at, .cells = undo->cell_count}))) + if ((list->journaled && !record) || + (undo && (!cells || !log_change(undo, (Entry){.change = REMOVED, .list = list, .at = at, .cells = undo->cell_count})))) return PURE_ALLOCATION; if (!cells) cells = kept; for (unsigned f = 0; f < l->fields; ++f) cells[f] = cell_at(l, leaf, within, f); - PureResult result = mirror(list, at, 0, item_bytes(l, leaf, within), NULL, 0); - if (result != PURE_OK) { - if (undo) --undo->count; - return result; + if (record) { + record_head(record, RECORD_REMOVE, at); + list->journal_size += RECORD_HEAD; } if (undo) undo->cell_count += l->fields; else let_go_cells(list, cells); @@ -702,23 +737,18 @@ PureResult pure_list_set(PureList *list, size_t at, unsigned field, PureCell val if (!depth || !own_cell(list, field, value, ©)) return PURE_ALLOCATION; Node *leaf = path[depth - 1].node; PureCell old = cell_at(l, leaf, within, field); - size_t size = list->draft ? cell_bytes(l, field, copy) : 0; - unsigned char *bytes = size ? malloc(size) : NULL; - if ((size && !bytes) || + size_t size = RECORD_HEAD + 1 + cell_bytes(l, field, copy); + unsigned char *record = list->journaled ? journal_room(list, size) : NULL; + if ((list->journaled && !record) || (undo && !log_change(undo, (Entry){.change = CHANGED, .list = list, .at = at, .field = field, .old = old}))) { - free(bytes); free_text(copy.text); return PURE_ALLOCATION; } - if (bytes) encode_cell(l, field, copy, bytes); - size_t offset = 0; - for (unsigned f = 0; f < field; ++f) offset += cell_bytes(l, f, cell_at(l, leaf, within, f)); - PureResult result = mirror(list, at, offset, cell_bytes(l, field, old), bytes, size); - free(bytes); - if (result != PURE_OK) { - if (undo) --undo->count; - free_text(copy.text); - return result; + if (record) { + unsigned char *end = record_head(record, RECORD_SET, at); + *end++ = (unsigned char)field; + encode_cell(l, field, copy, end); + list->journal_size += size; } if (l->text_fields[field]) texts(l, leaf, field)[within] = (PureSpan){copy.text, copy.size}; else numbers(l, leaf, field)[within] = copy.num; diff --git a/core/module-list.h b/core/module-list.h index d29fe95..b9d5a92 100644 --- a/core/module-list.h +++ b/core/module-list.h @@ -86,16 +86,27 @@ size_t pure_list_seek(const PureList *, unsigned field, PureCell key, bool *foun * before] = position after for every item. CONFLICT when two items have the * same key (nothing is made). */ PureResult pure_list_sorted(const PureList *, unsigned field, PureList **out, size_t *moved); -/* A list may keep a stored copy in a draft: header bytes, then each item's - * fields in order (a number as its binary64 bits, little-endian; a text as a - * u32le length and its bytes). Every change splices the draft as well, and a - * change the draft refuses (such as its size limit) does not happen. Undoing - * an event changes only the list: the caller restores the draft's version. */ -void pure_list_mirror(PureList *, PureDraft *, size_t header); -/* Store every item after the header (the draft holds just the header). */ -PureResult pure_list_write(PureList *); -/* Append the items stored in bytes; each field is checked. Before mirroring. */ +/* The stored form of items: each item's fields in order, a number as its + * binary64 bits (little-endian), a text as a u32le length and its bytes. + * pure_list_size is the bytes of all the items so stored, and + * pure_list_encode writes them to out. */ +size_t pure_list_size(const PureList *); +void pure_list_encode(const PureList *, unsigned char *out); +/* Append the items stored in bytes; each field is checked. */ PureResult pure_list_read(PureList *, const unsigned char *bytes, size_t size); +/* A stored list journals its changes (from pure_list_journal(list, true) + * on): every insert, remove and set adds a record, a u8 kind (1 insert, 2 + * remove, 3 set) and the u32le position, then an insert's item or a set's + * u8 field and cell in stored form. A change there is no room to record does + * not happen. pure_list_changes are the records since pure_list_forget last + * kept none; forgetting down to a length taken earlier drops the records of + * changes undone since (an undo does not journal). pure_list_replay makes + * the changes of records again, in order: a list read from its stored items + * and given its journal is the list that wrote them. */ +void pure_list_journal(PureList *, bool journaled); +PureSpan pure_list_changes(const PureList *); +void pure_list_forget(PureList *, size_t size); +PureResult pure_list_replay(PureList *, const unsigned char *bytes, size_t size); /* Undo the changes logged after mark (a count taken earlier), newest first. */ void pure_undo_rollback(PureUndo *, size_t mark); void pure_undo_commit(PureUndo *); diff --git a/core/module-save.c b/core/module-save.c index 4149316..0a4beaf 100644 --- a/core/module-save.c +++ b/core/module-save.c @@ -6,10 +6,19 @@ #include /* Stored forms. values: "PVS1", then per state {u8 type, u8 name length, - * name, binary64 bits (little-endian)}. A list: "PLS1", u8 field count, per - * field {u8 0 number | 1 text, u8 name length, name}, then its items - * (module-list.h). A text state: its UTF-8 bytes. */ -static const unsigned char values_magic[4] = {'P', 'V', 'S', '1'}, list_magic[4] = {'P', 'L', 'S', '1'}; + * name, binary64 bits (little-endian)}. A list: "PLS2", u8 field count, per + * field {u8 0 number | 1 text, u8 name length, name}, u64le the bytes of its + * items, its items, then its journal's records to the end (module-list.h): + * a save appends the records of the list's changes, and rewrites the items + * with no records once the records outgrow them. "PLS1" (read, rewritten on + * start) had no length, no records: its items reached the end. A text state: + * its UTF-8 bytes. */ +static const unsigned char values_magic[4] = {'P', 'V', 'S', '1'}, list_magic[4] = {'P', 'L', 'S', '2'}, + list_magic_1[4] = {'P', 'L', 'S', '1'}; +/* Records a list keeps before its items are stored again: at least this, + * else as many bytes as its items. So a save writes what changed, and the + * list's stored bytes stay within twice its items (and this). */ +enum { JOURNAL_MIN = 1 << 16 }; /* links: "PLK1"; u8 outputs, each {u8 name length, name, u8 field count, the * fields' types, u64le last number} (the module's outputs, then those it * dropped, without fields); u16le sources, each {u8 length, the name, u64le @@ -18,6 +27,17 @@ static const unsigned char values_magic[4] = {'P', 'V', 'S', '1'}, list_magic[4] static const unsigned char links_magic[4] = {'P', 'L', 'K', '1'}; static const PureVersion unsaved = {UINT64_MAX, UINT64_MAX}; +static unsigned char *put_le(unsigned char *at, uint64_t value, unsigned width) { + for (unsigned i = 0; i < width; ++i) *at++ = (unsigned char)(value >> (8 * i)); + return at; +} + +static uint64_t get_le(const unsigned char *at, unsigned width) { + uint64_t value = 0; + for (unsigned i = 0; i < width; ++i) value |= (uint64_t)at[i] << (8 * i); + return value; +} + bool pure_save_enabled(const PureApp *app) { return app->data.state && app->data.save; } static PureResult open_state(PureApp *app, const char *prefix, const char *name, PureDraft **out) { @@ -88,7 +108,7 @@ PURE_COLD PureResult pure_save_open_text(PureApp *app, const PureModule *m, uint return result; } -static size_t list_header(const PureModule *m, uint32_t record, unsigned char *out) { +static size_t list_header(const PureModule *m, uint32_t record, uint64_t items, unsigned char *out) { const PureRecord *r = &m->record[record]; size_t at = 0; memcpy(out, list_magic, 4); @@ -101,27 +121,24 @@ static size_t list_header(const PureModule *m, uint32_t record, unsigned char *o memcpy(out + at, name, strlen(name)); at += strlen(name); } + for (unsigned b = 0; b < 8; ++b) out[at++] = (unsigned char)(items >> (8 * b)); return at; } -PURE_COLD PureResult pure_save_rewrite_list(const PureModule *m, uint32_t state, PureList *list, PureDraft *draft) { - unsigned char header[5 + PURE_RECORD_FIELDS * (2 + PURE_MODULE_NAME)]; - size_t length = list_header(m, m->state_ref[state], header); - PureVersion before = pure_draft_version(draft); - pure_draft_hold(draft, true); - PureResult result = pure_draft_splice(draft, 0, pure_draft_size(draft), (PureSpan){header, length}); - pure_list_mirror(list, draft, length); - if (result == PURE_OK) result = pure_list_write(list); - if (result != PURE_OK) { - pure_draft_restore(draft, before); - pure_list_mirror(list, NULL, 0); - } - pure_draft_hold(draft, false); +PureResult pure_save_rewrite_list(const PureModule *m, uint32_t state, const PureList *list, PureDraft *draft) { + size_t items = pure_list_size(list), length; + unsigned char header[5 + PURE_RECORD_FIELDS * (2 + PURE_MODULE_NAME) + 8], *bytes = malloc(sizeof(header) + items); + if (!bytes) return PURE_ALLOCATION; + length = list_header(m, m->state_ref[state], items, header); + memcpy(bytes, header, length); + pure_list_encode(list, bytes + length); + PureResult result = pure_draft_splice(draft, 0, pure_draft_size(draft), (PureSpan){bytes, length + items}); + free(bytes); return result; } PURE_COLD PureResult pure_save_open_list(PureApp *app, const PureModule *m, uint32_t state, PureList **out, PureDraft **draft, - PureVersion *saved) { + PureVersion *saved, size_t *journaled) { uint32_t record = m->state_ref[state]; const PureRecord *r = &m->record[record]; *out = NULL; @@ -134,30 +151,36 @@ PURE_COLD PureResult pure_save_open_list(PureApp *app, const PureModule *m, uint char names[PURE_RECORD_FIELDS][PURE_MODULE_NAME] = {{0}}; uint8_t text[PURE_RECORD_FIELDS] = {0}; unsigned count = 0; - size_t at = 0; - bool stored = result == PURE_OK && bytes; + size_t at = 0, items = 0; + bool stored = result == PURE_OK && bytes, first = stored && size >= 4 && !memcmp(bytes, list_magic_1, 4); if (stored) { - bool ok = size >= 5 && !memcmp(bytes, list_magic, 4) && bytes[4] <= PURE_RECORD_FIELDS; + bool ok = size >= 5 && (first || !memcmp(bytes, list_magic, 4)) && bytes[4] <= PURE_RECORD_FIELDS; count = ok ? bytes[4] : 0; at = 5; for (unsigned f = 0; ok && f < count; ++f) { ok = named(bytes, size, &at, 0, &text[f], names[f]) && text[f] <= 1; for (unsigned k = 0; k < f; ++k) ok = ok && strcmp(names[k], names[f]); } + if (ok && !first) { + ok = size - at >= 8 && (items = (size_t)get_le(bytes + at, 8)) <= size - at - 8; + at += 8; + } else items = size - at; if (!ok) result = PURE_STORE_ERROR; } + /* The stored items, then the changes since they were stored. */ PureList *from = NULL; if (result == PURE_OK && stored) { from = pure_list_new(count, text); - result = from ? pure_list_read(from, bytes + at, size - at) : PURE_ALLOCATION; + result = from ? pure_list_read(from, bytes + at, items) : PURE_ALLOCATION; + if (result == PURE_OK) result = pure_list_replay(from, bytes + at + items, size - at - items); } + *journaled = result == PURE_OK && stored ? size - at - items : 0; free(bytes); bool same = stored && count == r->count; for (unsigned f = 0; same && f < count; ++f) same = text[f] == m->record_field_text[r->first + f] && !strcmp(names[f], m->record_field_names[r->first + f]); - if (result == PURE_OK && same) { + if (result == PURE_OK && same && !first) { *out = from; - pure_list_mirror(from, *draft, at); *saved = pure_draft_version(*draft); return PURE_OK; } @@ -165,10 +188,14 @@ PURE_COLD PureResult pure_save_open_list(PureApp *app, const PureModule *m, uint /* New, or stored with another record: its items move by field name. */ const char *field_names[PURE_RECORD_FIELDS]; for (unsigned f = 0; f < count; ++f) field_names[f] = names[f]; - if (from) result = pure_list_migrate(out, from, field_names, text, count, m, record); + if (same) { /* stored in the earlier form: the same items, stored again */ + *out = from; + from = NULL; + } else if (from) result = pure_list_migrate(out, from, field_names, text, count, m, record); else if (!(*out = pure_list_new(r->count, m->record_field_text + r->first))) result = PURE_ALLOCATION; } pure_list_free(from); + *journaled = 0; if (result == PURE_OK) result = pure_save_rewrite_list(m, state, *out, *draft); if (result != PURE_OK) { pure_list_free(*out); @@ -258,17 +285,6 @@ PURE_COLD PureResult pure_save_open_values(PureApp *app, const PureModule *m, do static bool same_version(PureVersion a, PureVersion b) { return a.ref == b.ref && a.bytes == b.bytes; } -static unsigned char *put_le(unsigned char *at, uint64_t value, unsigned width) { - for (unsigned i = 0; i < width; ++i) *at++ = (unsigned char)(value >> (8 * i)); - return at; -} - -static uint64_t get_le(const unsigned char *at, unsigned width) { - uint64_t value = 0; - for (unsigned i = 0; i < width; ++i) value |= (uint64_t)at[i] << (8 * i); - return value; -} - /* The links as stored; NULL when memory runs out. */ static unsigned char *encode_links(const PureApp *app, size_t *size) { const PureModule *m = app->module; @@ -406,6 +422,23 @@ PURE_COLD PureResult pure_save_open_links(PureApp *app, const PureModule *m) { return result; } +/* After a save of lists (the first count of states): kept, each list's + * changes are stored (its journal empties), or, failed, each draft is as it + * was; the holds the save took go either way. */ +static void settle_lists(PureApp *app, PureDraft *const *drafts, const uint32_t *states, size_t count, + const PureVersion *before, const size_t *journaled, bool saved) { + for (size_t k = 0; k < count; ++k) { + uint32_t i = states[k]; + if (app->module->state_type[i] != PURE_STATE_LIST) continue; + if (!saved) pure_draft_restore(drafts[k], before[i]); + else { + pure_list_forget(app->lists[i], 0); + app->journaled[i] = journaled[i]; + } + pure_draft_hold(drafts[k], false); + } +} + PureResult pure_save_commit(PureApp *app, const double *values, void *const *documents, size_t document_count, int64_t *revisions) { if (!pure_save_enabled(app)) { @@ -422,11 +455,38 @@ PureResult pure_save_commit(PureApp *app, const double *values, void *const *doc for (uint32_t i = 0; i < m->states; ++i) { PureDraft *draft = m->state_type[i] == PURE_STATE_TEXT ? app->texts[i] : m->state_type[i] == PURE_STATE_LIST ? app->list_drafts[i] : NULL; - if (draft && !same_version(pure_draft_version(draft), app->saved[i])) { + if (draft && (!same_version(pure_draft_version(draft), app->saved[i]) || + (m->state_type[i] == PURE_STATE_LIST && pure_list_changes(app->lists[i]).size))) { states[count] = i; drafts[count++] = draft; } } + /* A list's changes go after its stored records, or, once the records + * would outgrow its items, the items are stored again with none. */ + PureVersion lists_before[PURE_MODULE_STATES]; + size_t journaled[PURE_MODULE_STATES]; + for (size_t k = 0; k < count; ++k) { + uint32_t i = states[k]; + if (m->state_type[i] != PURE_STATE_LIST) continue; + PureSpan changes = pure_list_changes(app->lists[i]); + size_t items = pure_list_size(app->lists[i]); + lists_before[i] = pure_draft_version(drafts[k]); + pure_draft_hold(drafts[k], true); + PureResult result = PURE_OK; + if (!changes.size) journaled[i] = app->journaled[i]; + else if (app->journaled[i] + changes.size > (items > JOURNAL_MIN ? items : JOURNAL_MIN)) { + result = pure_save_rewrite_list(m, i, app->lists[i], drafts[k]); + journaled[i] = 0; + } else { + result = pure_draft_splice(drafts[k], pure_draft_size(drafts[k]), 0, changes); + journaled[i] = app->journaled[i] + changes.size; + } + if (result != PURE_OK) { + settle_lists(app, drafts, states, k + 1, lists_before, journaled, false); + return result; + } + } + size_t list_count = count; /* Numbers, cursors and indices: stored again only when their bytes change. */ PureVersion values_before = {0, 0}; bool values_changed = false; @@ -434,14 +494,21 @@ PureResult pure_save_commit(PureApp *app, const double *values, void *const *doc unsigned char encoded[VALUES_BYTES], *stored; size_t length = encode_values(m, values, encoded), size; PureResult result = read_whole(app->stored_values, &stored, &size); - if (result != PURE_OK) return result; + if (result != PURE_OK) { + settle_lists(app, drafts, states, list_count, lists_before, journaled, false); + return result; + } values_changed = size != length || memcmp(stored, encoded, length); free(stored); if (values_changed) { values_before = pure_draft_version(app->stored_values); pure_draft_hold(app->stored_values, true); result = pure_draft_splice(app->stored_values, 0, size, (PureSpan){encoded, length}); - if (result != PURE_OK) { pure_draft_hold(app->stored_values, false); return result; } + if (result != PURE_OK) { + pure_draft_hold(app->stored_values, false); + settle_lists(app, drafts, states, list_count, lists_before, journaled, false); + return result; + } states[count] = UINT32_MAX; drafts[count++] = app->stored_values; } @@ -466,6 +533,7 @@ PureResult pure_save_commit(PureApp *app, const double *values, void *const *doc pure_draft_restore(app->stored_values, values_before); pure_draft_hold(app->stored_values, false); } + settle_lists(app, drafts, states, list_count, lists_before, journaled, false); return result; } links_written = true; @@ -476,6 +544,7 @@ PureResult pure_save_commit(PureApp *app, const double *values, void *const *doc PureResult result = app->data.save(app->data.context, documents, document_count, drafts, count, revisions); for (size_t k = 0; result == PURE_OK && k < count; ++k) if (states[k] != UINT32_MAX) app->saved[states[k]] = pure_draft_version(drafts[k]); + settle_lists(app, drafts, states, list_count, lists_before, journaled, result == PURE_OK); if (values_changed) { if (result != PURE_OK) pure_draft_restore(app->stored_values, values_before); pure_draft_hold(app->stored_values, false); diff --git a/core/module-save.h b/core/module-save.h index bc7adcc..f83e294 100644 --- a/core/module-save.h +++ b/core/module-save.h @@ -4,23 +4,25 @@ /* App states a data host keeps (docs/design/008-module-format.md), internal * to the module executor. Each text state is a stored draft, each list state - * a stored draft its list mirrors, and numbers, cursors and indices are one - * stored value by name. A save publishes what changed, all or nothing. */ + * a stored draft of its items and the records of its changes since, and + * numbers, cursors and indices are one stored value by name. A save + * publishes what changed, all or nothing. */ bool pure_save_enabled(const PureApp *); /* A text state of m: its stored value, or its initial constant when none. */ PureResult pure_save_open_text(PureApp *, const PureModule *m, uint32_t state, PureDraft **out, PureVersion *saved); -/* A list state of m: its stored items, fields moved by name when the stored - * record differs from m's. */ +/* A list state of m: its stored items with its stored changes made again, + * fields moved by name when the stored record differs from m's; journaled is + * the bytes of the records stored after its items. */ PureResult pure_save_open_list(PureApp *, const PureModule *m, uint32_t state, PureList **out, PureDraft **draft, - PureVersion *saved); + PureVersion *saved, size_t *journaled); /* Open the stored numbers, cursors and indices, and put each stored one of the * same name and kind that still fits into values, for the states of m that * kept marks as new (-1); texts and lists are m's. */ PureResult pure_save_open_values(PureApp *, const PureModule *m, double *values, const int *kept, PureDraft *const *texts, PureList *const *lists); -/* Store a list whose record changed in its draft, header and items anew. */ -PureResult pure_save_rewrite_list(const PureModule *m, uint32_t state, PureList *list, PureDraft *draft); +/* Store a list in its draft anew: header and items, no records. */ +PureResult pure_save_rewrite_list(const PureModule *m, uint32_t state, const PureList *list, PureDraft *draft); /* The stored links of a starting app (module.h, PureApp); CONFLICT when an * output with sends left is missing or has other fields. */ PureResult pure_save_open_links(PureApp *, const PureModule *m); diff --git a/core/module.c b/core/module.c index d80b19f..8d889f7 100644 --- a/core/module.c +++ b/core/module.c @@ -2425,8 +2425,9 @@ void pure_app_finish(PureApp *app) { app->arena.used = 0; } -/* The start of a burst: what a failed save returns to. Text and stored list - * drafts stay held until the save, so every version since stays readable. */ +/* The start of a burst: what a failed save returns to. Text drafts stay held + * until the save, so every version since stays readable; a stored list's + * journal is as long as its changes since the last save. */ static void open_burst(PureApp *app) { if (app->open) return; const PureModule *m = app->module; @@ -2438,10 +2439,7 @@ static void open_burst(PureApp *app) { pure_draft_hold(app->texts[i], true); app->durable_texts[i] = pure_draft_version(app->texts[i]); } - if (app->list_drafts[i]) { - pure_draft_hold(app->list_drafts[i], true); - app->durable_lists[i] = pure_draft_version(app->list_drafts[i]); - } + if (app->list_drafts[i]) app->durable_lists[i] = pure_list_changes(app->lists[i]).size; } app->open = true; } @@ -2461,10 +2459,7 @@ static void close_burst(PureApp *app, bool keep) { if (!keep) pure_draft_restore(app->texts[i], app->durable_texts[i]); pure_draft_hold(app->texts[i], false); } - if (app->list_drafts[i]) { - if (!keep) pure_draft_restore(app->list_drafts[i], app->durable_lists[i]); - pure_draft_hold(app->list_drafts[i], false); - } + if (app->list_drafts[i] && !keep) pure_list_forget(app->lists[i], app->durable_lists[i]); } if (keep) pure_undo_commit(&app->undo); else { @@ -2588,7 +2583,8 @@ void pure_app_stop(PureApp *app) { /* Where one event began inside the burst: a failed event returns there. */ typedef struct { - PureVersion texts[PURE_MODULE_STATES], lists[PURE_MODULE_STATES]; + PureVersion texts[PURE_MODULE_STATES]; + size_t lists[PURE_MODULE_STATES]; /* stored lists' journal lengths */ size_t undo, sent; uint64_t last_sent[PURE_MODULE_INPUTS]; bool opened; @@ -2599,7 +2595,7 @@ static void mark_event(PureApp *app, Mark *mark) { open_burst(app); for (uint32_t i = 0; i < app->module->states; ++i) { if (app->texts[i]) mark->texts[i] = pure_draft_version(app->texts[i]); - if (app->list_drafts[i]) mark->lists[i] = pure_draft_version(app->list_drafts[i]); + if (app->list_drafts[i]) mark->lists[i] = pure_list_changes(app->lists[i]).size; } mark->undo = app->undo.count; mark->sent = app->sent_count; @@ -2609,7 +2605,7 @@ static void mark_event(PureApp *app, Mark *mark) { static void undo_event(PureApp *app, const Mark *mark) { for (uint32_t i = 0; i < app->module->states; ++i) { if (app->texts[i]) pure_draft_restore(app->texts[i], mark->texts[i]); - if (app->list_drafts[i]) pure_draft_restore(app->list_drafts[i], mark->lists[i]); + if (app->list_drafts[i]) pure_list_forget(app->lists[i], mark->lists[i]); } pure_undo_rollback(&app->undo, mark->undo); drop_sent(app, mark->sent); @@ -3238,13 +3234,14 @@ PURE_COLD static PureResult open_module(PureApp *app, const PureModule *module) PureDraft *texts[PURE_MODULE_STATES] = {0}, *list_drafts[PURE_MODULE_STATES] = {0}; PureList *lists[PURE_MODULE_STATES] = {0}; PureVersion saved[PURE_MODULE_STATES] = {{0, 0}}, before[PURE_MODULE_STATES] = {{0, 0}}; + size_t journaled[PURE_MODULE_STATES] = {0}; int kept[PURE_MODULE_STATES]; bool rewrite[PURE_MODULE_STATES] = {false}; PureResult result = PURE_OK; for (uint32_t i = 0; i < module->states; ++i) { int previous = old ? pure_module_state(old, module->state_names[i]) : -1; kept[i] = previous >= 0 && old->state_type[previous] == module->state_type[i] ? previous : -1; - if (kept[i] >= 0) saved[i] = app->saved[kept[i]]; + if (kept[i] >= 0) saved[i] = app->saved[kept[i]], journaled[i] = app->journaled[kept[i]]; if (module->state_type[i] == PURE_STATE_TEXT) { if (kept[i] >= 0) texts[i] = app->texts[kept[i]]; else if (result == PURE_OK) @@ -3258,7 +3255,7 @@ PURE_COLD static PureResult open_module(PureApp *app, const PureModule *module) list_drafts[i] = app->list_drafts[kept[i]]; rewrite[i] = result == PURE_OK && list_drafts[i] && lists[i] != app->lists[kept[i]]; } else if (stored && !(module->state_flags[i] & PURE_LIST_ROWS >> 8)) { /* rows are never stored */ - result = pure_save_open_list(app, module, i, &lists[i], &list_drafts[i], &saved[i]); + result = pure_save_open_list(app, module, i, &lists[i], &list_drafts[i], &saved[i], &journaled[i]); } else if (!(lists[i] = new_list(module, module->state_ref[i]))) result = PURE_ALLOCATION; } else values[i] = kept[i] >= 0 ? app->values[kept[i]] : module->initial[i]; } @@ -3306,7 +3303,10 @@ PURE_COLD static PureResult open_module(PureApp *app, const PureModule *module) pure_draft_hold(list_drafts[i], true); } for (uint32_t i = 0; i < module->states && result == PURE_OK; ++i) - if (rewrite[i]) result = pure_save_rewrite_list(module, i, lists[i], list_drafts[i]); + if (rewrite[i]) { + result = pure_save_rewrite_list(module, i, lists[i], list_drafts[i]); + journaled[i] = 0; + } for (uint32_t i = 0; i < module->states; ++i) if (rewrite[i]) { if (result != PURE_OK) pure_draft_restore(list_drafts[i], before[i]); @@ -3340,7 +3340,10 @@ PURE_COLD static PureResult open_module(PureApp *app, const PureModule *module) memcpy(app->texts, texts, sizeof(texts)); memcpy(app->lists, lists, sizeof(lists)); memcpy(app->list_drafts, list_drafts, sizeof(list_drafts)); + memcpy(app->journaled, journaled, sizeof(journaled)); memcpy(app->saved, saved, sizeof(saved)); + for (uint32_t i = 0; i < module->states; ++i) + if (list_drafts[i]) pure_list_journal(lists[i], true); /* a stored list's changes are recorded from now on */ if (old) remap_outputs(app, old, module, true); drop_passes(app); /* kept by instruction of the module they were made for */ app->module = module; diff --git a/core/module.h b/core/module.h index ed6c062..c8a5f3d 100644 --- a/core/module.h +++ b/core/module.h @@ -265,13 +265,15 @@ typedef struct { PureList *lists[PURE_MODULE_STATES]; PureUndo undo; /* list changes of the current event */ PureDraft *list_drafts[PURE_MODULE_STATES]; /* stored lists, when kept */ + size_t journaled[PURE_MODULE_STATES]; /* a stored list's record bytes after its items */ PureDraft *stored_values; /* numbers, cursors and indices, when kept */ PureVersion saved[PURE_MODULE_STATES]; /* text and list versions at the last save */ /* Events since the last save: what a failed save returns to, and the * documents messages opened (held until the save). */ bool deferred, open; double durable_values[PURE_MODULE_STATES]; - PureVersion durable_texts[PURE_MODULE_STATES], durable_lists[PURE_MODULE_STATES]; + PureVersion durable_texts[PURE_MODULE_STATES]; + size_t durable_lists[PURE_MODULE_STATES]; /* each stored list's journal length */ uint64_t durable_ticks, durable_events; struct PurePending { void *document; char key[65]; bool edited; } *pending; /* grows to PURE_APP_PENDING */ unsigned pending_count, pending_capacity; diff --git a/core/test-module.c b/core/test-module.c index 11e7362..777f7dc 100644 --- a/core/test-module.c +++ b/core/test-module.c @@ -994,9 +994,10 @@ static bool collect_bytes(void *context, PureSpan span) { } /* The stored form of the model: a number's binary64 bits, a text's u32le length and bytes. */ -static bool stored_as(PureDraft *draft, const Model *m) { - Grown got = {0}; - CHECK(pure_draft_read(draft, 0, pure_draft_size(draft), collect_bytes, &got) == PURE_OK); +static bool stored_as(const PureList *list, const Model *m) { + Grown got = {pure_list_size(list) ? malloc(pure_list_size(list)) : NULL, pure_list_size(list), 0}; + CHECK(got.bytes || !got.size); + pure_list_encode(list, got.bytes); size_t at = 0; bool same = true; for (size_t i = 0; i < m->count && same; ++i) { @@ -1014,13 +1015,28 @@ static bool stored_as(PureDraft *draft, const Model *m) { return same; } +/* A list read from stored items and given the records stored after them. */ +static PureList *stored_list(const Grown *stored, size_t items) { + static const uint8_t text_fields[2] = {0, 1}; + PureList *list = pure_list_new(2, text_fields); + CHECK(list && pure_list_read(list, stored->bytes, items) == PURE_OK); + if (pure_list_replay(list, stored->bytes + items, stored->size - items) != PURE_OK) { + pure_list_free(list); + return NULL; + } + return list; +} + static void list_trees(void) { static const uint8_t text_fields[2] = {0, 1}; uint32_t seed = 0x5eed1234u; PureList *list = pure_list_new(2, text_fields); - PureDraft *draft = pure_draft_new(); - CHECK(list && draft); - pure_list_mirror(list, draft, 0); + CHECK(list); + pure_list_journal(list, true); + /* What a store would hold: the items as last stored whole, then the + * records of each save since. */ + Grown stored = {0}; + size_t stored_items = 0, saves = 0, rewrites = 0; PureUndo undo = {0}; Model model = {0}, before = {0}; /* Up to four shares at a time, each with what it shared and the items @@ -1032,10 +1048,8 @@ static void list_trees(void) { unsigned first = 0, held = 0, splits_seen = 0; size_t largest = 0, events = 0, undone = 0; for (unsigned event = 0; event < 6000; ++event) { - size_t mark = undo.count; + size_t mark = undo.count, journal = pure_list_changes(list).size; model_copy(&before, &model); - pure_draft_hold(draft, true); - PureVersion version = pure_draft_version(draft); /* Mostly growing to a few thousand, then shrinking back down, so the * tree gains and loses levels. */ unsigned grow = event < 2500 ? 70 : event < 4500 ? 30 : 60; @@ -1067,19 +1081,41 @@ static void list_trees(void) { } } } - if (next_random(&seed) % 5 == 0) { /* undone: the list and its stored copy as before */ + if (next_random(&seed) % 5 == 0) { /* undone: the list and its journal as before */ pure_undo_rollback(&undo, mark); - pure_draft_restore(draft, version); + pure_list_forget(list, journal); model_copy(&model, &before); ++undone; } else pure_undo_commit(&undo); - pure_draft_hold(draft, false); ++events; largest = model.count > largest ? model.count : largest; splits_seen += model.count > 32; if (event % 7 == 0) CHECK(same_items(list, &model)); - if (event % 50 == 0) CHECK(stored_as(draft, &model)); + /* A save every few events: its records after the stored ones, or now + * and then the items stored whole with none after them. */ + if (event % 3 == 0) { + PureSpan changes = pure_list_changes(list); + if (event % 240 == 0) { + stored.size = 0; + Grown whole = {malloc(pure_list_size(list) + 1), 0, 0}; + CHECK(whole.bytes); + pure_list_encode(list, whole.bytes); + collect_bytes(&stored, (PureSpan){whole.bytes, pure_list_size(list)}); + free(whole.bytes); + stored_items = pure_list_size(list); + ++rewrites; + } else + collect_bytes(&stored, changes); + pure_list_forget(list, 0); + ++saves; + } + if (event % 51 == 0) { /* just saved: what a restart finds is the list as it is */ + PureList *restarted = stored_list(&stored, stored_items); + CHECK(restarted && same_items(restarted, &model)); + pure_list_free(restarted); + CHECK(stored_as(list, &model)); + } /* A share now and then, as a snapshot takes one; the oldest goes with * the items and nodes retired while it was the newest. */ if (event % 40 == 20) { @@ -1098,14 +1134,33 @@ static void list_trees(void) { if (event % 25 == 0) for (unsigned i = 0; i < held; ++i) CHECK(same_items(copies[(first + i) % SHARES], &shared[(first + i) % SHARES])); } - CHECK(same_items(list, &model) && stored_as(draft, &model) && largest > 2000 && undone > 500); + CHECK(same_items(list, &model) && stored_as(list, &model) && largest > 2000 && undone > 500 && rewrites > 20); + /* Damaged records are refused: cut short, past the end, of no kind or no field. */ + collect_bytes(&stored, pure_list_changes(list)); + pure_list_forget(list, 0); + PureList *whole = stored_list(&stored, stored_items); + CHECK(whole && same_items(whole, &model)); + pure_list_free(whole); + size_t length = stored.size; + static const unsigned char damaged[][8] = { + {1, 0xff, 0xff, 0xff, 0}, {2, 0xff, 0xff, 0xff, 0}, {9, 0, 0, 0, 0}, {3, 0, 0, 0, 0, 7}, {3, 0, 0, 0, 0, 0, 1}}; + static const size_t damaged_size[] = {5, 5, 5, 6, 7}; + for (unsigned d = 0; d < 5; ++d) { + stored.size = length; + collect_bytes(&stored, (PureSpan){damaged[d], damaged_size[d]}); + CHECK(!stored_list(&stored, stored_items)); + } + CHECK(length > stored_items); + stored.size = length - 1; /* the last record cut short */ + CHECK(!stored_list(&stored, stored_items)); + free(stored.bytes); + printf("{\"journal_saves\":%zu,\"journal_rewrites\":%zu}\n", saves, rewrites); for (; held; --held, first = (first + 1) % SHARES) { CHECK(same_items(copies[first], &shared[first])); pure_list_free(copies[first]); pure_retired_free(&retired[first]); } pure_list_free(list); - pure_draft_close(draft); pure_undo_free(&undo); /* A keyed list: items kept in key order, found by key. */ diff --git a/docs/design/008-module-format.md b/docs/design/008-module-format.md index 86d0850..233f39b 100644 --- a/docs/design/008-module-format.md +++ b/docs/design/008-module-format.md @@ -264,7 +264,7 @@ text·리스트가 남을 때만 위치를 유지한다. | 상태 | 저장 형태 (런타임 이름공간, 문서 키와 겹치지 않음) | |---|---| | text | `text-이름`: UTF-8 바이트 그대로. 앱의 draft가 곧 저장 문서라 입력 한 글자는 바뀐 익스텐트만 추가한다 | -| 리스트 | `list-이름`: `PLS1`, 레코드 필드 수, 필드마다 종류·이름, 그다음 항목(수는 binary64, text는 u32 길이와 바이트). 리스트 연산이 메모리의 항목과 draft를 함께 splice한다 | +| 리스트 | `list-이름`: `PLS2`, 레코드 필드 수, 필드마다 종류·이름, 항목의 바이트 수(u64), 항목(수는 binary64, text는 u32 길이와 바이트), 그 뒤 끝까지 변경 기록(저널). 기록은 종류(u8: 1 삽입, 2 제거, 3 설정)와 위치(u32), 그리고 삽입이면 항목, 설정이면 필드(u8)와 값이다. 리스트 연산은 메모리의 항목을 바꾸고 기록을 남긴다. 저장은 기록을 문서 끝에 붙인다. 붙은 기록이 항목의 바이트 수(최소 64 KiB)를 넘게 되면 그 저장은 항목을 새로 쓰고 기록을 비운다. 시작은 항목을 읽고 기록을 차례로 다시 적용한다. `PLS1`(길이·기록 없음)은 읽고 시작할 때 `PLS2`로 다시 쓴다 | | 수·커서·인덱스 | `values`: `PVS1`, 상태마다 종류·이름·binary64. 바뀐 이벤트에서만 다시 쓴다 | - **시작.** 저장된 상태를 이름과 종류로 불러온다. 없는 상태는 초기값으로 시작한다. 레코드가 달라진 리스트는 @@ -273,7 +273,7 @@ text·리스트가 남을 때만 위치를 유지한다. 아님)는 시작을 `store_error`로 멈춘다. 조용히 버리지 않는다. - **저장은 묶음이다.** 저장 한 번이 마지막 저장 이후 메시지가 고친 문서들과 바뀐 상태 문서들을 저장소의 **한 커밋**(`pure_draft_commit_all`)으로 공개한다. 모두 보이거나 하나도 보이지 않는다. 저장이 실패하면 마지막 저장 - 이후의 이벤트가 **모두** 되돌려진다(값, text와 리스트 draft, 리스트 되돌리기 기록, 이벤트·tick 수). 그래서 + 이후의 이벤트가 **모두** 되돌려진다(값, text와 리스트 draft, 리스트 되돌리기 기록과 저널, 이벤트·tick 수). 그래서 "성공을 알린 이벤트는 저장됐고, 실패를 알린 이벤트는 흔적이 없다"가 성립한다. 결과 불명(`uncertain`)이나 다른 기록자와의 충돌 뒤에는 앱이 저장소와 어긋나 이후 저장이 충돌로 실패한다. 다시 시작하면 저장소의 상태로 이어진다. - **기본은 이벤트마다 저장.** 키·text·메시지 이벤트는 저장까지 마치고 돌아온다(묶음 크기 1). 초당 60번 오는 -- cgit v1.2.3