featurebase/batch/batch.go
Travis 04aa8b18fc Add DAX - full list of squashed commits below
In this commit, the Directive is mocked; it doesn't actually reach out
to a controller.

Limits key translation to only those partitions (per index) specified in
the Directive. Attempting to create or find a key (or ID) for a
partition which is not handled by this node will result in an error;
translation requests are no longer forwarded to other nodes.

Limits import into only those shards specified, per index, by the
Directive. Attempting to import into a shard which is not handled by
this node will result in an error.

Stub out /directive endpoint

The `applyDirective()` method still needs to be implemented.

Update mds references to use the new /mds/types structure

In mds, we moved the shared types to mds/types. FeatureBase needs to
reference those instead.

This also bumps the mds version in go.mod.

Implement the Add/Remove Index part of Holder.ApplyDirective()

This adds functionality to `Holder.ApplyDirective()` which adds or
removes indexes (tables) based on those provided in the Directive. Still
to be implemented here: shards and partitions.

WIP: remove client from Batch

Move Batch into its own package: batch

Also, in order to avoid import loops, this introduces packages:
/batch/types
/client/types

Reorganize the Importer-related code

Moved the Importer interface to package: batch
Move the "pilosa client" implementation of the Importer interface to
package: client

Modify batch.NewBatch to take an Importer (not client)

This commit modifies the batch.NewBatch() function to use a functinal
option on Batch to inject an Importer into the Batch. Prior to this,
NewBatch() took a pointer to a client, which was a little too
restrictive. Now, MDS can implement an Importer which uses information
from MDS to determine to which node(s) the import calls should be directed.

Add client.SetAuthToken() method to satisfy SchemaManager interface

Update ApplyDirective logic to include fields.

This needs more work, but it was enough to get a basic test passing.

Move Transaction type into /types package.

Add interface check on batch.Importer no-op implementation

Updated ApplyDirective to create all currently support Field types

There are still the following TODOs:
- [ ] impolement field options (ex: decimal scale, int min/max, etc).
- [ ] `time` fields

Added support for Decimal.Scale in ApplyDirective

Update mds dependency

Add /health endpoint

Update to use dax (dax/mds) instead of mds.

After moving the mds repository into the dax repository as a
sub-package, this commit changes everything in FeatureBase to use the
dax repo instead of the now abandoned mds repo.

Introduce and implment the WriteLogger interfaces.

This adds both a `WriteLogReader` and `WriteLogWriter` interface. They
are both implemented by the implementation: `fileWriteLogger`. The
`fileWriteLogger` uses the dax/writelogger API to append log messages to
files on disk.

Add WriteLogWriter.ImportRoaring method to interface

This commit adds the `ImportRoaring` method to the `WriteLogWriter`
interface. Still to implement are the `Import` and `ImportValue`
methods.

Reorganize the ApplyDirective code

The primary goal was to cache the incoming Directive on the Holder prior
to applying all of the changes in the directive (i.e. loading data from
the WriteLogger) because applying those changes often validated against
the accepted state of the node.

Implement all of the WriteLogger read/write methods

Implement the HTTP WriteLogger implementation

WIP: Introduce shard.Version. Implement snapshotter.

Add HTTP Snapshotter implementation

This also recofigures server to use the HTTPSnapshotter instead of the
FileSnapshotter.

Implement snapshotter: TableKeys

Implement snapshotter: FieldKeys

Dependency dance

last of the dependency dance

Add support for prototype

This adds the Makefile targets to build the docker container and push it
to ECR.

SQL3 changes which break with dax changes

Missed TODO: implement FieldVersion version to WriteLogger

Address bug causing missing TranslateStores to error

Originally, we tried to limit the TranslateStores which get allocated to
only those for which the node is responsible. This works when adding a
new table. But if a table already exists, there's no logic to start
missing TranslateStores.

This reverts back to the old FeatureBase logic which brutishly allocates
a TranslateStore for every partition, even if one is not needed.

We need to address this by allowing the ApplyDirective logic to
initialize TranslateStores when they don't yet exist.

Move the ImportRoaringShardRequest type to the types package

Since the ImportRoaringShardRequest object is part of the Importer
interface, we need to move it to a non-root (i.e. pilosa) package. All
the other interface types are either concrete types or part of a
sub-package (such as roaring). We do this to prevent an implementer of
the interface from having to import the entire pilosa package and risk
circular imports.

buncha changes to support latest dax stuff

Move dax related types to /dax sub-package

This commit moves all the common "dax" types into the /dax sub-package.
The idea is to ensure that featurebase does not import dax at all.
It's ok if dax imports featurebase.
In the future, we might need to split the dax sub-package (common data
types used by muliple molecula data-plan services) into it's own repo.

Add type: dax.Schema

This isn't currently being used; I started to use is as a replacement
for pilosa_client.Schema, but then deferred that. But we'll need to do
it eventually, so it doesn't hurt to have this type in place.

Export RowIDs.Merge() method for use in orchestrator.

Add CreateSQL method to dax.Table type

The CreateSQL() method will return the "CREATE TABLE" statement required
to create the dax.Table.

Comment out confusing writelogger log message.

We need to revisit this, but for now, this log message is confusing.

Also, rename daxSharder to versionStore.

Remove hard-coded AWS account

Implement more FieldOptions such as Epoch

Some of the FieldOption logic was stubbed out in the dax package. This
commit fills that out more; specifically, it adds the
dax.Field.Options.Epoch parameter.

export stuff needed for TopK in orchestrator

export ValCount stuff to implement Percentile in orchestrator

export more stuff to support less code in orchestrator, shared objs

Port dax repo over to featurebase/dax (run all as sub-services)

This commit does ALOT. Sorry.
It introduces a `featurebase dax` sub-command which can be configured to
run the various dax services as sub-services within the same process, or
individually as the lone service in process.

It also changes all the URL paths to be prefixed with the service name.
So for example, instead of calling localhost:8080/status, you would now
call localhost:8080/featurebase/status.

Also, note that all services provide a /health endpoint to confirm they
are running in process.

Clean up integration tests. Remove PILOSA_ config prefix.

Remove duplicate clients (mistake from porting dax to featurebase)

Rename sub-service "featurebase" to "computer"

In the places where we have hard-coded the sub-service name into a URI
path, I've tried to tag the line with a comment containing:
`// #SERVICEPATHPREFIX`

Update copilot manifest files to reference "computer"

Port dax/README.md from dax repository

Separate (toml) Queryer Config from Injections

We needed to separate the toml config from the configuration required to
inject sub-services into the Queryer. I'm not sure this is the best
solution, but it's *a* solution. So here we are.

Clean up (i.e. remove) the queryer "implementations" package

Remove old test file

Run WriteLogger and Snapshotter as local sub-services.

Prior to this commit, the writelogger and snapshotter services only
worked when run as separate services. This allows them to be run in the
same process as all the other dax services.

There is still some naming issues that we should address, but it's
functional for now.

Clean up (i.e. organize) the intra-service interfaces.

Implement alpha Director for local messages from MDS to Computer

Prior to this commit, messages from MDS to the computer service were
still going over http. This commit introduces an interface
implementation which registers the local computer command, and use that
command's API to directly reference methods used by the Director.

Clean up a few more interface names

Add Queryer OpenAPI document.

Update copilot manifests to reflect latest changes

Add OpenAPI documents for WriteLogger and Snapshotter

Add OpenAPI document for MDS service

Add OpenAPI document for Computer service

Consolidate errors to use fb/errors package.

This commit is a first pass at trying to ensure that all of the DAX code
uses:
"github.com/molecula/featurebase/v3/errors"

This package is a wrapper for "github.com/pkg/errors", so going forward
we want to avoid importing that package.

The only method which isn't backward-compatible is `New()`; the
New() method in the featurebase/errors package takes an errors.Code. If
this becomes a problem, we could change this by reverting New() and then
introducing something like NewCoded(). But for now I think it might
actually discourage someone from just creating a New() error without
thinking about how it should be coded.

Introduce VersionStore interface

Move the existing VersionStore code to the `inmem` package as the
in-memory implementation of the new dax.VersionStore interface.

Introduce NodeService interface

With this, the Controller can maintain a registry of nodes by using this
NodeService interface as opposed to an in-memory map of nodes on the
Controller struct.

This also adds an inmem implementation of the NodeService interface.

Introduce controller.Balancer interface

This moves the existing balancer package to controller/naive package.
The idea is to allow us to add a different Balancer implementation in
the future.

Introduce DirectiveVersion interface

This commit also includes *A LOT* of refactoring to use dax.Worker and
dax.Job types everywhere instead of strings.

Introduce Schemar interface

The previous `Schemar` struct was moved to the `schemar/inmem` package,
and `Schemar` is now an interface implemented by that inmem package.

Remove unused type `nUnit`

Add boltdb implementation of VersionStore interface.

This removed the previous sqlite implementation; we decided not to use
sqlite for now (as a basic, local disk implementation) because it
requires CGO.

--------------------------------------------
No longer applicable:

Add sqlite implementation of VersionStore interface.

This commit implements the VersionStore interface using sqlite. Sqlite
requires CGO, so this may not be something we want to include, but it's
implemented here to get a feel for how an external implementation might
be used; the next step will be to determine how the user configured
FeatureBase to run using sqlite as a backing store for services like
MDS.

Add boltdb implementation of NodeService and DirectiveVersion interfaces.

Add boltdb implementation of naive Balancer interfaces.

This includes the two interfaces defined in `naive/balancer.go`:
- WorkerJobService
- FreeJobService

Add boltdb implementation of Schemar interface.

clean up a linter issue

Thread context.Context through all the interfaces.

Some of the interface implementations are going to use context, so we
need to make that part of the interface. The boltdb implementations, for
example, take a context. This is probably so we can do things like
cancel or timeout operations.

Update interfaces to return error; remove `panic(err)` everywhere.

Down-rev grpc version to 1.38.0

Later versions (after 1.42.0?) cause MustRunCluster.Close() in tests to
deadlock.

This commit also adds an `isComputeNode` feature flag around some of the
write log and shard/partition check functionality so that it doesn't run
under normal conditions (this is excercised by running the sql3 tests
for example).

Add MDS_Persistence test to cover meta data persistence

This adds a basic test which configures the MDS container to use boltdb
as its persistence storage, saved on a docker volume. Then, the mds
container is stopped/replaced, and we confirm that the data stored on
the volume is availble to the new MDS container.

Fix a few things after rebase with sql-experiment branch

The lastest version of sql-experiment contains a fairly significan
refactor of the way query iteration works. This commit adjusts for those
changes.

