Repository navigation
[Feature] Support appending to a normally closed TsFile in C++ and C APIs #923
Description
Motivation
The Java implementation can reopen a normally closed (complete) TsFile for appending through:
RestorableTsFileIOWriter.getWriterForAppendingDataOnCompletedTsFile(File)
This method detects a complete file, locates the start of FileMetadata from the footer, truncates the separator marker and all tail metadata, recovers the existing schema/chunk metadata, and then allows new data to be appended. Closing the writer generates a new metadata section and tail magic.
The C++ implementation currently supports recovery and continued writing only when the file is incomplete or its tail is damaged. When the tail magic is valid, RestorableTsFileIOWriter::self_check() treats the file as complete, sets can_write_ = false, and closes the write handle.
As a result, users of the C++ API and the C wrapper cannot append to a TsFile that was closed normally, even though the equivalent workflow is available in Java.
Expected behavior
Provide a supported C++ API equivalent to the Java completed-file append workflow, and expose it through the C API as well.
The workflow should:
- Validate that the input is a compatible TsFile.
- Detect whether the file is complete by checking the tail magic.
- For a complete file, read the footer metadata size and locate the start of
FileMetadata. - Safely truncate the separator marker, metadata/index section, footer size, and tail magic while retaining all completed chunk groups.
- Recover the existing tree/table schema, alignment information, chunk metadata, statistics, and per-device last timestamps.
- Allow users to append data using the recovered schema.
- Allow registration of supported new schemas after recovery, with clear behavior for:
- a new device or measurement in the tree model;
- a new table;
- attempts to replace or evolve an existing table/aligned schema.
- On close, write a new metadata section, footer, and tail magic so that the resulting file is complete and readable.
- Reject out-of-order timestamps relative to recovered data.
The operation should preserve the original file when validation or recovery fails before truncation. The API documentation should make the destructive/in-place nature of a successful append-open explicit.
Current C++ behavior
For an incomplete or damaged-tail file, the following path already works:
RestorableTsFileIOWriter::open(path, true)
→ recover schema and chunk metadata
→ TsFileWriter::init(&restorable_writer)
→ append data
→ close and rebuild metadata
For a normally closed file, open() returns successfully but can_write() is false, so it cannot be passed to TsFileWriter::init().
Suggested tests
- Append to a normally closed tree-model TsFile and verify old and new rows.
- Append to a normally closed table-model TsFile.
- Preserve recovered encoding/compression/data types and alignment.
- Register a new device and a new non-aligned measurement after recovery.
- Verify documented behavior for aligned-series and existing-table schema evolution.
- Reject duplicate or incompatible schemas.
- Reject timestamps not newer than the recovered last timestamp.
- Handle empty, incomplete, incompatible, encrypted, and large-metadata files.
- Verify that a no-op append followed by close produces a valid file without losing data.
- Add coverage for the C wrapper API.
Related implementation
- Java:
RestorableTsFileIOWriter.getWriterForAppendingDataOnCompletedTsFile(File) - C++:
RestorableTsFileIOWriter::self_check() - C++ recovery integration:
TsFileWriter::init(RestorableTsFileIOWriter*)
Activity
Adarsh-Me commentedon Sep 2, 2026
Hi! I'd like to take this issue. Could a committer please assign it to me? My plan is to design an append/reopen API in the C++ binding first, then mirror it in the C API, covering the normally-closed TsFile case with tests. Thanks!
ColinLeeo commentedon Sep 3, 2026
Thanks for your interest in working on this issue!
The Java implementation can be used as a reference, especially RestorableTsFileIOWriter.getWriterForAppendingDataOnCompletedTsFile(File).
The general workflow is to read the footer of a completed TsFile, locate and remove the tail metadata, recover the existing schema and chunk metadata, continue appending data, and regenerate the metadata when the writer is closed.
You’re welcome to share some initial API and implementation design thoughts in this issue before getting started. Thanks!
Adarsh-Me commentedon Sep 23, 2026
Design for append-on-complete in the C++/C APIs. Nothing here is written yet — I'd like a sanity check on the recovery strategy (§3) and the boundary-marker finding (§5) before code.
1. C++ surface
One new method rather than an
open()behaviour change:open(path, truncate_corrupted)must keep returningcan_write() == falsefor a complete file —test/file/restorable_tsfile_io_writer_test.cc:166(OpenCompleteFile) pinscan_write()==false,has_crashed()==false,get_truncated_size()==TSFILE_CHECK_COMPLETE,get_tsfile_io_writer()==nullptr. A separate entry point keeps every existing test untouched.has_crashed()should stayfalseon the append path. Today the recovery scan setscrashed_ = trueunconditionally (restorable_tsfile_io_writer.cc:837), so that assignment needs to become mode-dependent; otherwise callers that usehas_crashed()to decide whether data was lost will misread a clean append as a crash salvage.get_truncated_size()on the append path returns bytes removed (file_size - new_size), keeping the existing-2/0sentinels for the incompatible/complete cases.2. C surface, and one data-loss trap
These must not be built on
write_file_new()/TsFileWriter::open(path), because both truncate on open:src/cwrapper/tsfile_cwrapper.cc:177—int flags = O_RDWR | O_CREAT | O_TRUNC;src/writer/tsfile_writer.cc:298—return open(file_path, O_RDWR | O_CREAT | O_TRUNC, 0666);So a user who today tries to append through the existing C entry points loses the whole file before a writer object ever exists. The append entry points need their own non-truncating internal open (
O_RDWR|O_CREAT|O_BINARY, noO_TRUNC, noO_EXCL), and the docs forwrite_file_new()should say plainly that it discards an existing file.Ownership:
TsFileWriter::init(RestorableTsFileIOWriter*)setsio_writer_owned_ = false(tsfile_writer.h:197), so the C handle has to own theRestorableTsFileIOWriterand destroy it after the writer. The C++ side already has the right shapes —TsFileTableWriter(RestorableTsFileIOWriter*, ...)atsrc/writer/tsfile_table_writer.cc:27and the tree equivalent atsrc/writer/tsfile_tree_writer.cc:36— so the C work is mostly a context struct plus lifetime handling. The C API exposes noRestorableTsFileIOWriterat all today, so this is the first recovery entry point in the C layer.3. Recovery strategy: reuse the chunk scan, use the footer only to find the cut
I checked the alternative — deserialize
FileMetadatafrom the footer the way Java does — and I'd rather not. The scan already produces exactly whatTsFileWriter::init(RestorableTsFileIOWriter*)consumes (tsfile_writer.cc:152-231):ChunkGroupMetas with real device IDs, per-measurementdata_type_/encoding_/compression_type_, recovered statistics,aligned_devices_, andupdate_table_schema()+finalize_table_schemas()for the table model. Footer-driven recovery means writing a second recovery path over index-node structures, and it would have to re-derive per-chunk statistics that the scan already decodes from the pages themselves. Reusing the scan also inherits ~12 existing recovery tests (TableWriterRecoverAndWrite,AlignedTimeseriesRecoverAndWrite,MultiDeviceRecoverAndWriteWithTreeWriter,RecoveryAlignedSparseStatRespectsBitmap, …) instead of starting from zero.Algorithm, all before any mutation:
fstat;size == 0→ existing empty-file path.VERSION_NUM_BYTE(existing code,restorable_tsfile_io_writer.cc:609-628).meta_sizeas big-endian i32 atsize - 10; requiremeta_size > 0andmeta_size + 11 <= size; cut =size - 10 - meta_size - 1.cut(§5).[header_len, cut), which must consume exactly up tocut.truncate(cut),seek_to_end(),can_write_ = true.Step 6 is where this differs from the crash path on purpose. Crash recovery is best-effort salvage: it truncates at the last parseable offset, which is the right call for a half-written file. Here the file is known-good, so partial progress is a failure: any early stop returns an error and leaves the file alone. Without that rule, a file whose chunks the scanner can't parse gets truncated at step 7 down to ~7 bytes, i.e. append destroys a file it should have refused.
Two things already work in our favour:
OPERATION_INDEX_RANGEis handled by the scan (restorable_tsfile_io_writer.cc:721-737), which matters becauseend_file()writes that segment before the separator (tsfile_io_writer.cc:305-307), so truncation leaves the previous one in place and each append/close cycle adds another. The 1+16 skip means they are consumed cleanly rather than tripping the unknown-marker branch at:814. I want an explicit test for repeated append→close→append→close cycles, since accumulation is only benign if it stays benign at 3+ cycles.init(RestorableTsFileIOWriter*)setsenforce_recovered_last_time_order_(tsfile_writer.h:198-202) and it is checked at ~12 write/flush sites. Nothing new to design.Existing-table semantics need no decision either:
register_table()already returnsE_ALREADY_EXISTwhen the name is present (tsfile_writer.cc:261-272), so "evolve an existing table or aligned schema" = rejected, and appending to a recovered table works without re-registering. New device/new non-aligned measurement/new table after recovery all go through the ordinaryregister_*paths. Preserving the latest codec per series when a series was written with different codecs across chunks is already handled attsfile_writer.cc:217-225.4. Encryption
There is no encryption in
cpp/srcat all — noEncryptParametertype and no cipher path anywhere in the tree. The writer only records properties, and the spot where encryption would live is still a TODO:src/file/tsfile_io_writer.h:76-78readsif (common::g_config_value_.encrypt_flag_) { // TODO: support encrypt…encrypt_level_ = "2";So an encrypted file is an unreadable file for this library, andopen_for_append()should fail withE_TSFILE_CORRUPTED(C:RET_TSFILE_CORRUPTED, 35) rather than truncate. The bounded-scan rule in step 6 above is what actually guarantees that, since encrypted chunk bytes will not parse. I'd document encrypted input as unsupported rather than designing a rejection code for it.5. Boundary-marker finding — needs a maintainer answer
The byte at
cutisSEPARATOR_MARKER(0x02) in both current writers —tsfile_io_writer.cc:600-603andTsFileIOWriter.java:510-514(Java recordsmetaOffset = out.getPosition()before writing the separator, so Java'struncate(fileMetadataPos - 1)and ourcutland on the same offset; I verified the twoloadMetadataSize/write_separator_markerdefinitions agree at byte level).It is not 0x02 in the fixtures checked into this repo. I measured all five, big-endian i32 at
size-10:cutcutcpp/examples/test_cpp.tsfilepython/tests/resources/simple_table_t1.tsfilepython/tests/resources/simple_table_t2.tsfilepython/tests/resources/simple_tree.tsfilepython/tests/resources/table_with_time_column.tsfileAll five also carry head magic
TsFile+ version byte0x04and a matching tail magic, so the arithmetic (meta_sizecovers the metadata body only, separator is 1 byte before it) holds; only the marker's identity differs.MetaMarker.VERSION = 3is@Deprecatedin Java and nothing injava/tsfile/src/main/javawrites it any more, so my guess is these fixtures predate the current tail, but I don't know their provenance and I'd rather not encode a guess. Two questions:open_for_append()accept 0x03 at the boundary as well, or reject anything that isn't 0x02? My default is reject-with-error, which is safe for files this library writes and conservative for everything else.VERSION_NUM_BYTE == 0x04equality check, givenVERSION_NUMBER_V3 = 0x03exists in Java and v3 files may still be in users' hands?The related "large-metadata" test case: I would not copy the reader's
ASSERT(tsfile_meta_size > 0 && tsfile_meta_size <= (1ll << 20))(tsfile_io_reader.cc:555).ASSERTcompiles toassert()unlessNDEBUG(src/utils/util_define.h:139-142), so on a malformed tail that aborts the process instead of returning an error code — bad for a function whose whole job is to validate untrusted file bytes — and the 1 MiB ceiling would also reject the legitimately-large-metadata file the issue asks us to cover. The append path uses returned error codes and a size bound expressed against the file length only.6. Test plan
C++ (
test/file/, next to the existing recovery tests; each is the complete-file twin of an existing truncated-file test): tree append and verify old+new rows; table append; new device and new non-aligned measurement after recovery; aligned-series and existing-table registration rejected with the documented code; timestamps older than the recovered per-devicelast_time_; no-op append+close then a full read-back diff of every series' statistics — that one is the real guard, because the rewritten tail is regenerated from scanned chunk-level metadata, not copied from the original footer, so a silent loss there needs to be caught rather than assumed; repeated append/close cycles for theOPERATION_INDEX_RANGEaccumulation; empty, header-only, bad-magic, bad-version,meta_sizeout of range, and separator-mismatch files all leaving the input byte-identical; plus the C wrapper round-trip, including that the append entry point does not truncate.Cross-implementation check I'd like to add: read a C++-appended file back with the Java reader, so the regenerated tail is proven acceptable to the reference implementation and not just to the code that wrote it.
7. What I have and have not verified
Verified: the file-format arithmetic and every source claim above, by reading the code at the cited lines, plus the byte measurements in §5 run against the five checked-in fixtures.
Not verified: I have not compiled or run anything. This host has no
cmake/ninjaand no configured MSVC/MinGW build of libtsfile, so no test in §6 has executed, and I am not claiming any behaviour I have not read from source. First PR will therefore be the truncation/validation step plus one round-trip test, run locally, before the C wrapper lands.ColinLeeo commentedon Sep 30, 2026
Sorry for the delayed response, and please excuse the delay. I carefully reviewed your implementation, and it looks very good. I also compared the proposed flow with the Java implementation.
Overall, the design is aligned with Java’s completed-file append workflow. In Java, RestorableTsFileIOWriter.getWriterForAppendingDataOnCompletedTsFile(File) detects a complete file, uses the metadata size in the footer to locate fileMetadataPos, removes the existing metadata tail, and then enters the normal RestorableTsFileIOWriter recovery flow so that new data can be appended and a new footer can be generated on close.
Therefore, I agree with adding a separate open_for_append() entry point instead of changing the behavior of the existing open(). The current open() semantics can remain unchanged, with a normally completed file remaining non-writable, while the new entry point handles the explicit append workflow.
For the marker at the metadata boundary, I suggest following the actual Java behavior. The Java append helper calculates the truncation position from the footer metadata size and calls truncate(fileMetadataPos - 1) without requiring the boundary byte to be a specific marker. Therefore, I do not think the C++ implementation needs to introduce a stricter marker requirement than Java.
Regarding the file version, Java currently supports versions V3 through V4, while the current C++ implementation only supports V4. I suggest that this issue should first align the C++ completed-file append workflow with Java while keeping the current C++ version scope limited to V4. V3 compatibility can be handled as a separate follow-up task, since it would require complete support in the corresponding chunk, page, metadata, and configuration deserialization paths.
The ownership and lifetime handling in the C API also look clear. The recovered RestorableTsFileIOWriter needs to remain alive for the entire lifetime of the higher-level tree/table writer and should only be released after the higher-level writer has completed its close operation. The proposed context/lifetime design should be sufficient for this.
Overall, I agree with the current design and think it is ready to move forward. The scope can remain:
Adarsh-Me commentedon Oct 10, 2026
Thanks - the marker point holds: open_for_append cuts at file_size - (MAGIC_LEN + 4), length read from the footer, never inspecting that byte, so nothing is stricter than Java (L745-760). open() keeps the semantics you described, scope stays V4-only with V3 a follow-up. #980's append path is red; once fixed I'd land the C API as a second PR. Sound?
ColinLeeo commentedon Oct 11, 2026
Sounds good, thanks! Let’s get #980’s tests passing first, then add the C API in a separate PR.