2026-03-30 20:46:43 +00:00
package store
import (
"compress/gzip"
"io"
"time"
core "dappco.re/go/core"
2026-03-30 20:53:12 +00:00
"github.com/klauspost/compress/zstd"
2026-03-30 20:46:43 +00:00
)
var defaultArchiveOutputDirectory = ".core/archive"
// CompactOptions controls cold archive generation.
// Usage example: `options := store.CompactOptions{Before: time.Now().Add(-90 * 24 * time.Hour), Output: "/tmp/archive", Format: "gzip"}`
type CompactOptions struct {
Before time . Time
Output string
Format string
}
type compactArchiveEntry struct {
2026-04-03 06:01:00 +00:00
entryID int64
bucketName string
measurementName string
fieldsJSON string
tagsJSON string
committedAtUnixMilli int64
2026-03-30 20:46:43 +00:00
}
// Compact archives old journal entries as newline-delimited JSON.
// Usage example: `result := storeInstance.Compact(store.CompactOptions{Before: time.Now().Add(-30 * 24 * time.Hour), Output: "/tmp/archive", Format: "gzip"})`
func ( storeInstance * Store ) Compact ( options CompactOptions ) core . Result {
if err := ensureJournalSchema ( storeInstance . database ) ; err != nil {
return core . Result { Value : core . E ( "store.Compact" , "ensure journal schema" , err ) , OK : false }
}
outputDirectory := options . Output
if outputDirectory == "" {
outputDirectory = defaultArchiveOutputDirectory
}
format := options . Format
if format == "" {
format = "gzip"
}
2026-03-30 20:53:12 +00:00
if format != "gzip" && format != "zstd" {
2026-03-30 20:46:43 +00:00
return core . Result { Value : core . E ( "store.Compact" , core . Concat ( "unsupported archive format: " , format ) , nil ) , OK : false }
}
filesystem := ( & core . Fs { } ) . NewUnrestricted ( )
if result := filesystem . EnsureDir ( outputDirectory ) ; ! result . OK {
return core . Result { Value : core . E ( "store.Compact" , "ensure archive directory" , result . Value . ( error ) ) , OK : false }
}
rows , err := storeInstance . database . Query (
"SELECT entry_id, bucket_name, measurement, fields_json, tags_json, committed_at FROM " + journalEntriesTableName + " WHERE archived_at IS NULL AND committed_at < ? ORDER BY committed_at" ,
options . Before . UnixMilli ( ) ,
)
if err != nil {
return core . Result { Value : core . E ( "store.Compact" , "query journal rows" , err ) , OK : false }
}
defer rows . Close ( )
var archiveEntries [ ] compactArchiveEntry
for rows . Next ( ) {
var entry compactArchiveEntry
if err := rows . Scan (
2026-04-03 06:01:00 +00:00
& entry . entryID ,
& entry . bucketName ,
& entry . measurementName ,
2026-03-30 20:46:43 +00:00
& entry . fieldsJSON ,
& entry . tagsJSON ,
2026-04-03 06:01:00 +00:00
& entry . committedAtUnixMilli ,
2026-03-30 20:46:43 +00:00
) ; err != nil {
return core . Result { Value : core . E ( "store.Compact" , "scan journal row" , err ) , OK : false }
}
archiveEntries = append ( archiveEntries , entry )
}
if err := rows . Err ( ) ; err != nil {
return core . Result { Value : core . E ( "store.Compact" , "iterate journal rows" , err ) , OK : false }
}
if len ( archiveEntries ) == 0 {
return core . Result { Value : "" , OK : true }
}
outputPath := compactOutputPath ( outputDirectory , format )
2026-04-03 06:01:00 +00:00
archiveFileResult := filesystem . Create ( outputPath )
if ! archiveFileResult . OK {
return core . Result { Value : core . E ( "store.Compact" , "create archive file" , archiveFileResult . Value . ( error ) ) , OK : false }
2026-03-30 20:46:43 +00:00
}
2026-04-03 06:01:00 +00:00
file , ok := archiveFileResult . Value . ( io . WriteCloser )
2026-03-30 20:46:43 +00:00
if ! ok {
return core . Result { Value : core . E ( "store.Compact" , "archive file is not writable" , nil ) , OK : false }
}
fileClosed := false
defer func ( ) {
if ! fileClosed {
_ = file . Close ( )
}
} ( )
2026-03-30 20:53:12 +00:00
writer , err := archiveWriter ( file , format )
if err != nil {
return core . Result { Value : err , OK : false }
}
2026-03-30 20:46:43 +00:00
writeOK := false
defer func ( ) {
if ! writeOK {
_ = writer . Close ( )
}
} ( )
for _ , entry := range archiveEntries {
lineMap , err := compactArchiveLine ( entry )
if err != nil {
return core . Result { Value : err , OK : false }
}
lineJSON , err := jsonString ( lineMap , "store.Compact" , "marshal archive line" )
if err != nil {
return core . Result { Value : err , OK : false }
}
if _ , err := io . WriteString ( writer , lineJSON + "\n" ) ; err != nil {
return core . Result { Value : core . E ( "store.Compact" , "write archive line" , err ) , OK : false }
}
}
if err := writer . Close ( ) ; err != nil {
return core . Result { Value : core . E ( "store.Compact" , "close archive writer" , err ) , OK : false }
}
writeOK = true
if err := file . Close ( ) ; err != nil {
return core . Result { Value : core . E ( "store.Compact" , "close archive file" , err ) , OK : false }
}
fileClosed = true
transaction , err := storeInstance . database . Begin ( )
if err != nil {
return core . Result { Value : core . E ( "store.Compact" , "begin archive transaction" , err ) , OK : false }
}
committed := false
defer func ( ) {
if ! committed {
_ = transaction . Rollback ( )
}
} ( )
archivedAt := time . Now ( ) . UnixMilli ( )
for _ , entry := range archiveEntries {
if _ , err := transaction . Exec (
"UPDATE " + journalEntriesTableName + " SET archived_at = ? WHERE entry_id = ?" ,
archivedAt ,
2026-04-03 06:01:00 +00:00
entry . entryID ,
2026-03-30 20:46:43 +00:00
) ; err != nil {
return core . Result { Value : core . E ( "store.Compact" , "mark journal row archived" , err ) , OK : false }
}
}
if err := transaction . Commit ( ) ; err != nil {
return core . Result { Value : core . E ( "store.Compact" , "commit archive transaction" , err ) , OK : false }
}
committed = true
return core . Result { Value : outputPath , OK : true }
}
func compactArchiveLine ( entry compactArchiveEntry ) ( map [ string ] any , error ) {
fields := make ( map [ string ] any )
fieldsResult := core . JSONUnmarshalString ( entry . fieldsJSON , & fields )
if ! fieldsResult . OK {
return nil , core . E ( "store.Compact" , "unmarshal fields" , fieldsResult . Value . ( error ) )
}
tags := make ( map [ string ] string )
tagsResult := core . JSONUnmarshalString ( entry . tagsJSON , & tags )
if ! tagsResult . OK {
return nil , core . E ( "store.Compact" , "unmarshal tags" , tagsResult . Value . ( error ) )
}
return map [ string ] any {
2026-04-03 06:01:00 +00:00
"bucket" : entry . bucketName ,
"measurement" : entry . measurementName ,
2026-03-30 20:46:43 +00:00
"fields" : fields ,
"tags" : tags ,
2026-04-03 06:01:00 +00:00
"committed_at" : entry . committedAtUnixMilli ,
2026-03-30 20:46:43 +00:00
} , nil
}
2026-03-30 20:53:12 +00:00
func archiveWriter ( writer io . Writer , format string ) ( io . WriteCloser , error ) {
switch format {
case "gzip" :
return gzip . NewWriter ( writer ) , nil
case "zstd" :
zstdWriter , err := zstd . NewWriter ( writer )
if err != nil {
return nil , core . E ( "store.Compact" , "create zstd writer" , err )
}
return zstdWriter , nil
default :
return nil , core . E ( "store.Compact" , core . Concat ( "unsupported archive format: " , format ) , nil )
}
}
2026-03-30 20:46:43 +00:00
func compactOutputPath ( outputDirectory , format string ) string {
extension := ".jsonl"
if format == "gzip" {
extension = ".jsonl.gz"
}
2026-03-30 20:53:12 +00:00
if format == "zstd" {
extension = ".jsonl.zst"
}
2026-03-30 20:46:43 +00:00
filename := core . Concat ( "journal-" , time . Now ( ) . UTC ( ) . Format ( "20060102-150405" ) , extension )
return joinPath ( outputDirectory , filename )
}