pull dax IDK changes in to FB IDK (#2177)

* pull dax IDK changes in to FB IDK

* Move docker-related IDK build stuff to featurebase root

Building the docker image required the root level go.mod and vendor
directory. This change moves the make targets to the root level
Makefile, and the Dockerfiles now copy the root level vendor directory
(and everything else in the root for that matter).

* Fix batch- and client-related tests

* InitializePoller on MDS restart/replacement

Prior to this change, if MDS was restarted, its internal poller (which
maintains an in-memory list of nodes to poll) is empty. This is bad,
because it doesn't know about nodes that it should be polling.

This change fixes that. Upon MDS startup, it intializes the poller with
the list of nodes that MDS keeps in persistent storage (currently:
boltdb).

* Add EFS volume to MDS Copilot manifest

This allows us to use MDS's persistent storage (via boltdb) in the
Copilot demo by saving metadata in a boltdb file on EFS.

* Thread logger.Logger through all dax components

* Revert some of the breaking changes from DAX development.

When we first started prototyping DAX, we made changes to the
featurebase core code which would have broken the existing featurebase
functionality. This commit reverts some of those changes. Anywhere that
we need to modify core featurebase functionilty, we put it behind some
kind of feature flag. This flag is typically determined by whether the
running node is a "compute" node (i.e. DAX.COMPUTER.RUN = true).

Co-authored-by: Travis Turner <travis@molecula.com>

add packaging for DAX

need cgo for datagen build

bind to 0.0.0.0, pass GOOS and GOARCH explicitly

not sure if the explicit GOOS/GOARCH is actually necessary...

Get INSERT INTO (aka ingest) working through SQL3

This commit does a few things which I'll try do describe here.

- Introduces a Qctx interface. The existing Qcx is an implementation of
  this interface, and can be used exactly how it has been. But this
  allows us to abstract away the notion of Qcx in the Queryer (which is
  handling SQL3) until we're ready to address that. As an example, the
  Qcx has a notion of a featurebase Holder, but that doesn't make sense
  when we're at the Queryer layer. For now, the Qctx used in the Queryer
  is a no-op.

- Adds a ComputeAPI interface implementation for the Queryer. This is
  effectively the Import() and ImportValues() methods used for ingest.
  The logic here handles the incoming ImportRequest by first doing any
  necessary column and row translation for the entire request, then it
  splits the records by shard, and generates a new ImportRequest per
  shard with only the shard-appropriate records.

- Changes the mds.Importer to take an MDS interface implementation
  (which can be an mds client) instead of an mdsAddress. This allows us
  to use a localy MDS implementation rather than assuming we need a
  client to make calls over a network.

Add queryer.Importer interface to handle ingest via SQL (#2203)

* Add queryer.Importer interface to handle ingest via SQL

This is meant to support ingest through SQL when the queryer and the
compute services are running in the same process, or when they are on
seperate processes and need to talk via http client.

* remove datagen from RPM

was originally added as a convenience to generate test data, but is
unused and annoying because datagen doesn't easily cross-compile due
to cgo

* add marshalUnmarshal to controller to avoid passing pointers

passing pointers across API boundaries can cause unpredictable things
in local vs remote configurations.

Co-authored-by: Matthew Jaffee <jaffee@pilosa.com>

"fix" a few issues with wrong default partition numbers

these still need to be properly fixed and actually get the correct
data from MDS

go mod tidy

Introduce TableQualifier (OrganizationID/DatabaseID) (#2220)

* add check in ApplyDirective that version is increasing

fix TestAPIDirective to make version always increasing

* fix docker image build and break out dax test in CI

We have to run the DAX integration tests separately as they call out
to Docker, and so it isn't easy to run them in a Docker container as
the other tests do. So we run them directly on the CI runner which has
Docker and Go installed.

We also explicitly exclude these tests from running during the other
tests.

Also my editor was automatically reformatting some comments badly
which is why I added the "data" thing in those two places

* add timeout to poller

* give Poller a default Logger

apparently we can NPE sometimes, seen in CI: https://gitlab.com/molecula/featurebase/-/jobs/3028286364

* bunch of testing fixes, mostly IDK/DAX related

make MDS error if sendDirectives errors, don't just
log. sendDirectives can error if computer nodes disagree about the
validity of a schema (for example), in which case it might need to get
deleted and user notified somehow. very messy, needs more thought.

re-introduce old env prefix to maintain compatibility with master
branch

make self-contained dax container for IDK testing

build IDK images from source (now that all the source is available
since it's in the same repo)

catch errors in DoExtractQuery in idktest.go

fix IDK bug where prefix path was hardcoded in all cases rather than
only when useMDS was true

fix TestBatchTargetMDS... needed to add field options and catch error
when creating table. also needed an _id field

* fix env prefix in tests

* WIP getting tests to pass, wanna see CI

* don't error if we get a zero version directive and we don't have a

directive yet

* cleanup debugging junk

* "fix" future.rename thing, run IDK tests

* Introduce TableQualifier (OrganizationID/DatabaseID)

This commit introduces a lot of new types (in dax/table.go) related to
TableQualifer (which is made up of OrganizationID and DatabaseID), as
well as things like TableID and TableKey.

For the most part, we try to thread a QualifiedTableID through the
entirety of DAX. There are some places (for example in the Balancers,
which are just aware of string keys) which use a string TableKey
(tbl__org__db__tableid).

* Remove some debugging comments

* Add Org/DB support to CLI.

This commit adds support for special commands:

SET
SET ORG acme
SET DB db1
USE db1

* remove ".pulled" from IDK Makefile

I don't think we need it any more as most things can be built
locally. I think it was only there to refresh the FeatureBase images
that were tagged as master, but we don't need to do that any more.

* Change DAX json tags to kebab-case (i.e. hyphenated)

This commit also renames some struct arguments to more accurately
reflect their type: for example, renaming `Table` to `TableKey` when the
type is TableKey.

* Return DAX TableName in SHOW TABLES (instead of Index.Name)

There are cases where SchemaAPI is used to return DAX friendly table
names (as opposed to featurebase index names, which are DAX TableKey).

This is an attempt to do that. With that said, it's not ideal because
anything could call those API methods and expect the other type.

* Fix a bug which wasn't completely dropping a table.

When using boltdb as a backend, DROP TABLE wasn't removing the
reverse-lookup key for the table in boltdb.

* Remove idk/testenv/certs which got accidentally committed.

also update .gitignore to include those.

* Fix IDK ingest tests to be TableQualifier aware.

* Add example Table types to dax/table.com godoc.

* ignore idk.Main fields for flags, upgrade commandeer

* go mod tidy

* Fix DAX integration tests: ingester using wrong ENV VARs

We change from ORGANIZATION_ID to ORG_ID
and from DATABASE_ID to DB_ID

* Clarify things around idk (docker) tests

* Stop running TestKafkaSourceIntegration with t.Parallel()

This test can't be run in parallel as it's currently written. Doing so
allows for interleaving of messages to the same kafka topic between
tests.

I didn't attempt to modify the test so it could be run in parallel. That
could be done, but left for someone more ambitious.

Co-authored-by: Matthew Jaffee <jaffee@pilosa.com>

Require Directive.Version be a non-zero value. (#2227)

Because the directive cached on the holder is not a pointer, its default
version is 0. In order to avoid having to compare against that, we just
require that Directive.Version start at 1.

General, non-invasive code cleanup and comment adjustment.

Move ImportRoaringShardRequest out of the types package

Early on in the DAX development, I moved ImportRoaringShardRequest into
a types package. There must have been some import loop going on, but
since that is not longer the case, it's safe to move this back into the
core featurebase (er... pilosa) package.

Move Transaction struct back into the pilosa package (from types)

Revert some name changes (cli -> client)

Add DAX Handler CloseTimeout

This was implemented in htt_handler.go, but it had been commented out in
the DAX handler. This just uncomments that and finishes the
implementation.

Remove Qcx from queryer.Importer interface

This sets us up to revert the Qctx interface that was initially
introduced to allow us to abstract away the need for a Qcx when calling
the ComputeAPI from a remote service (i.e. the queryer).

Add some go-doc comments and remove unused code.

Move SchemaManager setup from datagen to idk.Main (#2233)

The set for idk.SchemaManager (for dax implementations) was previously
in datagen. This may have been because of some import loop problem
during development, but that's no longer an issue.

The setup for this should be in idk.Main so anything using that can
leverage the MDS-specific SchemaManager setup.

Fix issues around nil TxFactory

First, don't return a nil. Rather return a new *TxFactory (with no
holder).

Second, don't call `f.holder` in the testhook outside of checking if
`f.holder` is nil.

Wrap all bare errors

Make service prefixes constants

Instead of having `"computer"` throughout the code, use instead a
constant: `dax.ServicePrefixComputer`.

MDS skip errors when sending empty directives

also add in the docker-login and ecr-push changes for serverless DAX

Fix the logic in Directive.IsEmpty() (#2236)

Update the cached value for Index.translatePartitions

In the case where a node already knows about an index, but its
assignment of partitions for that index changes (for example, when
another node goes down and the node in question is now responsible for
more partitions than it previously was), then we need to update the
cached value of Index.translatePartitions because that's used in
translation checks.

minor fixes for IDK-related bugs

WIP: tokenize CLI to access cloud

FB CLI cloud support with automatic token refresh

Also adds support for a GET command which allows making HTTP GET
queries to cloud CP which can be handy for debugging stuff. E.g. GET /v2/databases

buncha little fixes working on writelogger stuff

fix writelogger/snapshotter setup bugs

implement writelogging for importRoaringShard

add debug endpoint to MDS

use shard transactional endpoint in MDS datagen

add debugging to API related to writelogger

revert handleroption change

clean up big PR

remove "GET" command from CLI for making arbitrary HTTP request to
cloud control plane (was a messy hack and not that useful)

remove json tags from FB objects where we had to duplicate the object
elsewhere due to import loops and weren't actually json encoding it

unexport handlerOption which was exported to try to avoid doing
certain things if we're in DAX mode, but I didn't end up merging that code.

remove (hopefully) unecessary extra call to api.indexField

fix some formatting, unexport some vars, godoc, etc

oops, fix build failure

Update FeatureBase CLI to support a standard deployment

The standard deployment uses a different endpoint and request payload.
This commit tries to detect is the standard deployment is being used,
and if so, it uses a standard-specific FBQueryer.

It also modifies the auto-detection logic to try standard featurebase
and dax ports in the case where a port was not provided.

MDS API refactor (#2259)

* MDS API refactor

table IDs are exposed but only created server side

also cleaned up dax Makefile

* clean up review feedback

Co-authored-by: Travis Turner <travis@pilosa.com>

* remove TablesByName

* rip out inmem implementations and use boltdb everywhere

* remove inmem balancer, create bolt tempfile by default on startup

* WIP on snapshot table impl and test

* Minor comment and code layout adjustments.

This commit also adds the `Equals` method to `QualifiedTableID` for
equality comparisons. It's no longer safe to compare struct (two structs
might still be equal even if one of the structs doesn't have a `Name`
value.

* Use a unique docker network for each dax test

Ocassionally we would see some test failures due to a network already
existing. This shouldn't happen, but to avoid that, this commit
generates a unique name for each sub test (which gets deleted at the end
of every test).

* Fix one instance of NewQualifiedTableID losing Name

We should probably check the other instances and see if Name is getting
lost.

* simplify unique network stuff and fix api directive tests

* Remove TableIDRequest and TableIDResponse types for /table-id (#2267)

For the mds/table-id http requests, just use dax.QualifiedTableID as
both the request and response types.

* remove lattice from dax, no error on node re-reg, dax docker-compose

* various updates

* WIP: mds-refactor branch review

* no-op on SnapshotTableKeys if table is not keyed

* Makefile helpers

* add doWeCare so controller doesn't fail unnecessarily

* clean up table creation (#2272)

* Strip underscores from TableID stub name

* fix boltdb versionstore tests: generate unique, sorted tables

* fix controller test related to reregistering a node

* JobSet -> generic Set

Co-authored-by: Travis Turner <travis@pilosa.com>
Co-authored-by: Travis Turner <travis@molecula.com>

Cleanup after rebase on master

The latest rebase on master entailed all the client/batch changes as
well as some of the qcx refactoring. It made for a hairy rebase. This
commit fixes some of the tests that were failing after that rebase.

Fix batch/client import loop missed during rebase (#2280)

It's not surprising that `batch` can't import `client`. It was doing
that here (importing an error type from the `client` package). What is
surprising is that it's okay for `batch_test.go` to import `client` even
though `batch_test.go` is an internal test and therefore part of the
`batch` package.

different boltDB's for schemar/controller, explicit balancers

nice helpers for dax docker-compose, make build really fast

build FB binary outside of docker, then create Docker image with its
working dir in an empty subdirectory so it doesn't send a GB of
context to the daemon.

error on unassigned jobs and use client with timeout

fix CR feedback

deregister batch of nodes

also make removal faster via director dial timeout

implement WorkersForJobPrefix so orchestrator doesn't make up shards

also fix some godocs and remove unused method

Run sub-tasks of a Directive concurrently in a worker pool. (#2275)

* Run sub-tasks of a Directive concurrently in a worker pool.

This allows the compute node to concurrently load shapshot and writelog
data concurrently, instead of one keyset/partition/shard at a time.

It introduces a config parameter called `DirectiveWorkerPoolSize`.

* code review cleanup

* Use unique container names in DAX integration tests

We were seeing "container already exists" errors in CI, so just to be
safe, this commit constructs a unique container name for every container
in the DAX integration test run.

Stub in SystemAPI to Queryer (note: will not work if used)

This just makes is so that dax can compile. Actually implementing
system-table functionality for dax will take some planning.

Tlt/dax merge prep (#2282)

* Remove copilot directory

* Remove Dockerfile-datagen-long

* Remove orphaned RegisterNodeRequest

This type is not defined in the dax/mds/http package.

* implement TIMEQUANTUM and TTL in Table.Field type

* Remove the "service" misdirection in queryer/writelogger/snapshotter.

We had originally used an additional layer, er.. package, for a "service".
The main distinction was that the Config differed in that it was
internal, unlike the Config that we need to provide for the top-level
server config (i.e. toml). Having that additional layer just to support
a different Config seemed premature at best. So I'm removing it.

* Remove dax docker containers no longer used in tests

Since we run everything as "featurebase", we don't have multiple
container types anymore.

* Some minor comment updates

* Remove nfpm stuff related to dax

* Fix linter issues

Fix "duplicate" issues raised by sonarcloud.

run docker components of dax integration tests with coverage

trying to get dax integration coverage

add coverate volume mounts throughout dax integration tests

add a lock, tweak dax Makefile, remote flag on query handler

remove some unused code

convert batch tests to use clustertests to get coverage

maybe fix clustertests

more authclustertests fixes, test is failing locally

but also seems to have been silently failing in CI prior to these
changes... let's see if it's still silent

fix some lint to kick CI

just re-running the job wasn't working... strange behavior

remove RetryLogic test and pipe which don't work

RetryLogic test removed due to etcd changes. Seebs thinks we shouldn't
test this here.

Pipe was being ignored since we're no longer using "bash -c" to
execute the command. If we need to generate that output file we'll
either have to reintroduce bash -c and set -o pipefail so that it
actually fails properly, or figure out some other solution.

shooting into the dark...

first cut at bulk node registration

remove unused stuff from batch tests, set coverpkg to ../...

batch registration timeout and fix tests

disable most tests and don't run fb background batch test

debuggin!!!!!!!!!

and then he tried this....

Implement importer (for INSERT INTO) in the Queryer

Prior to this, we we passing a nil value in for the importer to the
planner.NewExecutionPlanner in the Queryer. This meant that INSERT INTO
statements didn't work. Now they should.

It uses the importer that we build for IDK in /idk/mds/importer.go, and
wrapps that with a type that can determine if the provided string
"index" is of the form indexName or TableKey.

turn off debug mode, fix log saving

Run sql3 test definitions in a dax integration test

There are currently 22 tests which are not passing. They are skipped in
the "skips" slice.

WIP, not working, pql queries to tests

Add TableQualifier to PQL query logic in the Queryer

Add more PQL tests to the keyed table

Allow instant node registration if registration-batch-timeout=0

When running dax services in process, we don't want to wait 3s for the
compute node to register; we know it's there because it's in the same
process.

Fixes related to IncludesColumn PQL test.

Tests for ConstRow and FieldValue

cleanup

add UnionRows and Options, better error reporting on bad queries

delete unused schemar client.go, clean up unused in batch test CI

move test timeouts into more reasonable territory

apparently this had already been done, but got merge-stommped at some point

move dax bolt test helpers into dax package

Add computer CheckIn routine (#2296)

* Add computer CheckIn routine

This adds a background routine which sends a "check-in" request to MDS
every <interval>. This is to address the case where the poller has
removed a computer node from the node list (due to a network fault, for
example), but the node is still healthy and becomes available again. In
that case, the node needs to "check-in" to tell MDS it is still there.
MDS will likely send the node a new directive with Method=reset telling
the node to delete all of its data an apply the latest directive.

* Don't send directives to Deregistered (i.e. removed) nodes

We have an issue where we're locking on sendDirective in the
controller, and when the node is unavailable, the send hangs and never
releases the lock. This is a temporary fix for that until we address the
real problem.

Fix .gitlab-ci.yml after rebase

fix some indentation shenanigans

(cherry picked from commit 20a8b5713a)
2022-12-12 09:01:20 -08:00

1918 lines
56 KiB
Go

// Copyright 2021 Molecula Corp. All rights reserved.
// Package batch provides tooling to prepare batches of records for ingest.
package batch
import (
"bytes"
"context"
"math/bits"
"sort"
"sync"
"time"
featurebase "github.com/featurebasedb/featurebase/v3"
"github.com/featurebasedb/featurebase/v3/batch/egpool"
"github.com/featurebasedb/featurebase/v3/logger"
"github.com/featurebasedb/featurebase/v3/pql"
"github.com/featurebasedb/featurebase/v3/roaring"
"github.com/pkg/errors"
)
// Batch defaults.
const (
DefaultKeyTranslateBatchSize = 100000
existenceFieldName = "_exists"
)
// TODO if using column translation, column ids might get way out of
// order. Could be worth sorting everything after translation (as an
// option?). Instead of sorting all simultaneously, it might be faster
// (more cache friendly) to sort ids and save the swap ops to apply to
// everything else that needs to be sorted. Note: we're already doing
// some sorting in importValueData and importMutexData, so if we
// implement it at the top level, remember to remove it there.
// TODO support clearing values? nil values in records are ignored,
// but perhaps we could have a special type indicating that a bit or
// value should explicitly be cleared?
// RecordBatch is a Pilosa ingest interface designed to allow for
// maximum throughput on common workloads. Users should call Add()
// with a Row object until it returns ErrBatchNowFull, at which time
// they should call Import(), and then repeat.
//
// Add will not modify or otherwise retain the Row once it returns, so
// it is recommended that callers reuse the same Row with repeated
// calls to Add, just modifying its values appropriately in between
// calls. This avoids allocating a new slice of Values for each
// inserted Row.
//
// The supported types of the values in Row.Values are implementation
// defined. Similarly, the supported types for Row.ID are
// implementation defined.
type RecordBatch interface {
Add(Row) error
// Import does translation, creates the fragment files, and then,
// if we're not using split batch mode, imports everything to
// Pilosa. It then resets internal data structures for the next
// batch. If we are using split batch mode, it saves the fragment
// data to the batch, resets all other internal structures, and
// continues.
// Split batch mode DOES NOT CURRENTLY SUPPORT MUTEX OR INT FIELDS!
Import() error
// Len reports the number of records which have been added to the
// batch since the last call to Import (or since it was created).
Len() int
// Flush is only applicable in split batch mode where it actually
// imports the stored data to Pilosa. Otherwise it simply returns
// nil.
Flush() error
}
// agedTranslation combines a translation with a recording of when it was last used.
type agedTranslation struct {
id uint64
lastUsed uint64
}
// Batch implements RecordBatch.
//
// It supports Values of type string, uint64, int64, float64, or nil. The
// following table describes what Pilosa field each type of value must map to.
// Fields are set up when calling "NewBatch".
//
// | type | pilosa field type | options |
// |--------+-------------------+-----------|
// | string | set | keys=true |
// | uint64 | set | any |
// | int64 | int | any |
// | float64| decimal | scale |
// | bool | bool | any |
// | nil | any | |
//
// nil values are ignored.
type Batch struct {
importer Importer
index *featurebase.IndexInfo
header []*featurebase.FieldInfo
headerMap map[string]*featurebase.FieldInfo
// prevDuration records the time that each doImport() takes. This
// is used to set the timeout for transactions to a reasonable
// value based on the last import. It starts with a conservative
// default set in NewBatch.
prevDuration time.Duration
// ids is a slice of length batchSize of record IDs
ids []uint64
// rowIDs is a map of field index (in the header) to slices of
// length batchSize which contain row IDs.
rowIDs map[int][]uint64
// clearRowIDs is a map[fieldIndex][idsIndex]rowID we don't expect
// clears to happen very often, so we store the idIndex/value
// mapping in a map rather than a slice as we do for rowIDs. This
// is a potentially temporary workaround to allow packed boolean
// fields to clear "false" values. Packed fields may be more
// completely supported by Pilosa in future.
clearRowIDs map[int]map[int]uint64
// rowIDSets is a map from field name to a batchSize slice of
// slices of row IDs. When a given record can have more than one
// value for a field, rowIDSets stores that information.
rowIDSets map[string][][]uint64
// values holds the values for each record of an int field
values map[string][]int64
// boolValues is a map[fieldName][idsIndex]bool, which holds the values for
// each record of a bool field. It is a map of maps in order to accomodate
// nil values (they just aren't recorded in the map[int]).
boolValues map[string]map[int]bool
// boolNulls holds a slice of indices into b.ids for each bool field which
// has nil values.
boolNulls map[string][]uint64
// times holds a time for each record. (if any of the fields are time fields)
times []QuantizedTime
// nullIndices holds a slice of indices into b.ids for each
// integer field which has nil values.
nullIndices map[string][]uint64
// TODO support bool fields.
// for each field, keep a map of key to which record indexes that key mapped to
toTranslate map[int]map[string][]int
toTranslateClear map[int]map[string][]int
// toTranslateSets is a map from field name to a map of string
// keys that need to be translated to sets of record indexes which
// those keys map to.
toTranslateSets map[string]map[string][]int
// toTranslateID maps each string key to a record index - this
// will get translated into Batch.rowIDs
toTranslateID []string
colTranslations map[string]agedTranslation
rowTranslations map[string]map[string]agedTranslation
cycle uint64
maxAge uint64
// staleTime tracks the time the first record of the batch was inserted
// plus the maxStaleness, in order to raise ErrBatchNowStale if the
// maxStaleness has elapsed
staleTime time.Time
maxStaleness time.Duration
// Maximum number of keys to translate at one time.
keyTranslateBatchSize int
log logger.Logger
// experimental — only used by FlushToFragments which is an
// alternative to Import which just builds the bitmap data for a
// batch without actually importing it.
splitBatchMode bool
frags fragments
clearFrags fragments
useShardTransactionalEndpoint bool
mdsHost string
}
func (b *Batch) Len() int { return len(b.ids) }
// BatchOption is a functional option for Batch objects.
type BatchOption func(b *Batch) error
func OptLogger(l logger.Logger) BatchOption {
return func(b *Batch) error {
b.log = l
return nil
}
}
func OptSplitBatchMode(on bool) BatchOption {
return func(b *Batch) error {
b.splitBatchMode = on
return nil
}
}
func OptCacheMaxAge(age uint64) BatchOption {
return func(b *Batch) error {
b.maxAge = age
return nil
}
}
func OptMaxStaleness(t time.Duration) BatchOption {
return func(b *Batch) error {
b.maxStaleness = t
return nil
}
}
func OptKeyTranslateBatchSize(v int) BatchOption {
return func(b *Batch) error {
b.keyTranslateBatchSize = v
return nil
}
}
// OptUseShardTransactionalEndpoint tells the batch to import using
// the newer shard-transactional endpoint.
func OptUseShardTransactionalEndpoint(use bool) BatchOption {
return func(b *Batch) error {
b.useShardTransactionalEndpoint = use
return nil
}
}
func OptImporter(i Importer) BatchOption {
return func(b *Batch) error {
b.importer = i
return nil
}
}
// NewBatch initializes a new Batch object which will use the given Importer,
// index, set of fields, and will take "size" records before returning
// ErrBatchNowFull. The positions of the Fields in 'fields' correspond to the
// positions of values in the Row's Values passed to Batch.Add().
func NewBatch(importer Importer, size int, index *featurebase.IndexInfo, fields []*featurebase.FieldInfo, opts ...BatchOption) (*Batch, error) {
if len(fields) == 0 {
return nil, errors.New("can't batch with no fields")
} else if size == 0 {
return nil, errors.New("can't batch with no batch size")
}
headerMap := make(map[string]*featurebase.FieldInfo, len(fields))
rowIDs := make(map[int][]uint64, len(fields))
values := make(map[string][]int64)
boolValues := make(map[string]map[int]bool)
boolNulls := make(map[string][]uint64)
tt := make(map[int]map[string][]int, len(fields))
ttSets := make(map[string]map[string][]int)
hasTime := false
for i, field := range fields {
headerMap[field.Name] = field
opts := field.Options
// The client package has a FieldTypeDefault, but featurebase does not.
// When this code was moved from the client package to the batch
// package, FieldTypeDefault was no longer available. It probably isn't
// necessary, but to ensure backwards compatiblity, we continue to
// support it here with an unexported variable.
fieldTypeDefault := ""
switch typ := opts.Type; typ {
case fieldTypeDefault, featurebase.FieldTypeSet, featurebase.FieldTypeTime:
if opts.Keys {
tt[i] = make(map[string][]int)
ttSets[field.Name] = make(map[string][]int)
}
hasTime = typ == featurebase.FieldTypeTime || hasTime
case featurebase.FieldTypeInt, featurebase.FieldTypeDecimal, featurebase.FieldTypeTimestamp:
// tt line only needed if int field is string foreign key
tt[i] = make(map[string][]int)
values[field.Name] = make([]int64, 0, size)
case featurebase.FieldTypeMutex:
// similar to set/time fields, but no need to support sets
// of values (hence no ttSets)
if opts.Keys {
tt[i] = make(map[string][]int)
}
rowIDs[i] = make([]uint64, 0, size)
case featurebase.FieldTypeBool:
boolValues[field.Name] = make(map[int]bool)
default:
return nil, errors.Errorf("field type '%s' is not currently supported through Batch", typ)
}
}
b := &Batch{
importer: importer,
header: fields,
headerMap: headerMap,
prevDuration: time.Minute * 11,
index: index,
ids: make([]uint64, 0, size),
rowIDs: rowIDs,
clearRowIDs: make(map[int]map[int]uint64),
rowIDSets: make(map[string][][]uint64),
values: values,
boolValues: boolValues,
boolNulls: boolNulls,
nullIndices: make(map[string][]uint64),
toTranslate: tt,
toTranslateClear: make(map[int]map[string][]int),
toTranslateSets: ttSets,
colTranslations: make(map[string]agedTranslation),
rowTranslations: make(map[string]map[string]agedTranslation),
maxAge: 64,
maxStaleness: time.Duration(0),
keyTranslateBatchSize: DefaultKeyTranslateBatchSize,
log: logger.NopLogger,
frags: make(fragments),
clearFrags: make(fragments),
}
if hasTime {
b.times = make([]QuantizedTime, 0, size)
}
for _, opt := range opts {
err := opt(b)
if err != nil {
return nil, errors.Wrap(err, "applying options")
}
}
return b, nil
}
// Row represents a single record which can be added to a Batch.
type Row struct {
ID interface{}
// Values map to the slice of fields in Batch.header
Values []interface{}
// Clears' int key is an index into Batch.header
Clears map[int]interface{}
// Time applies to all time fields
Time QuantizedTime
}
// QuantizedTime represents a moment in time down to some granularity
// (year, month, day, or hour).
type QuantizedTime struct {
ymdh [10]byte
}
// Set sets the Quantized time to the given timestamp (down to hour
// granularity).
func (qt *QuantizedTime) Set(t time.Time) {
copy(qt.ymdh[:], t.Format("2006010215"))
}
// SetYear sets the quantized time's year, but leaves month, day, and
// hour untouched.
func (qt *QuantizedTime) SetYear(year string) {
copy(qt.ymdh[:4], year)
}
// SetMonth sets the QuantizedTime's month, but leaves year, day, and
// hour untouched.
func (qt *QuantizedTime) SetMonth(month string) {
copy(qt.ymdh[4:6], month)
}
// SetDay sets the QuantizedTime's day, but leaves year, month, and
// hour untouched.
func (qt *QuantizedTime) SetDay(day string) {
copy(qt.ymdh[6:8], day)
}
// SetHour sets the QuantizedTime's hour, but leaves year, month, and
// day untouched.
func (qt *QuantizedTime) SetHour(hour string) {
copy(qt.ymdh[8:10], hour)
}
func (qt *QuantizedTime) Time() (time.Time, error) {
return time.Parse("2006010215", string(qt.ymdh[:]))
}
// Reset sets the time to the zero value which generates no time views.
func (qt *QuantizedTime) Reset() {
for i := range qt.ymdh {
qt.ymdh[i] = 0
}
}
// views builds the list of Pilosa views for this particular time,
// given a quantum.
func (qt *QuantizedTime) views(q featurebase.TimeQuantum) ([]string, error) {
zero := QuantizedTime{}
if *qt == zero {
return nil, nil
}
views := make([]string, 0, len(q))
for _, unit := range q {
switch unit {
case 'Y':
if qt.ymdh[0] == 0 {
return nil, errors.New("no data set for year")
}
views = append(views, string(qt.ymdh[:4]))
case 'M':
if qt.ymdh[4] == 0 {
return nil, errors.New("no data set for month")
}
views = append(views, string(qt.ymdh[:6]))
case 'D':
if qt.ymdh[6] == 0 {
return nil, errors.New("no data set for day")
}
views = append(views, string(qt.ymdh[:8]))
case 'H':
if qt.ymdh[8] == 0 {
return nil, errors.New("no data set for hour")
}
views = append(views, string(qt.ymdh[:10]))
}
}
return views, nil
}
func (b *Batch) getColTranslation(key string) (uint64, bool) {
trans, ok := b.colTranslations[key]
if ok {
trans.lastUsed = b.cycle
b.colTranslations[key] = trans
}
return trans.id, ok
}
func (b *Batch) getRowTranslation(field, key string) (uint64, bool) {
trans, ok := b.rowTranslations[field][key]
if ok {
trans.lastUsed = b.cycle
b.rowTranslations[field][key] = trans
}
return trans.id, ok
}
// Add adds a record to the batch. Performance will be best if record
// IDs are shard-sorted. That is, all records which belong to the same
// Pilosa shard are added adjacent to each other. If the records are
// also in-order within a shard this will likely help as well. Add
// clears rec.Clears when it returns normally (either a nil error or
// BatchNowFull).
func (b *Batch) Add(rec Row) error {
// Clear recValues and rec.Clears upon return.
defer func() {
for i := range rec.Values {
rec.Values[i] = nil
}
for k := range rec.Clears {
delete(rec.Clears, k)
}
}()
if len(b.ids) == cap(b.ids) {
return ErrBatchAlreadyFull
}
if len(rec.Values) != len(b.header) {
return errors.Errorf("record needs to match up with batch fields, got %d fields and %d record", len(b.header), len(rec.Values))
}
handleStringID := func(rid string) error {
if rid == "" {
return errors.Errorf("record identifier cannot be an empty string")
}
if colID, ok := b.getColTranslation(rid); ok {
b.ids = append(b.ids, colID)
} else {
if b.toTranslateID == nil {
b.toTranslateID = make([]string, cap(b.ids))
}
b.toTranslateID[len(b.ids)] = rid
b.ids = append(b.ids, 0)
}
return nil
}
var err error
switch rid := rec.ID.(type) {
case uint64:
b.ids = append(b.ids, rid)
case string:
err := handleStringID(rid)
if err != nil {
return err
}
case []byte:
err = handleStringID(string(rid))
if err != nil {
return err
}
default: // TODO support nil ID as being auto-allocated.
return errors.Errorf("unsupported id type %T value %v", rid, rid)
}
// curPos is the current position in b.ids, rowIDs[*], etc.
curPos := len(b.ids) - 1
if b.times != nil {
b.times = append(b.times, rec.Time)
}
for i := 0; i < len(rec.Values); i++ {
field := b.header[i]
switch val := rec.Values[i].(type) {
case string:
switch field.Options.Type {
case featurebase.FieldTypeInt:
if val == "" {
// copied from the `case nil:` section for ints and decimals
b.values[field.Name] = append(b.values[field.Name], 0)
nullIndices, ok := b.nullIndices[field.Name]
if !ok {
nullIndices = make([]uint64, 0)
}
nullIndices = append(nullIndices, uint64(curPos))
b.nullIndices[field.Name] = nullIndices
} else if intVal, ok := b.getRowTranslation(field.Name, val); ok {
b.values[field.Name] = append(b.values[field.Name], int64(intVal))
} else {
ints, ok := b.toTranslate[i][val]
if !ok {
ints = make([]int, 0)
}
ints = append(ints, curPos)
b.toTranslate[i][val] = ints
b.values[field.Name] = append(b.values[field.Name], 0)
}
case featurebase.FieldTypeBool:
// If we want to support bools as string values, we would do
// that here.
default:
// nil-extend
for len(b.rowIDs[i]) < curPos {
b.rowIDs[i] = append(b.rowIDs[i], nilSentinel)
}
rowIDs := b.rowIDs[i]
// empty string is not a valid value at this point (Pilosa refuses to translate it)
if val == "" { //
b.rowIDs[i] = append(rowIDs, nilSentinel)
} else if rowID, ok := b.getRowTranslation(field.Name, val); ok {
b.rowIDs[i] = append(rowIDs, rowID)
} else {
ints, ok := b.toTranslate[i][val]
if !ok {
ints = make([]int, 0)
}
ints = append(ints, curPos)
b.toTranslate[i][val] = ints
b.rowIDs[i] = append(rowIDs, 0)
}
}
case uint64:
// nil-extend
for len(b.rowIDs[i]) < curPos {
b.rowIDs[i] = append(b.rowIDs[i], nilSentinel)
}
b.rowIDs[i] = append(b.rowIDs[i], val)
case int64:
b.values[field.Name] = append(b.values[field.Name], val)
case []string:
if len(val) == 0 {
continue
}
rowIDSets, ok := b.rowIDSets[field.Name]
if !ok {
rowIDSets = make([][]uint64, len(b.ids)-1, cap(b.ids))
b.rowIDSets[field.Name] = rowIDSets
}
for len(rowIDSets) < len(b.ids)-1 {
rowIDSets = append(rowIDSets, nil) // nil extend
}
rowIDs := make([]uint64, 0, len(val))
for _, k := range val {
if k == "" {
continue
}
if rowID, ok := b.getRowTranslation(field.Name, k); ok {
rowIDs = append(rowIDs, rowID)
} else {
ttsets, ok := b.toTranslateSets[field.Name]
if !ok {
ttsets = make(map[string][]int)
b.toTranslateSets[field.Name] = make(map[string][]int)
}
ints, ok := ttsets[k]
if !ok {
ints = make([]int, 0, 1)
}
ints = append(ints, curPos)
b.toTranslateSets[field.Name][k] = ints
}
}
b.rowIDSets[field.Name] = append(rowIDSets, rowIDs)
case []uint64:
if len(val) == 0 {
continue
}
rowIDSets, ok := b.rowIDSets[field.Name]
if !ok {
rowIDSets = make([][]uint64, len(b.ids)-1, cap(b.ids))
}
for len(rowIDSets) < len(b.ids)-1 {
rowIDSets = append(rowIDSets, nil) // nil extend
}
b.rowIDSets[field.Name] = append(rowIDSets, val)
case nil:
switch field.Options.Type {
case featurebase.FieldTypeInt, featurebase.FieldTypeDecimal, featurebase.FieldTypeTimestamp:
b.values[field.Name] = append(b.values[field.Name], 0)
nullIndices, ok := b.nullIndices[field.Name]
if !ok {
nullIndices = make([]uint64, 0)
}
nullIndices = append(nullIndices, uint64(curPos))
b.nullIndices[field.Name] = nullIndices
case featurebase.FieldTypeBool:
boolNulls, ok := b.boolNulls[field.Name]
if !ok {
boolNulls = make([]uint64, 0)
}
boolNulls = append(boolNulls, uint64(curPos))
b.boolNulls[field.Name] = boolNulls
default:
// only append nil to rowIDs if this field already has
// rowIDs. Otherwise, this could be a []string or
// []uint64 field where we've only seen nil values so
// far. when we see a uint64 or string value, we'll
// "nil-extend" rowIDs to make sure it's the right
// length.
if rowIDs, ok := b.rowIDs[i]; ok {
b.rowIDs[i] = append(rowIDs, nilSentinel)
}
}
case bool:
b.boolValues[field.Name][curPos] = val
case pql.Decimal:
b.values[field.Name] = append(b.values[field.Name], val.ToInt64(field.Options.Scale))
default:
return errors.Errorf("Val %v Type %[1]T is not currently supported. Use string, uint64 (row id), or int64 (integer value)", val)
}
}
for i, uval := range rec.Clears {
field := b.header[i]
if _, ok := b.clearRowIDs[i]; !ok {
b.clearRowIDs[i] = make(map[int]uint64)
}
switch val := uval.(type) {
case string:
clearRows := b.clearRowIDs[i]
// translate val and add to clearRows
if rowID, ok := b.getRowTranslation(field.Name, val); ok {
clearRows[curPos] = rowID
} else {
_, ok := b.toTranslateClear[i]
if !ok {
b.toTranslateClear[i] = make(map[string][]int)
}
ints, ok := b.toTranslateClear[i][val]
if !ok {
ints = make([]int, 0)
}
ints = append(ints, curPos)
b.toTranslateClear[i][val] = ints
}
case uint64:
b.clearRowIDs[i][curPos] = val
case nil:
if field.Options.Type == featurebase.FieldTypeMutex {
for len(b.rowIDs[i]) <= curPos {
b.rowIDs[i] = append(b.rowIDs[i], nilSentinel)
}
b.rowIDs[i][len(b.rowIDs[i])-1] = clearSentinel
}
default:
return errors.Errorf("Clearing a value '%v' Type %[1]T is not currently supported (field '%s')", val, field.Name)
}
// nil extend b.rowIDs so we don't run into a horrible bug
// where we skip doing clears because b.rowIDs doesn't have a
// value for this field
for len(b.rowIDs[i]) <= curPos {
b.rowIDs[i] = append(b.rowIDs[i], nilSentinel)
}
}
if len(b.ids) == cap(b.ids) {
return ErrBatchNowFull
}
if b.maxStaleness != time.Duration(0) { // set maxStaleness to 0 to disable staleness checking
if len(b.ids) == 1 {
b.staleTime = time.Now().Add(b.maxStaleness)
} else if time.Now().After(b.staleTime) {
return ErrBatchNowStale
}
}
return nil
}
// ErrBatchNowFull — similar to io.EOF — is a marker error to notify the user of
// a batch that it is time to call Import.
var ErrBatchNowFull = errors.New("batch is now full - you cannot add any more records (though the one you just added was accepted)")
// ErrBatchAlreadyFull is a real error saying that Batch.Add did not
// complete because the batch was full.
var ErrBatchAlreadyFull = errors.New("batch was already full, record was rejected")
// ErrBatchNowStale indicates that the oldest record in the batch is older than
// the maxStaleness value of the batch. Like ErrBatchNowFull, the error does
// not mean the record was rejected.
var ErrBatchNowStale = errors.New("batch is stale and needs to be imported (however, record was accepted)")
// Import does translation, creates the fragment files, and then,
// if we're not using split batch mode, imports everything to
// Pilosa. It then resets internal data structures for the next
// batch. If we are using split batch mode, it saves the fragment
// data to the batch, resets all other internal structures, and
// continues. split batch mode DOES NOT CURRENTLY SUPPORT MUTEX
// OR INT FIELDS!
func (b *Batch) Import() error {
ctx := context.Background()
start := time.Now()
trns, err := b.importer.StartTransaction(ctx, "", b.prevDuration*10, false, time.Hour)
if err != nil {
return errors.Wrap(err, "starting transaction")
}
defer func() {
if trns != nil {
if trnsl, err := b.importer.FinishTransaction(ctx, trns.ID); err != nil {
b.log.Errorf("error finishing transaction: %v. trns: %+v", err, trnsl)
}
}
b.importer.StatsTiming(MetricBatchImportDurationSeconds, time.Since(start), 1.0)
}()
size := len(b.ids)
transStart := time.Now()
// first we need to translate the toTranslate, then fill out the missing row IDs
err = b.doTranslation()
if err != nil {
return errors.Wrap(err, "doing Translation")
}
transTime := time.Now()
b.log.Printf("translating batch of %d took: %v", size, transTime.Sub(transStart))
frags, clearFrags, err := b.makeFragments(b.frags, b.clearFrags)
if err != nil {
return errors.Wrap(err, "making fragments (flush)")
}
if b.useShardTransactionalEndpoint {
frags, clearFrags, err = b.makeSingleValFragments(frags, clearFrags)
if err != nil {
return errors.Wrap(err, "making single val fragments")
}
}
makeTime := time.Now()
b.log.Printf("making fragments for batch of %d took %v", size, makeTime.Sub(transTime))
if b.splitBatchMode {
b.frags = frags
b.clearFrags = clearFrags
} else {
b.frags = make(fragments)
b.clearFrags = make(fragments)
// create bitmaps out of each field in b.rowIDs and import. Also
// import int data.
if !b.useShardTransactionalEndpoint {
err = b.doImport(frags, clearFrags)
if err != nil {
return errors.Wrap(err, "doing import")
}
b.log.Printf("importing fragments took %v", time.Since(makeTime))
} else {
err = b.doImportShardTransactional(frags, clearFrags)
if err != nil {
return errors.Wrap(err, "doing shard transactional import")
}
}
}
b.reset()
return nil
}
// Flush is only applicable in split batch mode where it actually
// imports the stored data to Pilosa. Otherwise it simply returns
// nil.
func (b *Batch) Flush() error {
ctx := context.Background()
if !b.splitBatchMode {
return nil
}
start := time.Now()
trns, err := b.importer.StartTransaction(ctx, "", b.prevDuration*10, false, time.Hour)
if err != nil {
return errors.Wrap(err, "starting transaction")
}
defer func() {
trnsl, err := b.importer.FinishTransaction(ctx, trns.ID)
if err != nil {
b.log.Errorf("error finishing transaction: %v. trns: %+v", err, trnsl)
}
b.importer.StatsTiming(MetricBatchFlushDurationSeconds, time.Since(start), 1.0)
}()
importStart := time.Now()
err = b.doImport(b.frags, b.clearFrags)
if err != nil {
return errors.Wrap(err, "doing import (ImportFragments)")
}
b.log.Debugf("superbatch import took %v", time.Since(importStart))
b.reset()
b.frags = make(fragments)
b.clearFrags = make(fragments)
return nil
}
func (b *Batch) doTranslation() error {
eg := egpool.Group{PoolSize: 20}
// Translate the column keys.
eg.Go(func() error {
// Deduplicate keys to translate.
dedup := make(map[string]struct{})
var keys []string
for _, key := range b.toTranslateID {
if key == "" {
continue
}
if _, ok := dedup[key]; ok {
continue
}
dedup[key] = struct{}{}
keys = append(keys, key)
}
if len(keys) == 0 {
// There are no column keys to translate.
return nil
}
// Create the keys.
start := time.Now()
trans, err := b.createIndexKeys(b.index, keys...)
if err != nil {
return errors.Wrap(err, "translating col keys")
}
if len(trans) != len(keys) {
return errors.Errorf("requested IDs for %d column keys but got %d back", len(keys), len(trans))
}
b.log.Debugf("translating %d column keys took %v", len(keys), time.Since(start))
// Apply keys to translation cache.
for key, id := range trans {
b.colTranslations[key] = agedTranslation{
id: id,
lastUsed: b.cycle,
}
}
// Translate remaining keys in batch.
for index, ttkey := range b.toTranslateID {
if ttkey == "" {
continue
}
b.ids[index] = trans[ttkey]
}
return nil
})
// creating a lock up here for the rowCache(s) which we get
// below. Usually this isn't needed, but sometimes I think the
// same rowCache gets used repeatedly because the same field is in
// there multiple times, and that can lead to race
// conditions. Need to understand this better, but gonna see if
// this avoids the races.
rowCacheLock := &sync.Mutex{}
// Translate the row keys.
for i, tt := range b.toTranslate {
// Skip this if there are no keys to translate.
ttc := b.toTranslateClear[i]
if len(tt) == 0 && len(ttc) == 0 {
continue
}
// Look up the associated field.
field := b.header[i]
fieldName := field.Name
// Fetch the translation cache.
rowCache := b.rowTranslations[fieldName]
if rowCache == nil {
rowCache = make(map[string]agedTranslation)
b.rowTranslations[fieldName] = rowCache
}
i, tt := i, tt
eg.Go(func() error {
// Collect the keys to translate.
keys := make([]string, 0, len(tt)+len(ttc))
for k := range tt {
keys = append(keys, k)
}
for k := range ttc {
keys = append(keys, k)
}
// Create the keys.
start := time.Now()
trans, err := b.createFieldKeys(field, keys...)
if err != nil {
return errors.Wrap(err, "translating field keys")
}
b.log.Debugf("translating %d field keys for %s took %v", len(trans), fieldName, time.Since(start))
// Apply keys to translation cache.
rowCacheLock.Lock()
for key, id := range trans {
rowCache[key] = agedTranslation{
id: id,
lastUsed: b.cycle,
}
}
rowCacheLock.Unlock()
switch ftype := field.Options.Type; ftype {
case featurebase.FieldTypeSet, featurebase.FieldTypeMutex, featurebase.FieldTypeTime:
// Fill out missing IDs in local batch records with translated IDs.
rows := b.rowIDs[i]
for key, idxs := range tt {
id, ok := trans[key]
if !ok {
return errors.Errorf("key translation missing: %q in field %q", key, fieldName)
}
for _, i := range idxs {
rows[i] = id
}
}
// Fill out missing IDs in clear lists.
clearRows := b.clearRowIDs[i]
for key, idxs := range ttc {
id, ok := trans[key]
if !ok {
return errors.Errorf("key translation missing: %q in field %q", key, fieldName)
}
for _, i := range idxs {
clearRows[i] = id
}
}
case featurebase.FieldTypeInt:
// Handle foreign key int fields — fill out b.values instead of b.rows.
vals := b.values[fieldName]
for key, idxs := range tt {
id, ok := trans[key]
if !ok {
return errors.Errorf("key translation missing: %q in field %q", key, fieldName)
}
for _, i := range idxs {
vals[i] = int64(id)
}
}
default:
return errors.Errorf("unexpected field type for translation: %q", ftype)
}
return nil
})
}
for fieldName, tt := range b.toTranslateSets {
// Skip this if there are no keys to translate.
if len(tt) == 0 {
continue
}
// Look up the associated field.
field := b.headerMap[fieldName]
// Fetch the translation cache.
rowCache := b.rowTranslations[fieldName]
if rowCache == nil {
rowCache = make(map[string]agedTranslation)
b.rowTranslations[fieldName] = rowCache
}
fieldName, tt := fieldName, tt
eg.Go(func() error {
// Collect the keys to translate.
keys := make([]string, 0, len(tt))
for k := range tt {
keys = append(keys, k)
}
// Create the keys.
start := time.Now()
trans, err := b.createFieldKeys(field, keys...)
if err != nil {
return errors.Wrap(err, "translating field keys")
}
b.log.Debugf("translating %d field keys for %s took %v", len(trans), fieldName, time.Since(start))
// Apply keys to translation cache.
rowCacheLock.Lock()
for key, id := range trans {
rowCache[key] = agedTranslation{
id: id,
lastUsed: b.cycle,
}
}
rowCacheLock.Unlock()
// Fill out missing IDs in local batch records with translated IDs.
rowIDSets := b.rowIDSets[fieldName]
for key, idxs := range tt {
id, ok := trans[key]
if !ok {
return errors.Errorf("key translation missing: %q in field %q", key, fieldName)
}
for _, i := range idxs {
rowIDSets[i] = append(rowIDSets[i], id)
}
}
return nil
})
}
return eg.Wait()
}
func (b *Batch) createIndexKeys(index *featurebase.IndexInfo, keys ...string) (map[string]uint64, error) {
ctx := context.Background()
batchSize := b.keyTranslateBatchSize
if batchSize <= 0 || len(keys) <= batchSize {
return b.importer.CreateIndexKeys(ctx, index, keys...)
}
results := make(map[string]uint64, len(keys))
for len(keys) > 0 {
keySlice := keys
if len(keySlice) > batchSize {
keySlice = keySlice[:batchSize]
}
trans, err := b.importer.CreateIndexKeys(ctx, index, keySlice...)
if err != nil {
return nil, err
} else if len(trans) != len(keySlice) {
return nil, errors.Errorf("requested IDs for %d column keys but got %d back", len(keySlice), len(trans))
}
for key, id := range trans {
results[key] = id
}
keys = keys[len(keySlice):]
}
return results, nil
}
func (b *Batch) createFieldKeys(field *featurebase.FieldInfo, keys ...string) (map[string]uint64, error) {
ctx := context.Background()
batchSize := b.keyTranslateBatchSize
if batchSize <= 0 || len(keys) <= batchSize {
return b.importer.CreateFieldKeys(ctx, b.index.Name, field, keys...)
}
results := make(map[string]uint64, len(keys))
for len(keys) > 0 {
keySlice := keys
if len(keySlice) > batchSize {
keySlice = keySlice[:batchSize]
}
trans, err := b.importer.CreateFieldKeys(ctx, b.index.Name, field, keySlice...)
if err != nil {
return nil, err
} else if len(trans) != len(keySlice) {
return nil, errors.Errorf("requested IDs for %d row keys but got %d back", len(keySlice), len(trans))
}
for key, id := range trans {
results[key] = id
}
keys = keys[len(keySlice):]
}
return results, nil
}
func (b *Batch) doImportShardTransactional(frags, clearFrags fragments) error {
ctx := context.Background()
start := time.Now()
requests := make(map[uint64]*featurebase.ImportRoaringShardRequest)
getOrCreate := func(requests map[uint64]*featurebase.ImportRoaringShardRequest, shard uint64) *featurebase.ImportRoaringShardRequest {
request, ok := requests[shard]
if !ok {
request = &featurebase.ImportRoaringShardRequest{
Remote: true, // the client will send to all replicas TODO probably rename before merge
Views: make([]featurebase.RoaringUpdate, 0, 1),
}
requests[shard] = request
}
return request
}
for fragKey, viewMap := range frags {
request := getOrCreate(requests, fragKey.shard)
for view, bitmap := range viewMap {
buf := &bytes.Buffer{}
_, err := bitmap.WriteTo(buf)
if err != nil {
return errors.Wrap(err, "serializing bitmap")
}
request.Views = append(request.Views, featurebase.RoaringUpdate{Field: fragKey.field, View: view, Set: buf.Bytes()})
// handle clear bitmap now if it exists so we don't have to go searching later
if clearVM := clearFrags.GetViewMap(fragKey.shard, fragKey.field); clearVM != nil {
if clearBitmap, ok := clearVM[view]; ok {
clearBuf := &bytes.Buffer{}
_, err := clearBitmap.WriteTo(clearBuf)
if err != nil {
return errors.Wrap(err, "serializing clear bitmap")
}
request.Views[len(request.Views)-1].Clear = clearBuf.Bytes()
// delete from clearFrags so any remaining we know for sure must be added new
clearFrags.DeleteView(fragKey.shard, fragKey.field, view)
}
}
}
}
for fragKey, viewMap := range clearFrags {
request := getOrCreate(requests, fragKey.shard)
for view, bitmap := range viewMap {
buf := &bytes.Buffer{}
_, err := bitmap.WriteTo(buf)
if err != nil {
return errors.Wrap(err, "serializing bitmap")
}
request.Views = append(request.Views, featurebase.RoaringUpdate{Field: fragKey.field, View: view, Clear: buf.Bytes()})
}
}
b.importer.StatsTiming(MetricBatchShardImportBuildRequestsSeconds, time.Since(start), 1.0)
start = time.Now()
eg := egpool.Group{PoolSize: 20}
for shard, request := range requests {
shard := shard
request := request
eg.Go(func() error {
return b.importer.ImportRoaringShard(ctx, b.index.Name, shard, request)
})
}
err := eg.Wait()
dur := time.Since(start)
b.importer.StatsTiming(MetricBatchShardImportDurationSeconds, dur, 1.0)
b.log.Printf("import shard took: %v\n", dur)
return errors.Wrap(err, "doing shard-transactional imports")
}
func (b *Batch) doImport(frags, clearFrags fragments) error {
ctx := context.Background()
start := time.Now()
eg := egpool.Group{PoolSize: 20}
// TODO, currently this relies on upstream behavior of
// makeFragments to guarantee that any shard/field combination in
// clearFrags also has a shard/field in frags. We're only
// iterating over frags and then checking to see if clearFrags has
// the same keys. If we optimized makeFragments to skip adding
// things to frags which had no set bits (e.g. if we were only
// clearing things), then this code would need to be updated to
// ensure that it looked at the things in clearFrags which were
// *not* in frags.
for fragmentKey, viewMap := range frags {
field := fragmentKey.field
shard := fragmentKey.shard
viewMap := viewMap
eg.Go(func() error {
clearViewMap := clearFrags.GetViewMap(shard, field)
if len(clearViewMap) > 0 {
startx := time.Now()
err := b.importer.ImportRoaringBitmap(ctx, b.index.Name, b.indexField(field), shard, clearViewMap, true)
if err != nil {
return errors.Wrapf(err, "import clearing clearing data for %s", field)
}
b.log.Debugf("imp-roar-clr %s,shard:%d,views:%d %v", field, shard, len(clearViewMap), time.Since(startx))
}
starty := time.Now()
err := b.importer.ImportRoaringBitmap(ctx, b.index.Name, b.indexField(field), shard, viewMap, false)
b.log.Debugf("imp-roar %s,shard:%d,views:%d %v", field, shard, len(clearViewMap), time.Since(starty))
return errors.Wrapf(err, "importing data for %s", field)
})
}
eg.Go(func() error { return b.importValueData() })
eg.Go(func() error { return b.importMutexData() })
err := eg.Wait()
if err != nil {
if pferr := anyCause(ErrPreconditionFailed, eg.Errors()...); pferr != nil {
return pferr
}
return err
}
b.prevDuration = time.Since(start)
return nil
}
// indexField is a helper function which was introduced when we switched the
// index and field types from being client types (e.g client.Index,
// client.Field) to being featurebase types (e.g. featurebase.IndexInfo,
// featurebase.FieldInfo). Unlike client.Index, featurebase.IndexInfo is not
// expected to contain the "_exists" field. So calling Field("_exists") on
// IndexInfo results in a nil field. This method creates an instance of
// FieldInfo for the "_exists" field.
func (b *Batch) indexField(field string) *featurebase.FieldInfo {
if field == existenceFieldName {
return &featurebase.FieldInfo{
Name: existenceFieldName,
}
}
return b.index.Field(field)
}
func anyCause(cause error, errs ...error) error {
if cause == nil {
return nil
}
for _, err := range errs {
if errors.Cause(err) == cause {
return err
}
}
return nil
}
func (b *Batch) shardWidth() uint64 {
return featurebase.ShardWidth
}
// this is kind of bad as it means we can never import column id
// ^uint64(0) which is a valid column ID. I think it's unlikely to
// matter much in practice (we could maybe special case it somewhere
// if needed though).
var nilSentinel = ^uint64(0)
// clearSentinel indicates that we're trying to clear all values for
// this field of this record
var clearSentinel = nilSentinel - 1
func (b *Batch) makeFragments(frags, clearFrags fragments) (fragments, fragments, error) {
shardWidth := b.shardWidth()
emptyClearRows := make(map[int]uint64)
// create _exists fragments if needed
if b.index.Options.TrackExistence {
var curBM *roaring.Bitmap
curShard := ^uint64(0) // impossible sentinel value for shard.
for _, col := range b.ids {
if col/shardWidth != curShard {
curShard = col / shardWidth
curBM = frags.GetOrCreate(curShard, "_exists", "")
}
curBM.DirectAdd(col % shardWidth)
}
}
for i, rowIDs := range b.rowIDs {
if len(rowIDs) == 0 {
continue // this can happen when the values that came in for this field were string slices
}
clearRows := b.clearRowIDs[i]
if clearRows == nil {
clearRows = emptyClearRows
}
field := b.header[i]
opts := field.Options
if opts.Type == featurebase.FieldTypeMutex {
continue // we handle mutex fields separately — they can't use importRoaring
}
curShard := ^uint64(0) // impossible sentinel value for shard.
var curBM *roaring.Bitmap
var clearBM *roaring.Bitmap
for j := range b.ids {
col := b.ids[j]
row := nilSentinel
if len(rowIDs) > j {
// this is to protect against what i believe is a bug in the idk.DeleteSentinel logic in handling nil entries
// this will prevent a crash by assuming missing entries are nil entries which i think is ok
// TODO (twg) find where the nil entry was not added on the idk side ~ingest.go batchFromSchema method
row = rowIDs[j]
}
if col/shardWidth != curShard {
curShard = col / shardWidth
curBM = frags.GetOrCreate(curShard, field.Name, "")
clearBM = clearFrags.GetOrCreate(curShard, field.Name, "")
}
if row != nilSentinel {
// TODO this is super ugly, but we want to avoid setting
// bits on the standard view in the specific case when
// there isn't one. Should probably refactor this whole
// loop to be more general w.r.t. views. Also... tests for
// the NoStandardView case would be great.
if !(opts.Type == featurebase.FieldTypeTime && opts.NoStandardView) {
curBM.DirectAdd(row*shardWidth + (col % shardWidth))
}
if opts.Type == featurebase.FieldTypeTime {
views, err := b.times[j].views(opts.TimeQuantum)
if err != nil {
return nil, nil, errors.Wrap(err, "calculating views")
}
for _, view := range views {
tbm := frags.GetOrCreate(curShard, field.Name, view)
tbm.DirectAdd(row*shardWidth + (col % shardWidth))
}
}
}
clearRow, ok := clearRows[j]
if ok {
clearBM.DirectAddN(clearRow*shardWidth + (col % shardWidth))
// we're going to execute the clear before the set, so
// we want to make sure that at this point, the "set"
// fragments don't contain the bit that we're clearing
curBM.DirectRemoveN(clearRow*shardWidth + (col % shardWidth))
}
}
}
for fname, rowIDSets := range b.rowIDSets {
if len(rowIDSets) == 0 {
continue
} else if len(rowIDSets) < len(b.ids) {
// rowIDSets is guaranteed to have capacity == to b.ids,
// but if the last record had a nil for this field, it
// might not have the same length, so we re-slice it to
// ensure the lengths are the same.
rowIDSets = rowIDSets[:len(b.ids)]
}
field := b.headerMap[fname]
opts := field.Options
curShard := ^uint64(0) // impossible sentinel value for shard.
var curBM *roaring.Bitmap
for j := range b.ids {
col, rowIDs := b.ids[j], rowIDSets[j]
if len(rowIDs) == 0 {
continue
}
if col/shardWidth != curShard {
curShard = col / shardWidth
curBM = frags.GetOrCreate(curShard, fname, "")
}
// TODO this is super ugly, but we want to avoid setting
// bits on the standard view in the specific case when
// there isn't one. Should probably refactor this whole
// loop to be more general w.r.t. views. Also... tests for
// the NoStandardView case would be great.
if !(opts.Type == featurebase.FieldTypeTime && opts.NoStandardView) {
for _, row := range rowIDs {
curBM.DirectAdd(row*shardWidth + (col % shardWidth))
}
}
if opts.Type == featurebase.FieldTypeTime {
views, err := b.times[j].views(opts.TimeQuantum)
if err != nil {
return nil, nil, errors.Wrap(err, "calculating views")
}
for _, view := range views {
tbm := frags.GetOrCreate(curShard, fname, view)
for _, row := range rowIDs {
tbm.DirectAdd(row*shardWidth + (col % shardWidth))
}
}
}
}
}
return frags, clearFrags, nil
}
func (b *Batch) makeSingleValFragments(frags, clearFrags fragments) (fragments, fragments, error) {
shardWidth := b.shardWidth()
ids := make([]uint64, len(b.ids))
// -------------------------
// int-like fields
// -------------------------
for fieldName, bvalues := range b.values {
ids = ids[:len(b.ids)]
// trim out null values from ids and values.
nullIndices := b.nullIndices[fieldName]
i, n := uint64(0), 0
for _, nullIndex := range nullIndices {
copy(ids[n:], b.ids[i:nullIndex])
n += copy(bvalues[n:], bvalues[i:nullIndex])
i = nullIndex + 1
}
copy(ids[n:], b.ids[i:])
n += copy(bvalues[n:], bvalues[i:])
ids, bvalues = ids[:n], bvalues[:n]
if len(ids) == 0 {
continue
}
sc := &valsByIDsSortable{ids: ids, vals: bvalues, width: shardWidth}
if !sort.IsSorted(sc) {
sort.Stable(sc)
}
field := b.headerMap[fieldName]
base := field.Options.Base
if field.Options.Type == featurebase.FieldTypeTimestamp {
base = 0
}
shard := ids[0] / shardWidth
bitmap := frags.GetOrCreate(shard, fieldName, "bsig_"+fieldName) // TODO... grab bsig_ prefix from elsewhere
for i, id := range ids {
if i+1 < len(ids) {
// we only want the last value set for each id
if ids[i+1] == id {
continue
}
}
if shard != id/shardWidth {
shard = id / shardWidth
bitmap = frags.GetOrCreate(shard, fieldName, "bsig_"+fieldName)
}
fragmentColumn := id % shardWidth
bitmap.Add(fragmentColumn) // existence bit
svalue := bvalues[i] - base
negative := svalue < 0
var value uint64
if negative {
bitmap.Add(shardWidth + fragmentColumn) // set sign bit
value = uint64(svalue * -1)
} else {
value = uint64(svalue)
}
lz := bits.LeadingZeros64(value)
row := uint64(2)
for mask := uint64(0x1); mask <= 1<<(64-lz) && mask != 0; mask = mask << 1 {
if value&mask > 0 {
bitmap.Add(row*shardWidth + fragmentColumn)
}
row++
}
}
}
// -------------------------
// mutex fields
// -------------------------
for findex, rowIDs := range b.rowIDs {
field := b.header[findex]
if field.Options.Type != featurebase.FieldTypeMutex {
continue
}
ids = ids[:0]
// get slice of column ids for non-nil rowIDs and cut nil row
// IDs out of rowIDs.
idsIndex := 0
for i, id := range b.ids {
rowID := rowIDs[i]
if rowID == nilSentinel {
continue
}
rowIDs[idsIndex] = rowID
ids = append(ids, id)
idsIndex++
}
rowIDs = rowIDs[:idsIndex]
if len(ids) == 0 {
continue
}
sc := &rowsByIDsSortable{ids: ids, rows: rowIDs, width: shardWidth}
if !sort.IsSorted(sc) {
sort.Stable(sc)
}
shard := ids[0] / shardWidth
bitmap := frags.GetOrCreate(shard, field.Name, "standard")
clearBM := clearFrags.GetOrCreate(shard, field.Name, "standard")
for i, id := range ids {
if i+1 < len(ids) {
// we only want the last value set for each id
if ids[i+1] == id {
continue
}
}
row := rowIDs[i]
if shard != id/shardWidth {
shard = id / shardWidth
bitmap = frags.GetOrCreate(shard, field.Name, "standard")
clearBM = clearFrags.GetOrCreate(shard, field.Name, "standard")
}
fragmentColumn := id % shardWidth
clearBM.Add(fragmentColumn) // Will use this to clear columns.
if row != clearSentinel {
// clearSentinel is used for deletion
// so this value should only be added if its not clearSentinel
bitmap.Add(row*shardWidth + fragmentColumn)
}
}
}
// -------------------------
// Boolean fields
// -------------------------
falseRowOffset := 0 * shardWidth // fragment row 0
trueRowOffset := 1 * shardWidth // fragment row 1
// For bools which have been set to null, clear both the true and false
// values for the record. Because this ends up going through the
// API.ImportRoaringShard() method (which handles `bool` fields the same as
// `mutex` fields), we don't actually set the true and false rows of the
// boolean fragment; rather, we just set the first row to indicate which
// records (for all rows) to clear.
for fieldname, boolNulls := range b.boolNulls {
field := b.headerMap[fieldname]
if field.Options.Type != featurebase.FieldTypeBool {
continue
}
for _, pos := range boolNulls {
recID := b.ids[pos]
shard := recID / shardWidth
clearBM := clearFrags.GetOrCreate(shard, field.Name, "standard")
fragmentColumn := recID % shardWidth
clearBM.Add(fragmentColumn)
}
}
// For bools which have been set to a non-nil value, set the appropriate
// value for the record, and unset the opposing values. For example, if the
// bool is set to `false`, then set the bit in the "false" row, and clear
// the bit in the "true" row.
for fieldname, boolMap := range b.boolValues {
field := b.headerMap[fieldname]
if field.Options.Type != featurebase.FieldTypeBool {
continue
}
for pos, boolVal := range boolMap {
recID := b.ids[pos]
shard := recID / shardWidth
bitmap := frags.GetOrCreate(shard, field.Name, "standard")
clearBM := clearFrags.GetOrCreate(shard, field.Name, "standard")
fragmentColumn := recID % shardWidth
clearBM.Add(fragmentColumn)
if boolVal {
bitmap.Add(trueRowOffset + fragmentColumn)
} else {
bitmap.Add(falseRowOffset + fragmentColumn)
}
}
}
return frags, clearFrags, nil
}
type valsByIDsSortable struct {
ids []uint64
vals []int64
// shard width so we can compare by shard instead of ID
width uint64
}
func (v *valsByIDsSortable) Len() int { return len(v.ids) }
func (v *valsByIDsSortable) Less(i, j int) bool { return v.ids[i] < v.ids[j] }
func (v *valsByIDsSortable) Swap(i, j int) {
v.ids[i], v.ids[j] = v.ids[j], v.ids[i]
v.vals[i], v.vals[j] = v.vals[j], v.vals[i]
}
// importValueData imports data for int fields.
func (b *Batch) importValueData() error {
ctx := context.Background()
shardWidth := uint64(featurebase.ShardWidth)
eg := egpool.Group{PoolSize: 20}
ids := make([]uint64, len(b.ids))
for fieldName, bvalues := range b.values {
ids = ids[:len(b.ids)]
// trim out null values from ids and values.
nullIndices := b.nullIndices[fieldName]
i, n := uint64(0), 0
for _, nullIndex := range nullIndices {
copy(ids[n:], b.ids[i:nullIndex])
n += copy(bvalues[n:], bvalues[i:nullIndex])
i = nullIndex + 1
}
copy(ids[n:], b.ids[i:])
n += copy(bvalues[n:], bvalues[i:])
ids, bvalues = ids[:n], bvalues[:n]
// now do imports by shard
if len(ids) == 0 {
continue // TODO test this "all nil" case
}
sc := &valsByIDsSortable{ids: ids, vals: bvalues, width: shardWidth}
if !sort.IsSorted(sc) {
sort.Stable(sc) // TODO(jaffee) this was sort.Sort which I think is a bug. If we get multiple of the same record w/in a batch with different int values, the last one needs to win. We need a test for this.
}
curShard := ids[0] / shardWidth
startIdx := 0
for i := 1; i <= len(ids); i++ {
var recordID uint64
if i < len(ids) {
recordID = ids[i]
} else {
recordID = (curShard + 2) * shardWidth
}
if recordID/shardWidth != curShard {
endIdx := i
shard := curShard
field := b.headerMap[fieldName]
path, data, err := b.importer.EncodeImportValues(ctx, b.index.Name, field, shard, bvalues[startIdx:endIdx], ids[startIdx:endIdx], false)
if err != nil {
return errors.Wrap(err, "encoding import values")
}
eg.Go(func() error {
start := time.Now()
err := b.importer.DoImport(ctx, b.index.Name, field, shard, path, data)
b.log.Debugf("imp-vals %s,shard:%d,data:%d %v", field, shard, len(data), time.Since(start))
return errors.Wrapf(err, "importing values for field = %s", field.Name)
})
startIdx = i
curShard = recordID / shardWidth
}
}
}
err := eg.Wait()
if err != nil {
if pferr := anyCause(ErrPreconditionFailed, eg.Errors()...); pferr != nil {
return pferr
}
return err
}
return errors.Wrap(err, "importing value data")
}
type rowsByIDsSortable struct {
ids []uint64
rows []uint64
// shard width so we can compare by shard instead of ID
width uint64
}
func (v *rowsByIDsSortable) Len() int { return len(v.ids) }
func (v *rowsByIDsSortable) Less(i, j int) bool { return v.ids[i] < v.ids[j] }
func (v *rowsByIDsSortable) Swap(i, j int) {
v.ids[i], v.ids[j] = v.ids[j], v.ids[i]
v.rows[i], v.rows[j] = v.rows[j], v.rows[i]
}
// TODO this should work for bools as well - just need to support them
// at batch creation time and when calling Add, I think.
func (b *Batch) importMutexData() error {
ctx := context.Background()
shardWidth := uint64(featurebase.ShardWidth)
eg := egpool.Group{PoolSize: 20}
ids := make([]uint64, 0, len(b.ids))
for findex, rowIDs := range b.rowIDs {
field := b.header[findex]
if field.Options.Type != featurebase.FieldTypeMutex {
continue
}
ids = ids[:0]
// get slice of column ids for non-nil rowIDs and cut nil row
// IDs out of rowIDs.
idsIndex := 0
for i, id := range b.ids {
rowID := rowIDs[i]
if rowID == nilSentinel {
continue
}
rowIDs[idsIndex] = rowID
ids = append(ids, id)
idsIndex++
}
rowIDs = rowIDs[:idsIndex]
if len(ids) == 0 {
continue
}
sc := &rowsByIDsSortable{ids: ids, rows: rowIDs, width: shardWidth}
if !sort.IsSorted(sc) {
sort.Stable(sc)
}
curShard := ids[0] / shardWidth
startIdx := 0
for i := 1; i <= len(ids); i++ {
var recordID uint64
if i < len(ids) {
recordID = ids[i]
} else {
recordID = (curShard + 2) * shardWidth
}
if recordID/shardWidth != curShard {
endIdx := i
shard := curShard
field := field
path, data, err := b.importer.EncodeImport(ctx, b.index.Name, field, shard, rowIDs[startIdx:endIdx], ids[startIdx:endIdx], false)
if err != nil {
return errors.Wrap(err, "encoding mutex import")
}
eg.Go(func() error {
start := time.Now()
err := b.importer.DoImport(ctx, b.index.Name, field, shard, path, data)
b.log.Debugf("imp-mux %s,shard:%d,data:%d %v", field.Name, shard, len(data), time.Since(start))
return errors.Wrapf(err, "importing values for field = %s", field.Name)
})
startIdx = i
curShard = recordID / shardWidth
}
}
}
err := eg.Wait()
if err != nil {
if pferr := anyCause(ErrPreconditionFailed, eg.Errors()...); pferr != nil {
return pferr
}
return err
}
return errors.Wrap(err, "importing mutex data")
}
// reset is called at the end of importing to ready the batch for the
// next round. Where possible it does not re-allocate memory.
func (b *Batch) reset() {
b.ids = b.ids[:0]
b.times = b.times[:0]
for i, rowIDs := range b.rowIDs {
b.rowIDs[i] = rowIDs[:0]
}
for _, tt := range b.toTranslate {
for k := range tt {
delete(tt, k) // TODO pool these slices
}
}
for _, tts := range b.toTranslateSets {
for k := range tts {
delete(tts, k)
}
}
for field, rowIDSet := range b.rowIDSets {
for i := range rowIDSet {
rowIDSet[i] = nil
}
b.rowIDSets[field] = rowIDSet[:0]
}
for _, rowIDs := range b.clearRowIDs {
for k := range rowIDs {
delete(rowIDs, k)
}
}
for _, clearMap := range b.toTranslateClear {
for k := range clearMap {
delete(clearMap, k)
}
}
for _, boolsMap := range b.boolValues {
for k := range boolsMap {
delete(boolsMap, k)
}
}
for k := range b.boolNulls {
delete(b.boolNulls, k) // TODO pool these slices
}
for i := range b.toTranslateID {
b.toTranslateID[i] = ""
}
for k := range b.values {
delete(b.values, k) // TODO pool these slices
}
for k := range b.nullIndices {
delete(b.nullIndices, k) // TODO pool these slices
}
b.cycle++
for k, trans := range b.colTranslations {
if trans.lastUsed-b.cycle > b.maxAge {
delete(b.colTranslations, k)
}
}
for field, rowTranslations := range b.rowTranslations {
for k, trans := range rowTranslations {
if trans.lastUsed-b.cycle > b.maxAge {
delete(rowTranslations, k)
}
}
if len(rowTranslations) == 0 {
delete(b.rowTranslations, field)
}
}
}
// map[shard][field][view]fragmentData
type fragments map[fragmentKey]map[string]*roaring.Bitmap
type fragmentKey struct {
shard uint64
field string
}
func (f fragments) GetOrCreate(shard uint64, field, view string) *roaring.Bitmap {
key := fragmentKey{shard, field}
viewMap, ok := f[key]
if !ok {
viewMap = make(map[string]*roaring.Bitmap)
f[key] = viewMap
}
bm, ok := viewMap[view]
if !ok {
bm = roaring.NewBTreeBitmap()
viewMap[view] = bm
}
return bm
}
func (f fragments) GetViewMap(shard uint64, field string) map[string]*roaring.Bitmap {
key := fragmentKey{shard, field}
viewMap, ok := f[key]
if !ok {
return nil
}
// Remove any views which have an empty bitmap.
// TODO: Ideally we would prevent allocating the empty bitmap to begin with,
// but the logic is a bit tricky, and since we don't want to spend too much
// time on it right now, we're leaving that for a future exercise.
for k, v := range viewMap {
if v.Count() == 0 {
delete(viewMap, k)
}
}
return viewMap
}
func (f fragments) DeleteView(shard uint64, field, view string) {
vm := f.GetViewMap(shard, field)
if vm == nil {
return
}
delete(vm, view)
}