summaryrefslogtreecommitdiff
path: root/src/cli/import.c
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-04 18:58:06 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-04 18:58:06 +0900
commit61fa05ec0c4510f1cd437b5d672cd6fd7b996353 (patch)
treec5afdc1d63c2613809a8d514276c43f0990a7cb1 /src/cli/import.c
parent751999a82b8854ef5231e0ded5fe022b1ea5d8ff (diff)
Bound import memory with staged streaming
Diffstat (limited to 'src/cli/import.c')
-rw-r--r--src/cli/import.c436
1 files changed, 351 insertions, 85 deletions
diff --git a/src/cli/import.c b/src/cli/import.c
index f4688ab..ee4d1cf 100644
--- a/src/cli/import.c
+++ b/src/cli/import.c
@@ -3,6 +3,7 @@
#include "domain/message.h"
#include "mcp/json.h"
#include "storage/store.h"
+#include "sys/system.h"
#include "mjson.h"
@@ -11,55 +12,162 @@
#include <string.h>
#include <limits.h>
-#define IMPORT_MAX_LINE TINY_CLI_LINE_MAX
-#define IMPORT_MAX_RECORDS 100000U
-#define IMPORT_MAX_DECODED_BYTES (UINT64_C(4) * 1024U * 1024U * 1024U)
+#include "base/allocator.h"
-typedef struct import_record_list
+#ifndef TINY_IMPORT_MAX_LINE
+#define TINY_IMPORT_MAX_LINE TINY_CLI_LINE_MAX
+#endif
+#ifndef TINY_IMPORT_MAX_RECORDS
+#define TINY_IMPORT_MAX_RECORDS 100000U
+#endif
+#ifndef TINY_IMPORT_MAX_DECODED_BYTES
+#define TINY_IMPORT_MAX_DECODED_BYTES (UINT64_C(4) * 1024U * 1024U * 1024U)
+#endif
+#define IMPORT_READ_BUFFER 65536U
+
+typedef struct import_compact_entry
+{
+ char message_id[TINY_MESSAGE_ID_LENGTH + 1];
+ size_t selected_record;
+ bool deleted;
+} import_compact_entry_t;
+
+typedef struct import_compact_index
{
- tiny_import_record_t *items;
+ import_compact_entry_t *entries;
size_t count;
size_t capacity;
-} import_record_list_t;
+ size_t *slots;
+ size_t slot_capacity;
+} import_compact_index_t;
+
+typedef struct import_reader
+{
+ tiny_sys_file_t file;
+ unsigned char input[IMPORT_READ_BUFFER];
+ size_t input_offset;
+ size_t input_length;
+ char *line;
+ size_t line_length;
+ bool eof;
+} import_reader_t;
static void
-import_record_list_free(import_record_list_t *list)
+import_compact_index_free(import_compact_index_t *index)
{
- size_t index;
- if (!list)
+ if (!index)
{
return;
}
- for (index = 0; index < list->count; ++index)
+ free(index->slots);
+ free(index->entries);
+ memset(index, 0, sizeof(*index));
+}
+
+static uint64_t
+import_id_hash(const char *value)
+{
+ uint64_t hash = UINT64_C(1469598103934665603);
+ while (*value)
{
- tiny_message_free(&list->items[index].message);
+ hash ^= (unsigned char)*value++;
+ hash *= UINT64_C(1099511628211);
}
- free(list->items);
- list->items = NULL;
- list->count = 0;
- list->capacity = 0;
+ return hash;
}
static bool
-import_record_list_push(import_record_list_t *list)
+import_compact_index_rehash(import_compact_index_t *index, size_t capacity)
{
- tiny_import_record_t *resized;
- size_t capacity;
- if (list->count < list->capacity)
+ size_t *slots;
+ size_t position;
+ if (capacity > SIZE_MAX / sizeof(*slots) || !(slots = malloc(capacity * sizeof(*slots))))
{
- return true;
+ return false;
}
- capacity = list->capacity == 0 ? 32 : list->capacity * 2;
- resized = realloc(list->items, capacity * sizeof(*resized));
- if (!resized)
+ for (position = 0; position < capacity; ++position)
{
- return false;
+ slots[position] = SIZE_MAX;
}
- list->items = resized;
- list->capacity = capacity;
+ for (position = 0; position < index->count; ++position)
+ {
+ size_t slot = (size_t)import_id_hash(index->entries[position].message_id) & (capacity - 1U);
+ while (slots[slot] != SIZE_MAX)
+ {
+ slot = (slot + 1U) & (capacity - 1U);
+ }
+ slots[slot] = position;
+ }
+ free(index->slots);
+ index->slots = slots;
+ index->slot_capacity = capacity;
return true;
}
+static import_compact_entry_t *
+import_compact_index_find(const import_compact_index_t *index, const char *message_id)
+{
+ size_t slot;
+ if (!index->slot_capacity)
+ {
+ return NULL;
+ }
+ slot = (size_t)import_id_hash(message_id) & (index->slot_capacity - 1U);
+ while (index->slots[slot] != SIZE_MAX)
+ {
+ import_compact_entry_t *entry = &index->entries[index->slots[slot]];
+ if (strcmp(entry->message_id, message_id) == 0)
+ {
+ return entry;
+ }
+ slot = (slot + 1U) & (index->slot_capacity - 1U);
+ }
+ return NULL;
+}
+
+static import_compact_entry_t *
+import_compact_index_add(import_compact_index_t *index, const char *message_id)
+{
+ import_compact_entry_t *resized;
+ size_t capacity;
+ if (index->count == index->capacity)
+ {
+ capacity = index->capacity ? index->capacity * 2U : 32U;
+ if (capacity < index->capacity || capacity > SIZE_MAX / sizeof(*resized)
+ || !(resized = realloc(index->entries, capacity * sizeof(*resized))))
+ {
+ return NULL;
+ }
+ index->entries = resized;
+ index->capacity = capacity;
+ }
+ if (!index->slot_capacity || (index->count + 1U) * 10U > index->slot_capacity * 7U)
+ {
+ capacity = index->slot_capacity ? index->slot_capacity * 2U : 64U;
+ if (capacity < index->slot_capacity || !import_compact_index_rehash(index, capacity))
+ {
+ return NULL;
+ }
+ }
+ memset(&index->entries[index->count], 0, sizeof(index->entries[index->count]));
+ memcpy(index->entries[index->count].message_id, message_id, TINY_MESSAGE_ID_LENGTH + 1U);
+ {
+ size_t slot = (size_t)import_id_hash(message_id) & (index->slot_capacity - 1U);
+ while (index->slots[slot] != SIZE_MAX)
+ {
+ slot = (slot + 1U) & (index->slot_capacity - 1U);
+ }
+ index->slots[slot] = index->count;
+ }
+ index->count++;
+ if (index->count > TINY_IMPORT_MAX_RECORDS)
+ {
+ index->count--;
+ return NULL;
+ }
+ return &index->entries[index->count - 1U];
+}
+
static bool
import_get_string(const char *json, int len, const char *path, char *out, size_t out_size)
{
@@ -518,7 +626,8 @@ import_manifest_valid(const char *line, int len)
{
char type_name[64];
uint64_t version = 0;
- if (!import_get_string(line, len, "$.type", type_name, sizeof(type_name))
+ if (mjson_find(line, len, "$", NULL, NULL) != MJSON_TOK_OBJECT
+ || !import_get_string(line, len, "$.type", type_name, sizeof(type_name))
|| strcmp(type_name, "tiny-convmem-manifest") != 0
|| !import_get_u64(line, len, "$.format_version", &version))
{
@@ -532,7 +641,8 @@ import_parse_del_record(const char *line, int len, tiny_import_record_t *record)
{
char type_name[32];
tiny_message_init(&record->message);
- if (!import_get_string(line, len, "$.type", type_name, sizeof(type_name))
+ if (mjson_find(line, len, "$", NULL, NULL) != MJSON_TOK_OBJECT
+ || !import_get_string(line, len, "$.type", type_name, sizeof(type_name))
|| strcmp(type_name, "DEL") != 0
|| !import_get_string(
line, len, "$.message_id", record->message.message_id, sizeof(record->message.message_id))
@@ -553,7 +663,8 @@ import_parse_message_record(const char *line, int len, tiny_import_record_t *rec
size_t content_length = 0;
tiny_message_init(&record->message);
record->deleted = false;
- if (!import_get_string(
+ if (mjson_find(line, len, "$", NULL, NULL) != MJSON_TOK_OBJECT
+ || !import_get_string(
line, len, "$.message_id", record->message.message_id, sizeof(record->message.message_id))
|| !import_get_string(line, len, "$.role", role_name, sizeof(role_name))
|| !import_get_allocated_string(
@@ -615,35 +726,129 @@ import_record_decoded_bytes(const tiny_import_record_t *record)
}
static bool
-import_parse_stream(FILE *file, import_record_list_t *records)
+import_reader_open(import_reader_t *reader, const char *path)
{
- char *line = malloc((size_t)IMPORT_MAX_LINE + 2U);
- bool manifest_seen = false;
- uint64_t decoded_total = 0;
- if (!line)
+ memset(reader, 0, sizeof(*reader));
+ tiny_sys_file_init(&reader->file);
+ reader->line = malloc((size_t)TINY_IMPORT_MAX_LINE + 1U);
+ return reader->line && tiny_sys_file_open(&reader->file, path, TINY_SYS_FILE_READ) == TINY_SYS_OK;
+}
+
+static void
+import_reader_close(import_reader_t *reader)
+{
+ tiny_sys_file_close(&reader->file);
+ free(reader->line);
+ memset(reader, 0, sizeof(*reader));
+}
+
+static bool
+import_reader_rewind(import_reader_t *reader)
+{
+ reader->input_offset = 0;
+ reader->input_length = 0;
+ reader->line_length = 0;
+ reader->eof = false;
+ return tiny_sys_file_seek(&reader->file, 0, TINY_SYS_SEEK_BEGIN);
+}
+
+static int
+import_reader_next(import_reader_t *reader, const char **line, size_t *length)
+{
+ reader->line_length = 0;
+ while (true)
{
- return false;
+ unsigned char value;
+ if (reader->input_offset == reader->input_length)
+ {
+ uint32_t actual = 0;
+ if (reader->eof)
+ {
+ if (reader->line_length == 0)
+ {
+ return 0;
+ }
+ break;
+ }
+ if (!tiny_sys_file_read(&reader->file, reader->input, sizeof(reader->input), &actual))
+ {
+ return -1;
+ }
+ reader->input_offset = 0;
+ reader->input_length = actual;
+ reader->eof = actual == 0;
+ continue;
+ }
+ value = reader->input[reader->input_offset++];
+ if (value == '\n')
+ {
+ break;
+ }
+ if (value == 0 || reader->line_length >= TINY_IMPORT_MAX_LINE)
+ {
+ return -1;
+ }
+ reader->line[reader->line_length++] = (char)value;
}
- while (fgets(line, (int)IMPORT_MAX_LINE + 2, file) != NULL)
+ if (reader->line_length > 0 && reader->line[reader->line_length - 1U] == '\r')
{
- size_t length = strlen(line);
- tiny_import_record_t record;
- uint64_t decoded;
- if (length == 0 || length > IMPORT_MAX_LINE
- || (length == IMPORT_MAX_LINE + 1U && line[length - 1] != '\n')
- || (line[length - 1] != '\n' && !feof(file)))
+ reader->line_length--;
+ }
+ reader->line[reader->line_length] = '\0';
+ *line = reader->line;
+ *length = reader->line_length;
+ return 1;
+}
+
+static bool
+import_record_references_valid(const tiny_import_record_t *record,
+ const import_compact_index_t *index)
+{
+ size_t position;
+ if (record->deleted)
+ {
+ return true;
+ }
+ for (position = 0; position < record->message.metadata.relation_count; ++position)
+ {
+ import_compact_entry_t *target = import_compact_index_find(
+ index, record->message.metadata.relations[position].message_id);
+ if (!target || target->deleted)
{
- free(line);
return false;
}
- if (length > 0 && line[length - 1] == '\n')
- {
- line[--length] = '\0';
- }
- if (length > 0 && line[length - 1] == '\r')
+ }
+ for (position = 0; position < record->message.metadata.source_count; ++position)
+ {
+ import_compact_entry_t *target
+ = import_compact_index_find(index, record->message.metadata.source_message_ids[position]);
+ if (!target || target->deleted)
{
- line[--length] = '\0';
+ return false;
}
+ }
+ return true;
+}
+
+static bool
+import_validate_pass(import_reader_t *reader,
+ tiny_import_policy_t policy,
+ import_compact_index_t *index,
+ size_t *accepted,
+ size_t *rejected)
+{
+ const char *line;
+ size_t length, record_number = 0;
+ uint64_t decoded_total = 0;
+ bool manifest_seen = false;
+ int next;
+ *accepted = 0;
+ *rejected = 0;
+ while ((next = import_reader_next(reader, &line, &length)) > 0)
+ {
+ tiny_import_record_t record;
+ import_compact_entry_t *entry;
+ uint64_t decoded;
if (length == 0)
{
continue;
@@ -652,53 +857,111 @@ import_parse_stream(FILE *file, import_record_list_t *records)
{
if (!import_manifest_valid(line, (int)length))
{
- free(line);
return false;
}
manifest_seen = true;
continue;
}
- if (records->count >= IMPORT_MAX_RECORDS)
- {
- free(line);
- return false;
- }
- if (!import_parse_del_record(line, (int)length, &record)
- && !import_parse_message_record(line, (int)length, &record))
+ if (record_number >= TINY_IMPORT_MAX_RECORDS
+ || (!import_parse_del_record(line, (int)length, &record)
+ && !import_parse_message_record(line, (int)length, &record)))
{
- free(line);
return false;
}
decoded = import_record_decoded_bytes(&record);
- if (decoded > IMPORT_MAX_DECODED_BYTES - decoded_total || !import_record_list_push(records))
+ if (decoded > TINY_IMPORT_MAX_DECODED_BYTES - decoded_total)
{
tiny_message_free(&record.message);
- free(line);
return false;
}
decoded_total += decoded;
- records->items[records->count++] = record;
+ entry = import_compact_index_find(index, record.message.message_id);
+ if (!entry)
+ {
+ entry = import_compact_index_add(index, record.message.message_id);
+ if (!entry)
+ {
+ tiny_message_free(&record.message);
+ return false;
+ }
+ }
+ else if (policy == TINY_IMPORT_REJECT && !record.deleted && !entry->deleted)
+ {
+ (*rejected)++;
+ tiny_message_free(&record.message);
+ record_number++;
+ continue;
+ }
+ entry->selected_record = record_number;
+ entry->deleted = record.deleted;
+ (*accepted)++;
+ tiny_message_free(&record.message);
+ record_number++;
}
- if (ferror(file))
+ return next == 0 && manifest_seen;
+}
+
+static tiny_store_status_t
+import_staging_pass(import_reader_t *reader,
+ const import_compact_index_t *index,
+ tiny_import_transaction_t *transaction)
+{
+ const char *line;
+ size_t length, record_number = 0;
+ bool manifest_seen = false;
+ int next;
+ while ((next = import_reader_next(reader, &line, &length)) > 0)
{
- free(line);
- return false;
+ tiny_import_record_t record;
+ import_compact_entry_t *entry;
+ tiny_store_status_t status;
+ if (length == 0)
+ {
+ continue;
+ }
+ if (!manifest_seen)
+ {
+ manifest_seen = true;
+ continue;
+ }
+ if (!import_parse_del_record(line, (int)length, &record)
+ && !import_parse_message_record(line, (int)length, &record))
+ {
+ return TINY_STORE_CORRUPT;
+ }
+ entry = import_compact_index_find(index, record.message.message_id);
+ if (entry && entry->selected_record == record_number && !record.deleted)
+ {
+ if (!import_record_references_valid(&record, index))
+ {
+ tiny_message_free(&record.message);
+ return TINY_STORE_CORRUPT;
+ }
+ status = tiny_store_import_append(transaction, &record);
+ if (status != TINY_STORE_OK)
+ {
+ tiny_message_free(&record.message);
+ return status;
+ }
+ }
+ tiny_message_free(&record.message);
+ record_number++;
}
- free(line);
- return manifest_seen;
+ return next == 0 ? TINY_STORE_OK : TINY_STORE_IO_ERROR;
}
int
-tiny_cli_import(tiny_store_t *store, const tiny_cli_options_t *options)
+tiny_cli_import(const tiny_cli_options_t *options)
{
- import_record_list_t records = { NULL, 0, 0 };
+ import_compact_index_t index = { 0 };
+ import_reader_t reader;
+ tiny_import_transaction_t *transaction = NULL;
size_t imported = 0, rejected = 0;
- tiny_store_status_t status;
+ tiny_store_status_t status = TINY_STORE_CORRUPT;
tiny_import_policy_t policy = TINY_IMPORT_OVERWRITE;
- FILE *file = NULL;
int result = 1;
- if (!options || !options->import_path)
+ if (!options || !options->data_dir || !options->import_path)
{
fprintf(stderr, "import: path is required\n");
return 1;
@@ -721,29 +984,30 @@ tiny_cli_import(tiny_store_t *store, const tiny_cli_options_t *options)
}
}
- if (fopen_s(&file, options->import_path, "rb") != 0 || !file)
+ if (!import_reader_open(&reader, options->import_path))
{
fprintf(stderr, "import: unable to open '%s'\n", options->import_path);
+ import_reader_close(&reader);
return 1;
}
-
- if (!import_parse_stream(file, &records))
+ if (!import_validate_pass(&reader, policy, &index, &imported, &rejected)
+ || !import_reader_rewind(&reader))
{
fprintf(stderr, "import: invalid JSONL in '%s'\n", options->import_path);
- fclose(file);
- import_record_list_free(&records);
- return 1;
}
- if (fclose(file) != 0)
+ else
{
- fprintf(stderr, "import: unable to close '%s'\n", options->import_path);
- import_record_list_free(&records);
- return 1;
+ status = tiny_store_import_begin(&transaction, options->data_dir);
+ if (status == TINY_STORE_OK)
+ {
+ status = import_staging_pass(&reader, &index, transaction);
+ }
+ if (status == TINY_STORE_OK)
+ {
+ status = tiny_store_import_commit(transaction);
+ }
}
- status = tiny_store_import(store, records.items, records.count, policy, &imported, &rejected);
- import_record_list_free(&records);
-
if (status == TINY_STORE_OK)
{
printf("{\"imported\":%zu,\"rejected\":%zu}\n", imported, rejected);
@@ -762,6 +1026,8 @@ tiny_cli_import(tiny_store_t *store, const tiny_cli_options_t *options)
{
fprintf(stderr, "import: failed\n");
}
-
+ tiny_store_import_abort(transaction);
+ import_reader_close(&reader);
+ import_compact_index_free(&index);
return result;
}