mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 02:44:59 +00:00
* make ndjson pre_sort parallel, fix several bugs, test * remove json tags (not needed), rename pre_sort -> presort
296 lines
7 KiB
Go
296 lines
7 KiB
Go
package ctl
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/featurebasedb/featurebase/v3/logger"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
type PreSortCommand struct {
|
|
// Optional Index filter
|
|
File string
|
|
|
|
Type string
|
|
|
|
Table string
|
|
|
|
PrimaryKeyFields []string
|
|
PrimaryKeySeparator string
|
|
|
|
PartitionN int
|
|
|
|
JobSize int
|
|
NumWorkers int
|
|
|
|
// Path to write sorted files to
|
|
OutputDir string
|
|
|
|
// Standard input/output
|
|
logDest logger.Logger
|
|
|
|
outputLocks []sync.Mutex
|
|
outputFiles map[int]*os.File
|
|
outputEncoders map[int]*json.Encoder
|
|
outputWriters map[int]*csv.Writer
|
|
}
|
|
|
|
// NewPreSortCommand returns a new instance of PreSortCommand.
|
|
func NewPreSortCommand(logdest logger.Logger) *PreSortCommand {
|
|
return &PreSortCommand{
|
|
logDest: logdest,
|
|
Type: "ndjson",
|
|
OutputDir: "presorted_files",
|
|
PartitionN: 256,
|
|
JobSize: 1000,
|
|
NumWorkers: 4,
|
|
PrimaryKeySeparator: "|",
|
|
outputFiles: make(map[int]*os.File),
|
|
}
|
|
}
|
|
|
|
// Run executes the main program execution.
|
|
func (cmd *PreSortCommand) Run(ctx context.Context) error {
|
|
if cmd.File == "" {
|
|
return errors.New("must set a file or directory to read from")
|
|
}
|
|
if len(cmd.PrimaryKeyFields) == 0 {
|
|
return errors.New("must define primary-key-fields")
|
|
}
|
|
if cmd.Table == "" {
|
|
return errors.New("must set a table name")
|
|
}
|
|
cmd.outputLocks = make([]sync.Mutex, cmd.PartitionN)
|
|
filelist := []string{}
|
|
walkfunc := func(path string, d fs.DirEntry, err error) error {
|
|
if !d.IsDir() {
|
|
filelist = append(filelist, path)
|
|
}
|
|
return nil
|
|
}
|
|
err := filepath.WalkDir(cmd.File, walkfunc)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "walking %s", cmd.File)
|
|
}
|
|
if len(filelist) > 3 {
|
|
cmd.logDest.Printf("%d files: %v...", len(filelist), filelist[:3])
|
|
} else {
|
|
cmd.logDest.Printf("Files: %v", filelist)
|
|
}
|
|
|
|
if err := os.MkdirAll(cmd.OutputDir, 0755); err != nil {
|
|
return errors.Wrapf(err, "creating output directory: '%s'", cmd.OutputDir)
|
|
}
|
|
|
|
for i := 0; i < cmd.PartitionN; i++ {
|
|
name := filepath.Join(cmd.OutputDir, fmt.Sprintf("output_%d", i))
|
|
f, err := os.Create(name)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "couldn't open output file %s", name)
|
|
}
|
|
cmd.outputFiles[i] = f
|
|
}
|
|
defer func() {
|
|
for _, f := range cmd.outputFiles {
|
|
_ = f.Sync()
|
|
_ = f.Close()
|
|
}
|
|
}()
|
|
|
|
linech := make(chan [][]byte, 16)
|
|
var eg *errgroup.Group
|
|
switch cmd.Type {
|
|
case "ndjson":
|
|
eg = cmd.setupNDJSON(linech)
|
|
case "csv":
|
|
// CSV ends here
|
|
return errors.Wrap(cmd.RunCSV(filelist), "running CSV command")
|
|
default:
|
|
return errors.Errorf("unsupported type %s, try ndjson or csv", cmd.Type)
|
|
}
|
|
|
|
// The rest of this is just for NDJSON... CSV doesn't currently
|
|
// support multiple workers. It needs to do the file reading
|
|
// differently in order to handle potential newlines within
|
|
// fields.
|
|
for _, fname := range filelist {
|
|
f, err := os.Open(fname)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "opening %s", fname)
|
|
}
|
|
|
|
sc := bufio.NewScanner(f)
|
|
batch:
|
|
for {
|
|
lines := make([][]byte, 0, cmd.JobSize)
|
|
for i := 0; i < cmd.JobSize; i++ {
|
|
if !sc.Scan() {
|
|
linech <- lines
|
|
break batch
|
|
}
|
|
line := sc.Bytes()
|
|
// must copy line as scanner will reuse the buffer it's given us
|
|
lc := make([]byte, len(line))
|
|
copy(lc, line)
|
|
lines = append(lines, lc)
|
|
}
|
|
linech <- lines
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return errors.Wrap(err, "scanning file")
|
|
}
|
|
}
|
|
|
|
close(linech)
|
|
if err := eg.Wait(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cmd *PreSortCommand) setupNDJSON(linech chan [][]byte) *errgroup.Group {
|
|
cmd.outputEncoders = make(map[int]*json.Encoder)
|
|
for i, f := range cmd.outputFiles {
|
|
cmd.outputEncoders[i] = json.NewEncoder(f)
|
|
cmd.outputEncoders[i].SetIndent("", "")
|
|
cmd.outputEncoders[i].SetEscapeHTML(false)
|
|
}
|
|
|
|
// start workers
|
|
eg := &errgroup.Group{}
|
|
for i := 0; i < cmd.NumWorkers; i++ {
|
|
eg.Go(func() error {
|
|
return cmd.ndjsonWorker(linech)
|
|
})
|
|
}
|
|
return eg
|
|
}
|
|
|
|
func (cmd *PreSortCommand) ndjsonWorker(linech chan [][]byte) error {
|
|
for lines := range linech {
|
|
rec := map[string]interface{}{}
|
|
for _, line := range lines {
|
|
err := json.Unmarshal(line, &rec)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "unmarshaling '%s'", line)
|
|
}
|
|
partition, err := cmd.ndjsonPartition(rec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cmd.outputNDJSON(partition, rec); err != nil {
|
|
return errors.Wrap(err, "outputting ndjson")
|
|
}
|
|
for k := range rec {
|
|
delete(rec, k)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cmd *PreSortCommand) outputNDJSON(partition int, record map[string]interface{}) error {
|
|
cmd.outputLocks[partition].Lock()
|
|
defer cmd.outputLocks[partition].Unlock()
|
|
|
|
return cmd.outputEncoders[partition].Encode(record)
|
|
}
|
|
|
|
func (cmd *PreSortCommand) ndjsonPartition(rec map[string]interface{}) (int, error) {
|
|
h := fnv.New64a()
|
|
_, _ = h.Write([]byte(cmd.Table))
|
|
|
|
for i, name := range cmd.PrimaryKeyFields {
|
|
val, ok := rec[name]
|
|
if !ok {
|
|
return 0, errors.Errorf("couldn't find primary key part '%s' in record: %+v", name, rec)
|
|
}
|
|
h.Write([]byte(toString(val)))
|
|
if i < len(cmd.PrimaryKeyFields)-1 {
|
|
h.Write([]byte(cmd.PrimaryKeySeparator))
|
|
}
|
|
}
|
|
return int(h.Sum64() % uint64(cmd.PartitionN)), nil
|
|
}
|
|
|
|
func toString(val interface{}) string {
|
|
switch valt := val.(type) {
|
|
case string:
|
|
return valt
|
|
case float64, int, int64, uint64, float32, uint:
|
|
return fmt.Sprintf("%d", valt)
|
|
default:
|
|
return fmt.Sprintf("%v", valt)
|
|
}
|
|
}
|
|
|
|
func (cmd *PreSortCommand) RunCSV(filelist []string) error {
|
|
outputWriters := make(map[int]*csv.Writer)
|
|
for i, f := range cmd.outputFiles {
|
|
outputWriters[i] = csv.NewWriter(f)
|
|
}
|
|
|
|
for _, fname := range filelist {
|
|
f, err := os.Open(fname)
|
|
if err != nil {
|
|
cmd.logDest.Warnf("Could not open %s, skipping", fname)
|
|
}
|
|
reader := csv.NewReader(f)
|
|
reader.ReuseRecord = true
|
|
rec, err := reader.Read()
|
|
if err != nil {
|
|
return errors.Wrap(err, "")
|
|
}
|
|
header := make(map[string]int)
|
|
for i, key := range rec {
|
|
header[key] = i
|
|
}
|
|
for rec, err = reader.Read(); err == nil; rec, err = reader.Read() {
|
|
partition, err := cmd.csvPartition(header, rec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = outputWriters[partition].Write(rec)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "writing record to partition %d", partition)
|
|
}
|
|
}
|
|
if err != io.EOF && err != nil {
|
|
return errors.Wrapf(err, "error attempting to decode json from %s", fname)
|
|
}
|
|
}
|
|
|
|
for _, w := range outputWriters {
|
|
w.Flush()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cmd *PreSortCommand) csvPartition(header map[string]int, rec []string) (int, error) {
|
|
h := fnv.New64a()
|
|
_, _ = h.Write([]byte(cmd.Table))
|
|
|
|
for i, name := range cmd.PrimaryKeyFields {
|
|
pos, ok := header[name]
|
|
if !ok {
|
|
return 0, errors.Errorf("couldn't find primary key part '%s' in header: %+v", name, header)
|
|
}
|
|
h.Write([]byte(rec[pos]))
|
|
if i < len(cmd.PrimaryKeyFields)-1 {
|
|
h.Write([]byte(cmd.PrimaryKeySeparator))
|
|
}
|
|
}
|
|
return int(h.Sum64() % uint64(cmd.PartitionN)), nil
|
|
}
|