[
  {
    "path": ".gitignore",
    "content": ".vscode\n.DS_Store\n*.cer\n*.pem\n/bin\n/log*\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: go\n\ngo:\n  - 1.13.x\n  - 1.14.x\n\nservices:\n  - docker\n\nbefore_install:\n  - |\n    docker run --name kafka --rm -d -p 2181:2181 -p 9092:9092 \\\n        -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 \\\n        obsidiandynamics/kafka\n  - |\n    docker run --name postgres --rm -d -p 5432:5432 \\\n        -e POSTGRES_HOST_AUTH_METHOD=trust \\\n        postgres:12\n  - go get -u -v all\n\nscript:\n  - make\n  - make int\n\nafter_success:\n  - bash <(curl -s https://codecov.io/bash)"
  },
  {
    "path": "LICENSE",
    "content": "BSD 3-Clause License\n\nCopyright (c) 2020, Obsidian Dynamics\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n   list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n   this list of conditions and the following disclaimer in the documentation\n   and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n   contributors may be used to endorse or promote products derived from\n   this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "Makefile",
    "content": "default: build test\n\nall: test lint\n\nbuild: dirs\n\tgo build -race -o bin ./...\n\ntest: dirs\n\tgo test ./... -race -count=1 -coverprofile=bin/coverage.out\n\nsoaktest: dirs\n\tSOAK_CMD=\"make test\" sh/soak.sh\n\nint: FORCE\n\tGOLABELS=int go test -timeout 180s -v -race -count=1 ./int\n\nsoakint: FORCE\n\tSOAK_CMD=\"make int\" sh/soak.sh\n\ndirs:\n\tmkdir -p bin\n\nlint:\n\tgolint ./...\n\nclean:\n\trm -rf bin\n\nlist: FORCE\n\t@$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ \"^[#.]\") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$'\n\nFORCE:\n"
  },
  {
    "path": "README.md",
    "content": "<img src=\"https://raw.githubusercontent.com/wiki/obsidiandynamics/goharvest/images/goharvest-logo-wide.png\" width=\"400px\" alt=\"logo\"/>&nbsp;\n===\n![Go version](https://img.shields.io/github/go-mod/go-version/obsidiandynamics/goharvest)\n[![Build](https://travis-ci.org/obsidiandynamics/goharvest.svg?branch=master) ](https://travis-ci.org/obsidiandynamics/goharvest#)\n![Release](https://img.shields.io/github/v/release/obsidiandynamics/goharvest?color=ff69b4)\n[![Codecov](https://codecov.io/gh/obsidiandynamics/goharvest/branch/master/graph/badge.svg)](https://codecov.io/gh/obsidiandynamics/goharvest)\n[![Go Report Card](https://goreportcard.com/badge/github.com/obsidiandynamics/goharvest)](https://goreportcard.com/report/github.com/obsidiandynamics/goharvest)\n[![Total alerts](https://img.shields.io/lgtm/alerts/g/obsidiandynamics/goharvest.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/obsidiandynamics/goharvest/alerts/)\n[![GoDoc Reference](https://img.shields.io/badge/docs-GoDoc-blue.svg)](https://pkg.go.dev/github.com/obsidiandynamics/goharvest?tab=doc)\n\n`goharvest` is a Go implementation of the [Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html) pattern for Postgres and Kafka.\n\n<img src=\"https://raw.githubusercontent.com/wiki/obsidiandynamics/goharvest/images/figure-outbox.png\" width=\"100%\" alt=\"Transactional Outbox\"/>\n\nWhile `goharvest` is a complex beast, the end result is dead simple: to publish Kafka messages reliably and atomically, simply write a record to a dedicated **outbox table** in a transaction, alongside any other database changes. (Outbox schema provided below.) `goharvest` scrapes the outbox table in the background and publishes records to a Kafka topic of the application's choosing, using the key, value and headers specified in the outbox record. `goharvest` currently works with Postgres. It maintains causal order of messages and does not require CDC to be enabled on the database, making for a zero-hassle setup. It handles thousands of records/second on commodity hardware.\n\n# Getting started\n## 1. Create an outbox table for your application\n```sql\nCREATE TABLE IF NOT EXISTS outbox (\n  id                  BIGSERIAL PRIMARY KEY,\n  create_time         TIMESTAMP WITH TIME ZONE NOT NULL,\n  kafka_topic         VARCHAR(249) NOT NULL,\n  kafka_key           VARCHAR(100) NOT NULL,  -- pick your own maximum key size\n  kafka_value         VARCHAR(10000),         -- pick your own maximum value size\n  kafka_header_keys   TEXT[] NOT NULL,\n  kafka_header_values TEXT[] NOT NULL,\n  leader_id           UUID\n)\n```\n\n## 2. Run `goharvest`\n### Standalone mode\nThis runs `goharvest` within a separate process called `reaper`, which will work alongside **any** application that writes to a standard outbox. (Not just applications written in Go.)\n\n#### Install `reaper`\n```sh\ngo get -u github.com/obsidiandynamics/goharvest/cmd/reaper\n```\n\n#### Create `reaper.yaml` configuration\n```yaml\nharvest:\n  baseKafkaConfig: \n    bootstrap.servers: localhost:9092\n  producerKafkaConfig:\n    compression.type: lz4\n    delivery.timeout.ms: 10000\n  leaderTopic: my-app-name\n  leaderGroupID: my-app-name\n  dataSource: host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\n  outboxTable: outbox\n  limits:\n    minPollInterval: 1s\n    heartbeatTimeout: 5s\n    maxInFlightRecords: 1000\n    minMetricsInterval: 5s\n    sendConcurrency: 4\n    sendBuffer: 10\nlogging:\n  level: Debug\n```\n\n#### Start `reaper`\n```sh\nreaper -f reaper.yaml\n```\n\n### Embedded mode\n`goharvest` can be run in the same process as your application.\n\n#### Add the dependency\n```sh\ngo get -u github.com/obsidiandynamics/goharvest\n```\n\n#### Create and start a `Harvest` instance\n```go\nimport \"github.com/obsidiandynamics/goharvest\"\n```\n\n```go\n// Configure the harvester. It will use its own database and Kafka connections under the hood.\nconfig := Config{\n  BaseKafkaConfig: KafkaConfigMap{\n    \"bootstrap.servers\": \"localhost:9092\",\n  },\n  DataSource: \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\",\n}\n\n// Create a new harvester.\nharvest, err := New(config)\nif err != nil {\n  panic(err)\n}\n\n// Start harvesting in the background.\nerr = harvest.Start()\nif err != nil {\n  panic(err)\n}\n\n// Wait indefinitely for the harvester to end.\nlog.Fatal(harvest.Await())\n```\n\n### Using a custom logger\n`goharvest` uses `log.Printf` for output by default. Logger configuration is courtesy of the Scribe façade, from [<code>libstdgo</code>](https://github.com/obsidiandynamics/libstdgo). The example below uses a Logrus binding for Scribe.\n\n```go\nimport (\n  \"github.com/obsidiandynamics/goharvest\"\n  scribelogrus \"github.com/obsidiandynamics/libstdgo/scribe/logrus\"\n  \"github.com/sirupsen/logrus\"\n)\n```\n\n```sh\nlog := logrus.StandardLogger()\nlog.SetLevel(logrus.DebugLevel)\n\n// Configure the custom logger using a binding.\nconfig := Config{\n  BaseKafkaConfig: KafkaConfigMap{\n    \"bootstrap.servers\": \"localhost:9092\",\n  },\n  Scribe:     scribe.New(scribelogrus.Bind()),\n  DataSource: \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\",\n}\n```\n\n### Listening for leader status updates\nJust like `goharvest` uses [NELI](https://github.com/obsidiandynamics/goneli) to piggy-back on Kafka's leader election, you can piggy-back on `goharvest` to get leader status updates:\n\n```go\nlog := logrus.StandardLogger()\nlog.SetLevel(logrus.TraceLevel)\nconfig := Config{\n  BaseKafkaConfig: KafkaConfigMap{\n    \"bootstrap.servers\": \"localhost:9092\",\n  },\n  DataSource: \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\",\n  Scribe:     scribe.New(scribelogrus.Bind()),\n}\n\n// Create a new harvester and register an event hander.\nharvest, err := New(config)\n\n// Register a handler callback, invoked when an event occurs within goharvest.\n// The callback is completely optional; it lets the application piggy-back on leader\n// status updates, in case it needs to schedule some additional work (other than\n// harvesting outbox records) that should only be run on one process at any given time.\nharvest.SetEventHandler(func(e Event) {\n  switch event := e.(type) {\n  case LeaderAcquired:\n    // The application may initialise any state necessary to perform work as a leader.\n    log.Infof(\"Got event: leader acquired: %v\", event.LeaderID())\n  case LeaderRefreshed:\n    // Indicates that a new leader ID was generated, as a result of having to remark\n    // a record (typically as due to an earlier delivery error). This is purely\n    // informational; there is nothing an application should do about this, other\n    // than taking note of the new leader ID if it has come to rely on it.\n    log.Infof(\"Got event: leader refreshed: %v\", event.LeaderID())\n  case LeaderRevoked:\n    // The application may block the callback until it wraps up any in-flight\n    // activity. Only upon returning from the callback, will a new leader be elected.\n    log.Infof(\"Got event: leader revoked\")\n  case LeaderFenced:\n    // The application must immediately terminate any ongoing activity, on the assumption\n    // that another leader may be imminently elected. Unlike the handling of LeaderRevoked,\n    // blocking in the callback will not prevent a new leader from being elected.\n    log.Infof(\"Got event: leader fenced\")\n  case MeterRead:\n    // Periodic statistics regarding the harvester's throughput.\n    log.Infof(\"Got event: meter read: %v\", event.Stats())\n  }\n})\n\n// Start harvesting in the background.\nerr = harvest.Start()\n```\n\n### Which mode should I use\nRunning `goharvest` in standalone mode using `reaper` is the recommended approach for most use cases, as it fully insulates the harvester from the rest of the application. Ideally, you should deploy `reaper` as a sidecar daemon, to run alongside your application. All the reaper needs is access to the outbox table and the Kafka cluster.\n\nEmbedded `goharvest` is useful if you require additional insights into its operation, which is accomplished by registering an `EventHandler` callback, as shown in the example above. This callback is invoked whenever the underlying leader status changes, which may be useful if you need to schedule additional workloads that should only be run on one process at any given time.\n\n## 3. Write outbox records\n### Directly, using SQL\nYou can write database records from any app, by simply issuing the following `INSERT` statement:\n\n```sql\nINSERT INTO ${outbox_table} (\n  create_time, \n  kafka_topic, \n  kafka_key, \n  kafka_value, \n  kafka_header_keys, \n  kafka_header_values\n)\nVALUES (NOW(), $1, $2, $3, $4, $5)\n```\n\nReplace `${outbox_table}` and bind the query variables as appropriate:\n\n* `kafka_topic` column specifies an arbitrary topic name, which may differ among records.\n* `kafka_key` is a mandatory `string` key. Each record must be published with a specified key, which will affect its placement among the topic's partitions.\n* `kafka_value` is an optional `string` value. If unspecified, the record will be published with a `nil` value, allowing it to be used as a compaction tombstone.\n* `kafka_header_keys` and `kafka_header_values` are arrays that specify the keys and values of record headers. When used each element in `kafka_header_keys` corresponds to an element in `kafka_header_values` at the same index. If not using headers, set both arrays to empty.\n\n> **Note**: **Writing outbox records should be performed in the same transaction as other related database updates.** Otherwise, messaging will not be atomic — the updates may be stably persisted while the message might be lost, and *vice versa*.\n\n### Using `stasher`\nThe `goharvest` library comes with a `stasher` helper package for writing records to an outbox.\n\n#### One-off messages\nWhen one database update corresponds to one message, the easiest approach is to call `Stasher.Stash()`:\n\n```go\nimport \"github.com/obsidiandynamics/goharvest\"\n```\n\n```go\ndb, err := sql.Open(\"postgres\", \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\")\nif err != nil {\n  panic(err)\n}\ndefer db.Close()\n\nst := New(\"outbox\")\n\n// Begin a transaction.\ntx, _ := db.Begin()\ndefer tx.Rollback()\n\n// Update other database entities in transaction scope.\n\n// Stash an outbox record for subsequent harvesting.\nerr = st.Stash(tx, goharvest.OutboxRecord{\n  KafkaTopic: \"my-app.topic\",\n  KafkaKey:   \"hello\",\n  KafkaValue: goharvest.String(\"world\"),\n  KafkaHeaders: goharvest.KafkaHeaders{\n    {Key: \"applicationId\", Value: \"my-app\"},\n  },\n})\nif err != nil {\n  panic(err)\n}\n\n// Commit the transaction.\ntx.Commit()\n```\n\n#### Multiple messages\nSending multiple messages within a single transaction may be done more efficiently using prepared statements:\n\n```go\n// Begin a transaction.\ntx, _ := db.Begin()\ndefer tx.Rollback()\n\n// Update other database entities in transaction scope.\n// ...\n\n// Formulates a prepared statement that may be reused within the scope of the transaction.\nprestash, _ := st.Prepare(tx)\n\n// Publish a bunch of messages using the same prepared statement.\nfor i := 0; i < 10; i++ {\n  // Stash an outbox record for subsequent harvesting.\n  err = prestash.Stash(goharvest.OutboxRecord{\n    KafkaTopic: \"my-app.topic\",\n    KafkaKey:   \"hello\",\n    KafkaValue: goharvest.String(\"world\"),\n    KafkaHeaders: goharvest.KafkaHeaders{\n      {Key: \"applicationId\", Value: \"my-app\"},\n    },\n  })\n  if err != nil {\n    panic(err)\n  }\n}\n\n// Commit the transaction.\ntx.Commit()\n```\n\n# Configuration\nThere are handful of parameters that for configuring `goharvest`, assigned via the `Config` struct:\n\n<table>\n  <thead>\n    <tr>\n      <th>Parameter</th>\n      <th>Default value</th>\n      <th>Description</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr valign=\"top\">\n      <td><code>BaseKafkaConfig</code></td>\n      <td>Map containing <code>bootstrap.servers=localhost:9092</code>.</td>\n      <td>Configuration shared by the underlying Kafka producer and consumer clients, including those used for leader election.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>ProducerKafkaConfig</code></td>\n      <td>Empty map.</td>\n      <td>Additional configuration on top of <code>BaseKafkaConfig</code> that is specific to the producer clients created by <code>goharvest</code> for publishing harvested messages. This configuration does not apply to the underlying NELI leader election protocol.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>LeaderGroupID</code></td>\n      <td>Assumes the filename of the application binary.</td>\n      <td>Used by the underlying leader election protocol as a unique identifier shared by all instances in a group of competing processes. The <code>LeaderGroupID</code> is used as Kafka <code>group.id</code> property under the hood, when subscribing to the leader election topic.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>LeaderTopic</code></td>\n      <td>Assumes the value of <code>LeaderGroupID</code>, suffixed with the string <code>.neli</code>.</td>\n      <td>Used by NELI as the name of the Kafka topic for orchestrating leader election. Competing processes subscribe to the same topic under an identical consumer group ID, using Kafka's exclusive partition assignment as a mechanism for arbitrating leader status.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>DataSource</code></td>\n      <td>Local Postgres data source <code>host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable</code>.</td>\n      <td>The database driver-specific data source string.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>OutboxTable</code></td>\n      <td><code>outbox</code></td>\n      <td>The name of the outbox table, optionally including the schema name.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Scribe</code></td>\n      <td>Scribe configured with bindings for <code>log.Printf()</code>; effectively the result of running <code>scribe.New(scribe.StandardBinding())</code>.</td>\n      <td>The logging façade used by the library, preconfigured with your logger of choice. See <a href=\"https://pkg.go.dev/github.com/obsidiandynamics/libstdgo/scribe?tab=doc\">Scribe GoDocs</a>.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Name</code></td>\n      <td>A string in the form <code>{hostname}_{pid}_{time}</code>, where <code>{hostname}</code> is the result of invoking <code>os.Hostname()</code>, <code>{pid}</code> is the process ID, and <code>{time}</code> is the UNIX epoch time, in seconds.</td>\n      <td>The symbolic name of this instance. This field is informational only, accompanying all log messages.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.MinPollInterval</code></td>\n      <td>100 ms</td>\n      <td>The lower bound on the poll interval, preventing the over-polling of Kafka on successive <code>Pulse()</code> invocations. Assuming <code>Pulse()</code> is called repeatedly by the application, NELI may poll Kafka at a longer interval than <code>MinPollInterval</code>. (Regular polling is necessary to prove client's liveness and maintain internal partition assignment, but polling excessively is counterproductive.)</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.HeartbeatTimeout</code></td>\n      <td>5 s</td>\n      <td>The period that a leader will maintain its status, not having received a heartbeat message on the leader topic. After the timeout elapses, the leader will assume a network partition and will voluntarily yield its status, signalling a <code>LeaderFenced</code> event to the application.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.QueueTimeout</code></td>\n      <td>30 s</td>\n      <td>The maximum period of time a record may be queued after having been marked, before timing out and triggering a remark.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.MarkBackoff</code></td>\n      <td>10 ms</td>\n      <td>The backoff delay introduced by the mark thread when a query returns no results, indicating the absence of backlogged records. A mark backoff prevents aggressive querying of the database in the absence of a steady flow of outbox records.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.IOErrorBackoff</code></td>\n      <td>500 ms</td>\n      <td>The backoff delay introduced when any of the mark, purge or reset queries encounter a database error.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.MaxInFlightRecords</code></td>\n      <td>1000</td>\n      <td>An upper bound on the number of marked records that may be in flight at any given time. I.e. the number of records that have been enqueued with a producer client, for which acknowledgements have yet to be received.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.SendConcurrency</code></td>\n      <td>8</td>\n      <td>The number of concurrent shards used for queuing causally unrelated records. Each shard is equipped with a dedicated producer client, allowing for its records to be sent independently of other shards.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.SendBuffer</code></td>\n      <td>10</td>\n      <td>The maximum number of marked records that may be buffered for subsequent sending, for any given shard. When the buffer is full, the marker will halt — waiting for records to be sent and for their acknowledgements to flow through.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.MarkQueryRecords</code></td>\n      <td>100</td>\n      <td>An upper bound on the number of records that may be marked in any given query. Limiting this number avoids long-running database queries.</td>\n    </tr>\n    <tr valign=\"top\">\n      <td><code>Limits.MinMetricsInterval</code></td>\n      <td>5 s</td>\n      <td>The minimum interval at which throughput metrics are emitted. Metrics are emitted conservatively and may be observed less frequently; in fact, throughput metrics are only emitted upon a successful message acknowledgement, which will not occur during periods of inactivity.</td>\n    </tr>\n  </tbody>\n</table>\n\n# Docs\n[Design](https://github.com/obsidiandynamics/goharvest/wiki/Design)\n\n[Comparison of messaging patterns](https://github.com/obsidiandynamics/goharvest/wiki/Comparison-of-messaging-patterns)\n\n[Comparison of harvesting methods](https://github.com/obsidiandynamics/goharvest/wiki/Comparison-of-harvesting-methods)\n\n[FAQ](https://github.com/obsidiandynamics/goharvest/wiki/FAQ)\n\n\n"
  },
  {
    "path": "battery.go",
    "content": "package goharvest\n\nimport (\n\t\"hash/fnv\"\n)\n\ntype cell struct {\n\trecords chan OutboxRecord\n\tdone    chan int\n}\n\nfunc (c cell) stop() {\n\tclose(c.records)\n}\n\nfunc (c cell) await() {\n\t<-c.done\n}\n\nfunc (c cell) enqueue(rec OutboxRecord) bool {\n\tselect {\n\tcase <-c.done:\n\t\treturn false\n\tcase c.records <- rec:\n\t\treturn true\n\t}\n}\n\ntype cellHandler func(records chan OutboxRecord)\n\nfunc newCell(buffer int, handler cellHandler) cell {\n\tc := cell{\n\t\trecords: make(chan OutboxRecord),\n\t\tdone:    make(chan int),\n\t}\n\tgo func() {\n\t\tdefer close(c.done)\n\t\thandler(c.records)\n\t}()\n\treturn c\n}\n\ntype battery interface {\n\tstop()\n\tawait()\n\tshutdown()\n\tenqueue(rec OutboxRecord) bool\n}\n\ntype concurrentBattery []cell\n\nfunc (b *concurrentBattery) stop() {\n\tfor _, c := range *b {\n\t\tc.stop()\n\t}\n}\n\nfunc (b *concurrentBattery) await() {\n\tfor _, c := range *b {\n\t\tc.await()\n\t}\n}\n\nfunc (b *concurrentBattery) shutdown() {\n\tb.stop()\n\tb.await()\n}\n\nfunc (b *concurrentBattery) enqueue(rec OutboxRecord) bool {\n\tif length := len(*b); length > 1 {\n\t\treturn (*b)[hash(rec.KafkaKey)%uint32(length)].enqueue(rec)\n\t}\n\treturn (*b)[0].enqueue(rec)\n}\n\nfunc newConcurrentBattery(concurrency int, buffer int, handler cellHandler) *concurrentBattery {\n\tb := make(concurrentBattery, concurrency)\n\tfor i := 0; i < concurrency; i++ {\n\t\tb[i] = newCell(buffer, handler)\n\t}\n\treturn &b\n}\n\nfunc hash(str string) uint32 {\n\talgorithm := fnv.New32a()\n\talgorithm.Write([]byte(str))\n\treturn algorithm.Sum32()\n}\n"
  },
  {
    "path": "battery_test.go",
    "content": "package goharvest\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestEnqueue_concurrencyOf1(t *testing.T) {\n\tenqueued := make(chan OutboxRecord)\n\tb := newConcurrentBattery(1, 0, func(records chan OutboxRecord) {\n\t\tfor rec := range records {\n\t\t\tenqueued <- rec\n\t\t}\n\t})\n\tdefer b.shutdown()\n\n\trec := OutboxRecord{}\n\tassert.True(t, b.enqueue(rec))\n\tassert.Equal(t, rec, <-enqueued)\n}\n\nfunc TestEnqueue_concurrencyOf2(t *testing.T) {\n\tenqueued := make(chan OutboxRecord)\n\tb := newConcurrentBattery(2, 0, func(records chan OutboxRecord) {\n\t\tfor rec := range records {\n\t\t\tenqueued <- rec\n\t\t}\n\t})\n\tdefer b.shutdown()\n\n\trec := OutboxRecord{}\n\tassert.True(t, b.enqueue(rec))\n\tassert.Equal(t, rec, <-enqueued)\n}\n\nfunc TestEnqueue_afterDone(t *testing.T) {\n\tb := newConcurrentBattery(2, 0, func(records chan OutboxRecord) {})\n\tb.await()\n\n\tassert.False(t, b.enqueue(OutboxRecord{}))\n\tb.stop()\n}\n"
  },
  {
    "path": "cmd/goharvest_example/example_main.go",
    "content": "package main\n\nimport (\n\t\"database/sql\"\n\n\t\"github.com/obsidiandynamics/goharvest\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\tscribelogrus \"github.com/obsidiandynamics/libstdgo/scribe/logrus\"\n\t\"github.com/sirupsen/logrus\"\n)\n\nfunc main() {\n\tconst dataSource = \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\"\n\n\t// Optional: Ensure the database table exists before we start harvesting.\n\tfunc() {\n\t\tdb, err := sql.Open(\"postgres\", dataSource)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tdefer db.Close()\n\n\t\t_, err = db.Exec(`\n\t\t\tCREATE TABLE IF NOT EXISTS outbox (\n\t\t\t\tid                  BIGSERIAL PRIMARY KEY,\n\t\t\t\tcreate_time         TIMESTAMP WITH TIME ZONE NOT NULL,\n\t\t\t\tkafka_topic         VARCHAR(249) NOT NULL,\n\t\t\t\tkafka_key           VARCHAR(100) NOT NULL,  -- pick your own key size\n\t\t\t\tkafka_value         VARCHAR(10000),         -- pick your own value size\n\t\t\t\tkafka_header_keys   TEXT[] NOT NULL,\n\t\t\t\tkafka_header_values TEXT[] NOT NULL,\n\t\t\t\tleader_id           UUID\n\t\t\t)\n\t\t`)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}()\n\n\t// Configure the harvester. It will use its own database connections under the hood.\n\tlog := logrus.StandardLogger()\n\tlog.SetLevel(logrus.DebugLevel)\n\tconfig := goharvest.Config{\n\t\tBaseKafkaConfig: goharvest.KafkaConfigMap{\n\t\t\t\"bootstrap.servers\": \"localhost:9092\",\n\t\t},\n\t\tDataSource: dataSource,\n\t\tScribe:     scribe.New(scribelogrus.Bind()),\n\t}\n\n\t// Create a new harvester.\n\tharvest, err := goharvest.New(config)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Start it.\n\terr = harvest.Start()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Wait indefinitely for it to end.\n\tlog.Fatal(harvest.Await())\n}\n"
  },
  {
    "path": "cmd/pump/pump_main.go",
    "content": "package main\n\nimport (\n\t\"database/sql\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"math/rand\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/obsidiandynamics/goharvest\"\n\t\"github.com/obsidiandynamics/goharvest/metric\"\n\t\"github.com/obsidiandynamics/goharvest/stasher\"\n)\n\nconst recordsPerTxn = 20\n\nfunc main() {\n\tvar keys, records, interval int\n\tvar dataSource, outboxTable, kafkaTopic string\n\tvar blank bool\n\tflag.IntVar(&keys, \"keys\", -1, \"Number of unique keys\")\n\tflag.IntVar(&records, \"records\", -1, \"Number of records to generate\")\n\tflag.IntVar(&interval, \"interval\", 0, \"Write interval (in milliseconds\")\n\tflag.StringVar(&dataSource, \"ds\", \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\", \"Data source\")\n\tflag.StringVar(&outboxTable, \"outbox\", \"outbox\", \"Outbox table name\")\n\tflag.StringVar(&kafkaTopic, \"topic\", \"pump\", \"Kafka output topic name\")\n\tflag.BoolVar(&blank, \"blank\", false, \"Generate blank records (nil value)\")\n\tflag.Parse()\n\n\terrorFunc := func(field string) {\n\t\tflag.PrintDefaults()\n\t\tpanic(fmt.Errorf(\"required '-%s' has not been set\", field))\n\t}\n\tif keys == -1 {\n\t\terrorFunc(\"keys\")\n\t}\n\tif records == -1 {\n\t\terrorFunc(\"records\")\n\t}\n\n\tfmt.Printf(\"Starting stasher; keys: %d, records: %d, interval: %d ms\\n\", keys, records, interval)\n\tfmt.Printf(\"  Data source: %s\\n\", dataSource)\n\tfmt.Printf(\"  Outbox table name: %s\\n\", outboxTable)\n\n\tdb, err := sql.Open(\"postgres\", dataSource)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer db.Close()\n\n\tst := stasher.New(outboxTable)\n\n\tmeter := metric.NewMeter(\"pump\", 5*time.Second)\n\n\tvar tx *sql.Tx\n\tvar pre stasher.PreStash\n\tfor i := 0; i < records; i++ {\n\t\tif i%recordsPerTxn == 0 {\n\t\t\tfinaliseTx(tx)\n\n\t\t\ttx, err = db.Begin()\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tpre, err = st.Prepare(tx)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t}\n\n\t\trand := rand.Uint64()\n\t\tvar value *string\n\t\tif !blank {\n\t\t\tvalue = goharvest.String(fmt.Sprintf(\"value-%x\", rand))\n\t\t}\n\n\t\trec := goharvest.OutboxRecord{\n\t\t\tKafkaTopic: kafkaTopic,\n\t\t\tKafkaKey:   fmt.Sprintf(\"key-%x\", rand%uint64(keys)),\n\t\t\tKafkaValue: value,\n\t\t\tKafkaHeaders: goharvest.KafkaHeaders{\n\t\t\t\tgoharvest.KafkaHeader{Key: \"Seq\", Value: strconv.Itoa(i)},\n\t\t\t},\n\t\t}\n\t\terr := pre.Stash(rec)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\ttime.Sleep(time.Duration(interval * int(time.Millisecond)))\n\t\tmeter.Add(1)\n\t\tmeter.MaybeStatsLog(log.Printf)\n\t}\n\tfinaliseTx(tx)\n}\n\nfunc finaliseTx(tx *sql.Tx) {\n\tif tx != nil {\n\t\terr := tx.Commit()\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "cmd/reaper/reaper_main.go",
    "content": "package main\n\nimport (\n\t\"flag\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"os\"\n\n\t\"github.com/obsidiandynamics/goharvest\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\t\"gopkg.in/yaml.v2\"\n\n\tscribelogrus \"github.com/obsidiandynamics/libstdgo/scribe/logrus\"\n\tlogrus \"github.com/sirupsen/logrus\"\n)\n\nfunc panicOnError(scr scribe.Scribe, err error) {\n\tif err != nil {\n\t\tscr.E()(\"Error: %v\", err.Error())\n\t\tpanic(err)\n\t}\n}\n\nfunc main() {\n\tvar configFile string\n\tflag.StringVar(&configFile, \"f\", \"\", \"Configuration file (shorthand)\")\n\tflag.StringVar(&configFile, \"file\", \"\", \"Configuration file\")\n\tflag.Parse()\n\n\terrorFunc := func(field string) {\n\t\tflag.PrintDefaults()\n\t\tpanic(fmt.Errorf(\"required '-%s' has not been set\", field))\n\t}\n\tif configFile == \"\" {\n\t\terrorFunc(\"f\")\n\t}\n\n\tlr := logrus.StandardLogger()\n\tlr.SetLevel(logrus.TraceLevel)\n\tscr := scribe.New(scribelogrus.Bind())\n\n\tworkDir, err := os.Getwd()\n\tpanicOnError(scr, err)\n\tscr.I()(\"Starting GoHarvest Reaper\")\n\texecutable, err := os.Executable()\n\tpanicOnError(scr, err)\n\tscr.I()(\"Executable: %s; working directory: %s\", executable, workDir)\n\n\tcfgData, err := ioutil.ReadFile(configFile)\n\tpanicOnError(scr, err)\n\tcfg, err := unmarshal(cfgData)\n\tpanicOnError(scr, err)\n\n\tcfg.Harvest.Scribe = scr\n\tlevel, err := scribe.ParseLevelName(cfg.Logging.Level)\n\tpanicOnError(scr, err)\n\tscr.SetEnabled(level.Level)\n\n\th, err := goharvest.New(cfg.Harvest)\n\tpanicOnError(scr, err)\n\n\tpanicOnError(scr, h.Start())\n\tpanicOnError(scr, h.Await())\n}\n\ntype LoggingConfig struct {\n\tLevel string `yaml:\"level\"`\n}\n\nfunc (l *LoggingConfig) setDefaults() {\n\tif l.Level == \"\" {\n\t\tl.Level = scribe.Levels[scribe.Debug].Name\n\t}\n}\n\ntype ReaperConfig struct {\n\tHarvest goharvest.Config `yaml:\"harvest\"`\n\tLogging LoggingConfig    `yaml:\"logging\"`\n}\n\nfunc (r *ReaperConfig) setDefaults() {\n\tr.Harvest.SetDefaults()\n\tr.Logging.setDefaults()\n}\n\nfunc unmarshal(in []byte) (ReaperConfig, error) {\n\tcfg := ReaperConfig{}\n\terr := yaml.UnmarshalStrict(in, &cfg)\n\tif err == nil {\n\t\tcfg.setDefaults()\n\t}\n\treturn cfg, err\n}\n"
  },
  {
    "path": "config.go",
    "content": "package goharvest\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"time\"\n\n\tvalidation \"github.com/go-ozzo/ozzo-validation\"\n\t\"github.com/obsidiandynamics/goneli\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\t\"gopkg.in/yaml.v2\"\n)\n\n// Duration is a convenience for deriving a pointer from a given Duration argument.\nfunc Duration(d time.Duration) *time.Duration {\n\treturn &d\n}\n\n// Int is a convenience for deriving a pointer from a given int argument.\nfunc Int(i int) *int {\n\treturn &i\n}\n\n// Limits configuration.\ntype Limits struct {\n\tIOErrorBackoff     *time.Duration `yaml:\"ioErrorBackoff\"`\n\tPollDuration       *time.Duration `yaml:\"pollDuration\"`\n\tMinPollInterval    *time.Duration `yaml:\"minPollInterval\"`\n\tMaxPollInterval    *time.Duration `yaml:\"maxPollInterval\"`\n\tHeartbeatTimeout   *time.Duration `yaml:\"heartbeatTimeout\"`\n\tDrainInterval      *time.Duration `yaml:\"drainInterval\"`\n\tQueueTimeout       *time.Duration `yaml:\"queueTimeout\"`\n\tMarkBackoff        *time.Duration `yaml:\"markBackoff\"`\n\tMaxInFlightRecords *int           `yaml:\"maxInFlightRecords\"`\n\tSendConcurrency    *int           `yaml:\"sendConcurrency\"`\n\tSendBuffer         *int           `yaml:\"sendBuffer\"`\n\tMarkQueryRecords   *int           `yaml:\"markQueryRecords\"`\n\tMinMetricsInterval *time.Duration `yaml:\"minMetricsInterval\"`\n}\n\nfunc defaultInt(i **int, def int) {\n\tif *i == nil {\n\t\t*i = &def\n\t}\n}\n\nfunc defaultDuration(d **time.Duration, def time.Duration) {\n\tif *d == nil {\n\t\t*d = &def\n\t}\n}\n\n// SetDefaults assigns the defaults for optional values.\nfunc (l *Limits) SetDefaults() {\n\tdefaultDuration(&l.IOErrorBackoff, 500*time.Millisecond)\n\tdefaultDuration(&l.HeartbeatTimeout, goneli.DefaultHeartbeatTimeout)\n\tdefaultDuration(&l.MaxPollInterval, *l.HeartbeatTimeout/2)\n\tdefaultDuration(&l.QueueTimeout, 30*time.Second)\n\tdefaultDuration(&l.DrainInterval, minDuration(*l.MaxPollInterval, *l.QueueTimeout))\n\tdefaultDuration(&l.MarkBackoff, 10*time.Millisecond)\n\tdefaultInt(&l.MaxInFlightRecords, 1000)\n\tdefaultInt(&l.SendConcurrency, 8)\n\tdefaultInt(&l.SendBuffer, 10)\n\tdefaultInt(&l.MarkQueryRecords, 100)\n\tdefaultDuration(&l.MinMetricsInterval, 5*time.Second)\n}\n\nfunc minDuration(d0, d1 time.Duration) time.Duration {\n\tif d0 < d1 {\n\t\treturn d0\n\t}\n\treturn d1\n}\n\n// Validate the Limits configuration, returning an error if invalid\nfunc (l Limits) Validate() error {\n\tminimumMaxPollInterval := 1 * time.Millisecond\n\tif l.MinPollInterval != nil {\n\t\tminimumMaxPollInterval = *l.MinPollInterval\n\t}\n\treturn validation.ValidateStruct(&l,\n\t\tvalidation.Field(&l.IOErrorBackoff, validation.Min(0)),\n\t\tvalidation.Field(&l.DrainInterval, validation.Required, validation.Min(1*time.Millisecond)),\n\t\tvalidation.Field(&l.MaxPollInterval, validation.Required, validation.Min(minimumMaxPollInterval)),\n\t\tvalidation.Field(&l.QueueTimeout, validation.Required, validation.Min(1*time.Millisecond)),\n\t\tvalidation.Field(&l.MarkBackoff, validation.Min(0)),\n\t\tvalidation.Field(&l.MaxInFlightRecords, validation.Required, validation.Min(1)),\n\t\tvalidation.Field(&l.SendConcurrency, validation.Required, validation.Min(1)),\n\t\tvalidation.Field(&l.SendBuffer, validation.Min(0)),\n\t\tvalidation.Field(&l.MarkQueryRecords, validation.Required, validation.Min(1)),\n\t\tvalidation.Field(&l.MinMetricsInterval, validation.Min(0)),\n\t)\n}\n\n// String obtains a textural representation of Limits.\nfunc (l Limits) String() string {\n\treturn fmt.Sprint(\n\t\t\"Limits[IOErrorBackoff=\", l.IOErrorBackoff,\n\t\t\", PollDuration=\", l.PollDuration,\n\t\t\", MinPollInterval=\", l.MinPollInterval,\n\t\t\", MaxPollInterval=\", l.MaxPollInterval,\n\t\t\", HeartbeatTimeout=\", l.HeartbeatTimeout,\n\t\t\", DrainInterval=\", l.DrainInterval,\n\t\t\", QueueTimeout=\", l.QueueTimeout,\n\t\t\", MarkBackoff=\", l.MarkBackoff,\n\t\t\", MaxInFlightRecords=\", l.MaxInFlightRecords,\n\t\t\", SendConcurrency=\", l.SendConcurrency,\n\t\t\", SendBuffer=\", l.SendBuffer,\n\t\t\", MarkQueryRecords=\", l.MarkQueryRecords,\n\t\t\", MinMetricsInterval=\", l.MinMetricsInterval, \"]\",\n\t)\n}\n\n// KafkaConfigMap represents the Kafka key-value configuration.\ntype KafkaConfigMap map[string]interface{}\n\n// Config encapsulates configuration for Harvest.\ntype Config struct {\n\tBaseKafkaConfig         KafkaConfigMap `yaml:\"baseKafkaConfig\"`\n\tProducerKafkaConfig     KafkaConfigMap `yaml:\"producerKafkaConfig\"`\n\tLeaderTopic             string         `yaml:\"leaderTopic\"`\n\tLeaderGroupID           string         `yaml:\"leaderGroupID\"`\n\tDataSource              string         `yaml:\"dataSource\"`\n\tOutboxTable             string         `yaml:\"outboxTable\"`\n\tLimits                  Limits         `yaml:\"limits\"`\n\tKafkaConsumerProvider   KafkaConsumerProvider\n\tKafkaProducerProvider   KafkaProducerProvider\n\tDatabaseBindingProvider DatabaseBindingProvider\n\tNeliProvider            NeliProvider\n\tScribe                  scribe.Scribe\n\tName                    string `yaml:\"name\"`\n}\n\n// Validate the Config, returning an error if invalid.\nfunc (c Config) Validate() error {\n\treturn validation.ValidateStruct(&c,\n\t\tvalidation.Field(&c.BaseKafkaConfig, validation.NotNil),\n\t\tvalidation.Field(&c.ProducerKafkaConfig, validation.NotNil),\n\t\tvalidation.Field(&c.DataSource, validation.Required),\n\t\tvalidation.Field(&c.OutboxTable, validation.Required),\n\t\tvalidation.Field(&c.Limits),\n\t\tvalidation.Field(&c.KafkaConsumerProvider, validation.NotNil),\n\t\tvalidation.Field(&c.KafkaProducerProvider, validation.NotNil),\n\t\tvalidation.Field(&c.DatabaseBindingProvider, validation.NotNil),\n\t\tvalidation.Field(&c.NeliProvider, validation.NotNil),\n\t\tvalidation.Field(&c.Scribe, validation.NotNil),\n\t\tvalidation.Field(&c.Name, validation.Required),\n\t)\n}\n\n// Obtains a textual representation of the configuration.\nfunc (c Config) String() string {\n\treturn fmt.Sprint(\n\t\t\"Config[BaseKafkaConfig=\", c.BaseKafkaConfig,\n\t\t\", ProducerKafkaConfig=\", c.ProducerKafkaConfig,\n\t\t\", LeaderTopic=\", c.LeaderTopic,\n\t\t\", LeaderGroupID=\", c.LeaderGroupID,\n\t\t\", DataSource=\", c.DataSource,\n\t\t\", OutboxTable=\", c.OutboxTable,\n\t\t\", Limits=\", c.Limits,\n\t\t\", KafkaConsumerProvider=\", c.KafkaConsumerProvider,\n\t\t\", KafkaProducerProvider=\", c.KafkaProducerProvider,\n\t\t\", DatabaseBindingProvider=\", c.DatabaseBindingProvider,\n\t\t\", NeliProvider=\", c.NeliProvider,\n\t\t\", Scribe=\", c.Scribe,\n\t\t\", Name=\", c.Name, \"]\")\n}\n\n// SetDefaults assigns the default values to optional fields.\nfunc (c *Config) SetDefaults() {\n\tif c.BaseKafkaConfig == nil {\n\t\tc.BaseKafkaConfig = KafkaConfigMap{}\n\t}\n\tif _, ok := c.BaseKafkaConfig[\"bootstrap.servers\"]; !ok {\n\t\tc.BaseKafkaConfig[\"bootstrap.servers\"] = \"localhost:9092\"\n\t}\n\tif c.ProducerKafkaConfig == nil {\n\t\tc.ProducerKafkaConfig = KafkaConfigMap{}\n\t}\n\tif c.DataSource == \"\" {\n\t\tc.DataSource = \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\"\n\t}\n\tif c.OutboxTable == \"\" {\n\t\tc.OutboxTable = \"outbox\"\n\t}\n\tc.Limits.SetDefaults()\n\tif c.KafkaConsumerProvider == nil {\n\t\tc.KafkaConsumerProvider = StandardKafkaConsumerProvider()\n\t}\n\tif c.KafkaProducerProvider == nil {\n\t\tc.KafkaProducerProvider = StandardKafkaProducerProvider()\n\t}\n\tif c.DatabaseBindingProvider == nil {\n\t\tc.DatabaseBindingProvider = StandardPostgresBindingProvider()\n\t}\n\tif c.NeliProvider == nil {\n\t\tc.NeliProvider = StandardNeliProvider()\n\t}\n\tif c.Scribe == nil {\n\t\tc.Scribe = scribe.New(scribe.StandardBinding())\n\t}\n\tif c.Name == \"\" {\n\t\tc.Name = fmt.Sprintf(\"%s_%d_%d\", goneli.Sanitise(getString(\"localhost\", os.Hostname)), os.Getpid(), time.Now().Unix())\n\t}\n}\n\n// Unmarshal a configuration from a byte slice, returning the configuration struct with pre-initialised defaults,\n// or an error if unmarshalling failed. The configuration is not validated prior to returning, in case further\n// amendments are required by the caller. The caller should call Validate() independently.\nfunc Unmarshal(in []byte) (Config, error) {\n\tcfg := Config{}\n\terr := yaml.UnmarshalStrict(in, &cfg)\n\tif err == nil {\n\t\tcfg.SetDefaults()\n\t}\n\treturn cfg, err\n}\n\ntype stringGetter func() (string, error)\n\nfunc getString(def string, stringGetter stringGetter) string {\n\tstr, err := stringGetter()\n\tif err != nil {\n\t\treturn def\n\t}\n\treturn str\n}\n"
  },
  {
    "path": "config_test.go",
    "content": "package goharvest\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/go-cmp/cmp\"\n\t\"github.com/google/go-cmp/cmp/cmpopts\"\n\t\"github.com/obsidiandynamics/goneli\"\n\t\"github.com/obsidiandynamics/libstdgo/check\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"gopkg.in/yaml.v2\"\n)\n\nfunc TestDefaultKafkaConsumerProvider(t *testing.T) {\n\tc := Config{}\n\tc.SetDefaults()\n\n\tcons, err := c.KafkaConsumerProvider(&KafkaConfigMap{})\n\tassert.Nil(t, cons)\n\tif assert.NotNil(t, err) {\n\t\tassert.Contains(t, err.Error(), \"Required property\")\n\t}\n}\n\nfunc TestDefaultKafkaProducerProvider(t *testing.T) {\n\tc := Config{}\n\tc.SetDefaults()\n\n\tprod, err := c.KafkaProducerProvider(&KafkaConfigMap{\"foo\": \"bar\"})\n\tassert.Nil(t, prod)\n\tif assert.NotNil(t, err) {\n\t\tassert.Contains(t, err.Error(), \"No such configuration property\")\n\t}\n}\n\nfunc TestDefaultNeliProvider(t *testing.T) {\n\tc := Config{}\n\tc.SetDefaults()\n\n\tconsMock := &consMock{}\n\tconsMock.fillDefaults()\n\tprodMock := &prodMock{}\n\tprodMock.fillDefaults()\n\tneli, err := c.NeliProvider(goneli.Config{\n\t\tKafkaConsumerProvider: convertKafkaConsumerProvider(mockKafkaConsumerProvider(consMock)),\n\t\tKafkaProducerProvider: convertKafkaProducerProvider(mockKafkaProducerProvider(prodMock)),\n\t}, goneli.NopBarrier())\n\tassert.NotNil(t, neli)\n\tassert.Nil(t, err)\n\tassert.Nil(t, neli.Close())\n}\n\nfunc TestLimitsString(t *testing.T) {\n\tlim := Limits{}\n\tlim.SetDefaults()\n\tassert.Contains(t, lim.String(), \"Limits[\")\n}\n\nfunc TestLimitsFromYaml(t *testing.T) {\n\tconst y = `\nioErrorBackoff: 10ms\npollDuration: 20ms\nminPollInterval: 30ms\n`\n\tlim := Limits{}\n\terr := yaml.UnmarshalStrict([]byte(y), &lim)\n\tassert.Nil(t, err)\n\tassert.Equal(t, 10*time.Millisecond, *lim.IOErrorBackoff)\n\tassert.Equal(t, 20*time.Millisecond, *lim.PollDuration)\n\tassert.Equal(t, 30*time.Millisecond, *lim.MinPollInterval)\n\n\tlim.SetDefaults()\n\n\t// Check that the defaults weren't overridden.\n\tdef := Limits{}\n\tdef.SetDefaults()\n\tassert.Equal(t, *def.MarkBackoff, *lim.MarkBackoff)\n}\n\nfunc TestGetString(t *testing.T) {\n\tassert.Equal(t, \"some-default\", getString(\"some-default\", func() (string, error) { return \"\", check.ErrSimulated }))\n\tassert.Equal(t, \"some-string\", getString(\"some-default\", func() (string, error) { return \"some-string\", nil }))\n}\n\nfunc TestValidateLimits(t *testing.T) {\n\tlim := Limits{}\n\tlim.SetDefaults()\n\tassert.Nil(t, lim.Validate())\n\n\tlim = Limits{\n\t\tIOErrorBackoff: Duration(-1),\n\t\tPollDuration:   Duration(time.Millisecond),\n\t}\n\tlim.SetDefaults()\n\tif err := lim.Validate(); assert.NotNil(t, err) {\n\t\tassert.Equal(t, \"IOErrorBackoff: must be no less than 0.\", lim.Validate().Error())\n\t}\n\n\tlim = Limits{\n\t\tDrainInterval: Duration(0),\n\t}\n\tlim.SetDefaults()\n\tif err := lim.Validate(); assert.NotNil(t, err) {\n\t\tassert.Equal(t, \"DrainInterval: cannot be blank.\", lim.Validate().Error())\n\t}\n\n\tlim = Limits{\n\t\tDrainInterval: Duration(1 * time.Nanosecond),\n\t}\n\tlim.SetDefaults()\n\tif err := lim.Validate(); assert.NotNil(t, err) {\n\t\tassert.Equal(t, \"DrainInterval: must be no less than 1ms.\", lim.Validate().Error())\n\t}\n}\n\nfunc TestConfigString(t *testing.T) {\n\tcfg := Config{}\n\tcfg.SetDefaults()\n\tassert.Contains(t, cfg.String(), \"Config[\")\n}\n\nfunc TestValidateConfig_valid(t *testing.T) {\n\tcfg := Config{\n\t\tBaseKafkaConfig:         KafkaConfigMap{},\n\t\tProducerKafkaConfig:     KafkaConfigMap{},\n\t\tLeaderTopic:             \"leader-topic\",\n\t\tLeaderGroupID:           \"leader-group-d\",\n\t\tDataSource:              \"data-source\",\n\t\tOutboxTable:             \"outbox-table\",\n\t\tKafkaConsumerProvider:   StandardKafkaConsumerProvider(),\n\t\tKafkaProducerProvider:   StandardKafkaProducerProvider(),\n\t\tDatabaseBindingProvider: StandardPostgresBindingProvider(),\n\t\tScribe:                  scribe.New(scribe.StandardBinding()),\n\t\tName:                    \"name\",\n\t}\n\tcfg.SetDefaults()\n\tassert.Nil(t, cfg.Validate())\n}\n\nfunc TestValidateConfig_invalidLimits(t *testing.T) {\n\tcfg := Config{\n\t\tBaseKafkaConfig:     KafkaConfigMap{},\n\t\tProducerKafkaConfig: KafkaConfigMap{},\n\t\tLeaderTopic:         \"leader-topic\",\n\t\tLeaderGroupID:       \"leader-group-id\",\n\t\tDataSource:          \"data-source\",\n\t\tOutboxTable:         \"outbox-table\",\n\t\tLimits: Limits{\n\t\t\tSendConcurrency: Int(-1),\n\t\t},\n\t\tKafkaConsumerProvider:   StandardKafkaConsumerProvider(),\n\t\tKafkaProducerProvider:   StandardKafkaProducerProvider(),\n\t\tDatabaseBindingProvider: StandardPostgresBindingProvider(),\n\t\tScribe:                  scribe.New(scribe.StandardBinding()),\n\t\tName:                    \"name\",\n\t}\n\tcfg.SetDefaults()\n\tassert.NotNil(t, cfg.Validate())\n}\n\nfunc TestValidateConfig_default(t *testing.T) {\n\tcfg := Config{}\n\tcfg.SetDefaults()\n\tassert.Nil(t, cfg.Validate())\n}\n\nfunc TestDefaultDrainTimeout(t *testing.T) {\n\tcfg := Config{\n\t\tLimits: Limits{\n\t\t\tHeartbeatTimeout: Duration(40 * time.Second),\n\t\t},\n\t}\n\tcfg.SetDefaults()\n\tassert.Equal(t, 20*time.Second, *cfg.Limits.MaxPollInterval)\n\tassert.Equal(t, 20*time.Second, *cfg.Limits.DrainInterval)\n\n\tcfg = Config{\n\t\tLimits: Limits{\n\t\t\tHeartbeatTimeout: Duration(40 * time.Second),\n\t\t\tQueueTimeout:     Duration(15 * time.Second),\n\t\t},\n\t}\n\tcfg.SetDefaults()\n\tassert.Equal(t, 20*time.Second, *cfg.Limits.MaxPollInterval)\n\tassert.Equal(t, 15*time.Second, *cfg.Limits.DrainInterval)\n}\n\nfunc TestUnmarshal_fullyPopulated(t *testing.T) {\n\tconst y = `\nbaseKafkaConfig: \n  bootstrap.servers: localhost:9093\nproducerKafkaConfig:\n  compression.type: lz4\nleaderTopic: leader-topic\nleaderGroupID: leader-group-id\ndataSource: data-source\noutboxTable: outbox-table\nlimits:\n  ioErrorBackoff: 10ms\n  pollDuration: 20ms\n  minPollInterval: 30ms\n  maxPollInterval: 35ms\n  heartbeatTimeout: 15ms\t\n  drainInterval: 32ms\n  queueTimeout: 40ms\t\n  markBackoff: 50ms\n  maxInFlightRecords: 60\n  sendConcurrency: 70\n  sendBuffer: 80\n  minMetricsInterval: 90ms\nname: test-name\n`\n\tcfg, err := Unmarshal([]byte(y))\n\trequire.Nil(t, err)\n\tif !assert.Nil(t, cfg.Validate()) {\n\t\tt.Errorf(\"Validation error: %s\", cfg.Validate().Error())\n\t}\n\texp := Config{\n\t\tBaseKafkaConfig: KafkaConfigMap{\n\t\t\t\"bootstrap.servers\": \"localhost:9093\",\n\t\t},\n\t\tProducerKafkaConfig: KafkaConfigMap{\n\t\t\t\"compression.type\": \"lz4\",\n\t\t},\n\t\tLeaderTopic:   \"leader-topic\",\n\t\tLeaderGroupID: \"leader-group-id\",\n\t\tDataSource:    \"data-source\",\n\t\tOutboxTable:   \"outbox-table\",\n\t\tLimits: Limits{\n\t\t\tIOErrorBackoff:     Duration(10 * time.Millisecond),\n\t\t\tPollDuration:       Duration(20 * time.Millisecond),\n\t\t\tMinPollInterval:    Duration(30 * time.Millisecond),\n\t\t\tMaxPollInterval:    Duration(35 * time.Millisecond),\n\t\t\tHeartbeatTimeout:   Duration(15 * time.Millisecond),\n\t\t\tDrainInterval:      Duration(32 * time.Millisecond),\n\t\t\tQueueTimeout:       Duration(40 * time.Millisecond),\n\t\t\tMarkBackoff:        Duration(50 * time.Millisecond),\n\t\t\tMaxInFlightRecords: Int(60),\n\t\t\tSendConcurrency:    Int(70),\n\t\t\tSendBuffer:         Int(80),\n\t\t\tMinMetricsInterval: Duration(90 * time.Millisecond),\n\t\t},\n\t\tName: \"test-name\",\n\t}\n\texp.SetDefaults()\n\tignoreFields := cmpopts.IgnoreFields(\n\t\tConfig{},\n\t\t\"KafkaConsumerProvider\", \"KafkaProducerProvider\", \"DatabaseBindingProvider\", \"NeliProvider\", \"Scribe\",\n\t)\n\tassert.True(t, cmp.Equal(exp, cfg, ignoreFields), \"Diff: %v\", cmp.Diff(exp, cfg, ignoreFields))\n}\n\nfunc TestUnmarshal_empty(t *testing.T) {\n\tconst y = ``\n\tcfg, err := Unmarshal([]byte(y))\n\tassert.Nil(t, err)\n\tif !assert.Nil(t, cfg.Validate()) {\n\t\tt.Errorf(\"Validation error: %s\", cfg.Validate().Error())\n\t}\n\texp := Config{}\n\texp.SetDefaults()\n\tignoreFields := cmpopts.IgnoreFields(\n\t\tConfig{},\n\t\t\"KafkaConsumerProvider\", \"KafkaProducerProvider\", \"DatabaseBindingProvider\", \"NeliProvider\", \"Scribe\", \"Name\",\n\t)\n\tassert.True(t, cmp.Equal(exp, cfg, ignoreFields), \"Diff: %v\", cmp.Diff(exp, cfg, ignoreFields))\n}\n"
  },
  {
    "path": "db.go",
    "content": "package goharvest\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n)\n\n// KafkaHeader is a key-value tuple representing a single header entry.\ntype KafkaHeader struct {\n\tKey   string\n\tValue string\n}\n\n// String obtains a textual representation of a KafkaHeader.\nfunc (h KafkaHeader) String() string {\n\treturn h.Key + \":\" + h.Value\n}\n\n// KafkaHeaders is a slice of KafkaHeader tuples.\ntype KafkaHeaders []KafkaHeader\n\n// OutboxRecord depicts a single entry in the outbox table. It can be used for both reading and writing operations.\ntype OutboxRecord struct {\n\tID           int64\n\tCreateTime   time.Time\n\tKafkaTopic   string\n\tKafkaKey     string\n\tKafkaValue   *string\n\tKafkaHeaders KafkaHeaders\n\tLeaderID     *uuid.UUID\n}\n\n// String is a convenience function that returns a pointer to the given str argument, for use with setting OutboxRecord.Value.\nfunc String(str string) *string {\n\treturn &str\n}\n\n// String provides a textual representation of an OutboxRecord.\nfunc (rec OutboxRecord) String() string {\n\treturn fmt.Sprint(\"OutboxRecord[ID=\", rec.ID,\n\t\t\", CreateTime=\", rec.CreateTime,\n\t\t\", KafkaTopic=\", rec.KafkaTopic,\n\t\t\", KafkaKey=\", rec.KafkaKey,\n\t\t\", KafkaValue=\", rec.KafkaValue,\n\t\t\", KafkaHeaders=\", rec.KafkaHeaders,\n\t\t\", LeaderID=\", rec.LeaderID, \"]\")\n}\n\n// DatabaseBinding is an abstraction over the data access layer, allowing goharvest to use arbitrary database implementations.\ntype DatabaseBinding interface {\n\tMark(leaderID uuid.UUID, limit int) ([]OutboxRecord, error)\n\tPurge(id int64) (bool, error)\n\tReset(id int64) (bool, error)\n\tDispose()\n}\n\n// DatabaseBindingProvider is a factory for creating instances of a DatabaseBinding.\ntype DatabaseBindingProvider func(dataSource string, outboxTable string) (DatabaseBinding, error)\n"
  },
  {
    "path": "db_mock_test.go",
    "content": "package goharvest\n\nimport (\n\t\"github.com/google/uuid\"\n\t\"github.com/obsidiandynamics/libstdgo/concurrent\"\n)\n\ntype dbMockFuncs struct {\n\tMark    func(m *dbMock, leaderID uuid.UUID, limit int) ([]OutboxRecord, error)\n\tPurge   func(m *dbMock, id int64) (bool, error)\n\tReset   func(m *dbMock, id int64) (bool, error)\n\tDispose func(m *dbMock)\n}\n\ntype dbMockCounts struct {\n\tMark,\n\tPurge,\n\tReset,\n\tDispose concurrent.AtomicCounter\n}\n\ntype dbMock struct {\n\tmarkedRecords chan []OutboxRecord\n\tf             dbMockFuncs\n\tc             dbMockCounts\n}\n\nfunc (m *dbMock) Mark(leaderID uuid.UUID, limit int) ([]OutboxRecord, error) {\n\tdefer m.c.Mark.Inc()\n\treturn m.f.Mark(m, leaderID, limit)\n}\n\nfunc (m *dbMock) Purge(id int64) (bool, error) {\n\tdefer m.c.Purge.Inc()\n\treturn m.f.Purge(m, id)\n}\n\nfunc (m *dbMock) Reset(id int64) (bool, error) {\n\tdefer m.c.Reset.Inc()\n\treturn m.f.Reset(m, id)\n}\n\nfunc (m *dbMock) Dispose() {\n\tdefer m.c.Dispose.Inc()\n\tm.f.Dispose(m)\n}\n\nfunc (m *dbMock) fillDefaults() {\n\tif m.markedRecords == nil {\n\t\tm.markedRecords = make(chan []OutboxRecord)\n\t}\n\n\tif m.f.Mark == nil {\n\t\tm.f.Mark = func(m *dbMock, leaderID uuid.UUID, limit int) ([]OutboxRecord, error) {\n\t\t\tselect {\n\t\t\tcase records := <-m.markedRecords:\n\t\t\t\treturn records, nil\n\t\t\tdefault:\n\t\t\t\treturn []OutboxRecord{}, nil\n\t\t\t}\n\t\t}\n\t}\n\tif m.f.Purge == nil {\n\t\tm.f.Purge = func(m *dbMock, id int64) (bool, error) {\n\t\t\treturn true, nil\n\t\t}\n\t}\n\tif m.f.Reset == nil {\n\t\tm.f.Reset = func(m *dbMock, id int64) (bool, error) {\n\t\t\treturn true, nil\n\t\t}\n\t}\n\tif m.f.Dispose == nil {\n\t\tm.f.Dispose = func(m *dbMock) {}\n\t}\n\tm.c.Mark = concurrent.NewAtomicCounter()\n\tm.c.Purge = concurrent.NewAtomicCounter()\n\tm.c.Reset = concurrent.NewAtomicCounter()\n\tm.c.Dispose = concurrent.NewAtomicCounter()\n}\n\nfunc mockDatabaseBindingProvider(m *dbMock) func(string, string) (DatabaseBinding, error) {\n\treturn func(dataSource string, table string) (DatabaseBinding, error) {\n\t\treturn m, nil\n\t}\n}\n"
  },
  {
    "path": "event.go",
    "content": "package goharvest\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/obsidiandynamics/goharvest/metric\"\n)\n\n// EventHandler is a callback function for handling GoHarvest events.\ntype EventHandler func(e Event)\n\n// Event encapsulates a GoHarvest event.\ntype Event interface {\n\tfmt.Stringer\n}\n\n// LeaderAcquired is emitted upon successful acquisition of leader status.\ntype LeaderAcquired struct {\n\tleaderID uuid.UUID\n}\n\n// String obtains a textual representation of the LeaderAcquired event.\nfunc (e LeaderAcquired) String() string {\n\treturn fmt.Sprint(\"LeaderAcquired[leaderID=\", e.leaderID, \"]\")\n}\n\n// LeaderID returns the local UUID of the elected leader.\nfunc (e LeaderAcquired) LeaderID() uuid.UUID {\n\treturn e.leaderID\n}\n\n// LeaderRefreshed is emitted when a new leader ID is generated as a result of a remarking request.\ntype LeaderRefreshed struct {\n\tleaderID uuid.UUID\n}\n\n// String obtains a textual representation of the LeaderRefreshed event.\nfunc (e LeaderRefreshed) String() string {\n\treturn fmt.Sprint(\"LeaderRefreshed[leaderID=\", e.leaderID, \"]\")\n}\n\n// LeaderID returns the local UUID of the elected leader.\nfunc (e LeaderRefreshed) LeaderID() uuid.UUID {\n\treturn e.leaderID\n}\n\n// LeaderRevoked is emitted when the leader status has been revoked.\ntype LeaderRevoked struct{}\n\n// String obtains a textual representation of the LeaderRevoked event.\nfunc (e LeaderRevoked) String() string {\n\treturn fmt.Sprint(\"LeaderRevoked[]\")\n}\n\n// LeaderFenced is emitted when the leader status has been revoked.\ntype LeaderFenced struct{}\n\n// String obtains a textual representation of the LeaderFenced event.\nfunc (e LeaderFenced) String() string {\n\treturn fmt.Sprint(\"LeaderFenced[]\")\n}\n\n// MeterRead is emitted when the internal throughput Meter has been read.\ntype MeterRead struct {\n\tstats metric.MeterStats\n}\n\n// String obtains a textual representation of the MeterRead event.\nfunc (e MeterRead) String() string {\n\treturn fmt.Sprint(\"MeterRead[stats=\", e.stats, \"]\")\n}\n\n// Stats embedded in the MeterRead event.\nfunc (e MeterRead) Stats() metric.MeterStats {\n\treturn e.stats\n}\n"
  },
  {
    "path": "event_test.go",
    "content": "package goharvest\n\nimport (\n\t\"testing\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/obsidiandynamics/goharvest/metric\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestLeaderAcquired_string(t *testing.T) {\n\tleaderID, _ := uuid.NewRandom()\n\tassert.Contains(t, LeaderAcquired{leaderID}.String(), \"LeaderAcquired[\")\n\tassert.Contains(t, LeaderAcquired{leaderID}.String(), leaderID.String())\n}\n\nfunc TestLeaderAcquired_getter(t *testing.T) {\n\tleaderID, _ := uuid.NewRandom()\n\te := LeaderAcquired{leaderID}\n\tassert.Equal(t, leaderID, e.LeaderID())\n}\n\nfunc TestLeaderRefreshed_string(t *testing.T) {\n\tleaderID, _ := uuid.NewRandom()\n\tassert.Contains(t, LeaderRefreshed{leaderID}.String(), \"LeaderRefreshed[\")\n\tassert.Contains(t, LeaderRefreshed{leaderID}.String(), leaderID.String())\n}\n\nfunc TestLeaderRefreshed_getter(t *testing.T) {\n\tleaderID, _ := uuid.NewRandom()\n\te := LeaderRefreshed{leaderID}\n\tassert.Equal(t, leaderID, e.LeaderID())\n}\n\nfunc TestLeaderRevoked_string(t *testing.T) {\n\tassert.Equal(t, \"LeaderRevoked[]\", LeaderRevoked{}.String())\n}\n\nfunc TestLeaderFenced_string(t *testing.T) {\n\tassert.Equal(t, \"LeaderFenced[]\", LeaderFenced{}.String())\n}\n\nfunc TestMeterStats_string(t *testing.T) {\n\tstats := metric.MeterStats{}\n\tassert.Contains(t, MeterRead{stats}.String(), \"MeterRead[\")\n\tassert.Contains(t, MeterRead{stats}.String(), stats.String())\n}\n"
  },
  {
    "path": "examples/reaper.yaml",
    "content": "harvest:\n  baseKafkaConfig: \n    bootstrap.servers: localhost:9092\n  producerKafkaConfig:\n    compression.type: lz4\n    delivery.timeout.ms: 10000\n  leaderTopic: my-app-name\n  leaderGroupID: my-app-name\n  dataSource: host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\n  outboxTable: outbox\n  limits:\n    minPollInterval: 1s\n    heartbeatTimeout: 5s\n    maxInFlightRecords: 1000\n    minMetricsInterval: 5s\n    sendConcurrency: 4\n    sendBuffer: 10\nlogging:\n  level: Debug"
  },
  {
    "path": "examples/reaper_secure.yaml",
    "content": "harvest:\n  baseKafkaConfig: \n    bootstrap.servers: localhost:9094\n    security.protocol: sasl_ssl\n    ssl.ca.location: ca-cert.pem\n    sasl.mechanism: SCRAM-SHA-512\n    sasl.username: alice\n    sasl.password: alice-secret\n  leaderTopic: __consumer_offsets\n  leaderGroupID: my-app-name\n  dataSource: host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\n  outboxTable: outbox\nlogging:\n  level: Debug\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/obsidiandynamics/goharvest\n\ngo 1.14\n\nrequire (\n\tgithub.com/DATA-DOG/go-sqlmock v1.4.1\n\tgithub.com/confluentinc/confluent-kafka-go v1.5.2 // indirect\n\tgithub.com/go-ozzo/ozzo-validation v3.6.0+incompatible\n\tgithub.com/google/go-cmp v0.4.0\n\tgithub.com/google/uuid v1.1.1\n\tgithub.com/lib/pq v1.5.1\n\tgithub.com/obsidiandynamics/goneli v0.4.3\n\tgithub.com/obsidiandynamics/libstdgo v0.4.1\n\tgithub.com/sirupsen/logrus v1.5.0\n\tgithub.com/stretchr/testify v1.5.1\n\tgolang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f // indirect\n\tgopkg.in/confluentinc/confluent-kafka-go.v1 v1.5.2\n\tgopkg.in/yaml.v2 v2.2.8\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=\ngithub.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=\ngithub.com/DATA-DOG/go-sqlmock v1.4.1 h1:ThlnYciV1iM/V0OSF/dtkqWb6xo5qITT1TJBG1MRDJM=\ngithub.com/DATA-DOG/go-sqlmock v1.4.1/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=\ngithub.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=\ngithub.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=\ngithub.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 h1:kHaBemcxl8o/pQ5VM1c8PVE1PubbNx3mjUr09OqWGCs=\ngithub.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575/go.mod h1:9d6lWj8KzO/fd/NrVaLscBKmPigpZpn5YawRPw+e3Yo=\ngithub.com/confluentinc/confluent-kafka-go v1.5.2 h1:l+qt+a0Okmq0Bdr1P55IX4fiwFJyg0lZQmfHkAFkv7E=\ngithub.com/confluentinc/confluent-kafka-go v1.5.2/go.mod h1:u2zNLny2xq+5rWeTQjFHbDzzNuba4P1vo31r9r4uAdg=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE=\ngithub.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU=\ngithub.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=\ngithub.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58=\ngithub.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=\ngithub.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=\ngithub.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=\ngithub.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=\ngithub.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1 h1:KUDFlmBg2buRWNzIcwLlKvfcnujcHQRQ1As1LoaCLAM=\ngithub.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=\ngithub.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=\ngithub.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=\ngithub.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/lib/pq v1.5.1 h1:Jn6HYxiYrtQ92CopqJLvfPCJUrrruw1+1cn0jM9dKrI=\ngithub.com/lib/pq v1.5.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=\ngithub.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=\ngithub.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=\ngithub.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=\ngithub.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=\ngithub.com/obsidiandynamics/goneli v0.4.3 h1:lf3x/qSgEX9S6+Ak5GPcc3TBUQBhPJeiWvGrCykZcbM=\ngithub.com/obsidiandynamics/goneli v0.4.3/go.mod h1:1i3mTL/PaaDKu6f+hlndeRUCbV8uiDxu+203vBpn6oE=\ngithub.com/obsidiandynamics/libstdgo v0.4.1 h1:ZUnz+72xQSMgAjEqxp7i7NOBZlu6AcAE6ppmvVKxK3M=\ngithub.com/obsidiandynamics/libstdgo v0.4.1/go.mod h1:0gKiFsJhfrlCqbWFNhDDUJgj6XbXWZyrl0JS/C+jU5g=\ngithub.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=\ngithub.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=\ngithub.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q=\ngithub.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=\ngithub.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngo.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=\ngo.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=\ngo.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A=\ngo.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=\ngo.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=\ngo.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=\ngo.uber.org/zap v1.14.1 h1:nYDKopTbvAPq/NrUVZwT15y2lpROBiLLyoRTbXOYWOo=\ngo.uber.org/zap v1.14.1/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=\ngolang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=\ngolang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=\ngolang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=\ngolang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=\ngolang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=\ngolang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY=\ngolang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8=\ngolang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=\ngolang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=\ngolang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs=\ngolang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=\ngolang.org/x/tools v0.0.0-20200417140056-c07e33ef3290/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=\ngopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/confluentinc/confluent-kafka-go.v1 v1.4.0 h1:70Hht0HKadDe6GpSgstEtYrDMtHo3ZqK+3KeHepusaw=\ngopkg.in/confluentinc/confluent-kafka-go.v1 v1.4.0/go.mod h1:ZdI3yfYmdNSLQPNCpO1y00EHyWaHG5EnQEyL/ntAegY=\ngopkg.in/confluentinc/confluent-kafka-go.v1 v1.5.2 h1:g0WBLy6fobNUU8W/e9zx6I0Yl79Ya+BDW1NwzAlTiiQ=\ngopkg.in/confluentinc/confluent-kafka-go.v1 v1.5.2/go.mod h1:ZdI3yfYmdNSLQPNCpO1y00EHyWaHG5EnQEyL/ntAegY=\ngopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=\ngopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\nhonnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=\nhonnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=\nhonnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=\n"
  },
  {
    "path": "goharvest_doc_test.go",
    "content": "package goharvest\n\nimport (\n\t\"database/sql\"\n\t\"log\"\n\t\"testing\"\n\n\t\"github.com/obsidiandynamics/libstdgo/check\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\tscribelogrus \"github.com/obsidiandynamics/libstdgo/scribe/logrus\"\n\t\"github.com/sirupsen/logrus\"\n)\n\nfunc Example() {\n\tconst dataSource = \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\"\n\n\t// Optional: Ensure the database table exists before we start harvesting.\n\tfunc() {\n\t\tdb, err := sql.Open(\"postgres\", dataSource)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t\tdefer db.Close()\n\n\t\t_, err = db.Exec(`\n\t\t\tCREATE TABLE IF NOT EXISTS outbox (\n\t\t\t\tid                  BIGSERIAL PRIMARY KEY,\n\t\t\t\tcreate_time         TIMESTAMP WITH TIME ZONE NOT NULL,\n\t\t\t\tkafka_topic         VARCHAR(249) NOT NULL,\n\t\t\t\tkafka_key           VARCHAR(100) NOT NULL,  -- pick your own key size\n\t\t\t\tkafka_value         VARCHAR(10000),         -- pick your own value size\n\t\t\t\tkafka_header_keys   TEXT[] NOT NULL,\n\t\t\t\tkafka_header_values TEXT[] NOT NULL,\n\t\t\t\tleader_id           UUID\n\t\t\t)\n\t\t`)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}()\n\n\t// Configure the harvester. It will use its own database and Kafka connections under the hood.\n\tconfig := Config{\n\t\tBaseKafkaConfig: KafkaConfigMap{\n\t\t\t\"bootstrap.servers\": \"localhost:9092\",\n\t\t},\n\t\tDataSource: dataSource,\n\t}\n\n\t// Create a new harvester.\n\tharvest, err := New(config)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Start it.\n\terr = harvest.Start()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Wait indefinitely for it to end.\n\tlog.Fatal(harvest.Await())\n}\n\nfunc TestExample(t *testing.T) {\n\tcheck.RunTargetted(t, Example)\n}\n\nfunc Example_withCustomLogger() {\n\t// Example: Configure GoHarvest with a Logrus binding for Scribe.\n\n\tlog := logrus.StandardLogger()\n\tlog.SetLevel(logrus.DebugLevel)\n\n\t// Configure the custom logger using a binding.\n\tconfig := Config{\n\t\tBaseKafkaConfig: KafkaConfigMap{\n\t\t\t\"bootstrap.servers\": \"localhost:9092\",\n\t\t},\n\t\tScribe:     scribe.New(scribelogrus.Bind()),\n\t\tDataSource: \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\",\n\t}\n\n\t// Create a new harvester.\n\tharvest, err := New(config)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Start it.\n\terr = harvest.Start()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Wait indefinitely for it to end.\n\tlog.Fatal(harvest.Await())\n}\n\nfunc TestExample_withCustomLogger(t *testing.T) {\n\tcheck.RunTargetted(t, Example_withCustomLogger)\n}\n\nfunc Example_withSaslSslAndCustomProducerConfig() {\n\t// Example: Using Kafka with sasl_ssl for authentication and encryption.\n\n\tconfig := Config{\n\t\tBaseKafkaConfig: KafkaConfigMap{\n\t\t\t\"bootstrap.servers\": \"localhost:9094\",\n\t\t\t\"security.protocol\": \"sasl_ssl\",\n\t\t\t\"ssl.ca.location\":   \"ca-cert.pem\",\n\t\t\t\"sasl.mechanism\":    \"SCRAM-SHA-512\",\n\t\t\t\"sasl.username\":     \"alice\",\n\t\t\t\"sasl.password\":     \"alice-secret\",\n\t\t},\n\t\tProducerKafkaConfig: KafkaConfigMap{\n\t\t\t\"compression.type\": \"lz4\",\n\t\t},\n\t\tDataSource: \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\",\n\t}\n\n\t// Create a new harvester.\n\tharvest, err := New(config)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Start harvesting in the background.\n\terr = harvest.Start()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Wait indefinitely for the harvester to end.\n\tlog.Fatal(harvest.Await())\n}\n\nfunc TestExample_withSaslSslAndCustomProducerConfig(t *testing.T) {\n\tcheck.RunTargetted(t, Example_withSaslSslAndCustomProducerConfig)\n}\n\nfunc Example_withEventHandler() {\n\t// Example: Registering a custom event handler to get notified of leadership changes and metrics.\n\n\tlog := logrus.StandardLogger()\n\tlog.SetLevel(logrus.TraceLevel)\n\tconfig := Config{\n\t\tBaseKafkaConfig: KafkaConfigMap{\n\t\t\t\"bootstrap.servers\": \"localhost:9092\",\n\t\t},\n\t\tDataSource: \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\",\n\t\tScribe:     scribe.New(scribelogrus.Bind()),\n\t}\n\n\t// Create a new harvester and register an event hander.\n\tharvest, err := New(config)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Register a handler callback, invoked when an event occurs within goharvest.\n\t// The callback is completely optional; it lets the application piggy-back on leader\n\t// status updates, in case it needs to schedule some additional work (other than\n\t// harvesting outbox records) that should only be run on one process at any given time.\n\tharvest.SetEventHandler(func(e Event) {\n\t\tswitch event := e.(type) {\n\t\tcase LeaderAcquired:\n\t\t\t// The application may initialise any state necessary to perform work as a leader.\n\t\t\tlog.Infof(\"Got event: leader acquired: %v\", event.LeaderID())\n\t\tcase LeaderRefreshed:\n\t\t\t// Indicates that a new leader ID was generated, as a result of having to remark\n\t\t\t// a record (typically as due to an earlier delivery error). This is purely\n\t\t\t// informational; there is nothing an application should do about this, other\n\t\t\t// than taking note of the new leader ID if it has come to rely on it.\n\t\t\tlog.Infof(\"Got event: leader refreshed: %v\", event.LeaderID())\n\t\tcase LeaderRevoked:\n\t\t\t// The application may block the callback until it wraps up any in-flight\n\t\t\t// activity. Only upon returning from the callback, will a new leader be elected.\n\t\t\tlog.Infof(\"Got event: leader revoked\")\n\t\tcase LeaderFenced:\n\t\t\t// The application must immediately terminate any ongoing activity, on the assumption\n\t\t\t// that another leader may be imminently elected. Unlike the handling of LeaderRevoked,\n\t\t\t// blocking in the callback will not prevent a new leader from being elected.\n\t\t\tlog.Infof(\"Got event: leader fenced\")\n\t\tcase MeterRead:\n\t\t\t// Periodic statistics regarding the harvester's throughput.\n\t\t\tlog.Infof(\"Got event: meter read: %v\", event.Stats())\n\t\t}\n\t})\n\n\t// Start harvesting in the background.\n\terr = harvest.Start()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Wait indefinitely for it to end.\n\tlog.Fatal(harvest.Await())\n}\n\nfunc TestExample_withEventHandler(t *testing.T) {\n\tcheck.RunTargetted(t, Example_withEventHandler)\n}\n"
  },
  {
    "path": "harvest.go",
    "content": "package goharvest\n\nimport (\n\t\"fmt\"\n\t\"runtime/debug\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/obsidiandynamics/goharvest/metric\"\n\t\"github.com/obsidiandynamics/goneli\"\n\t\"github.com/obsidiandynamics/libstdgo/concurrent\"\n\t\"github.com/obsidiandynamics/libstdgo/diags\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\t\"gopkg.in/confluentinc/confluent-kafka-go.v1/kafka\"\n\t_ \"gopkg.in/confluentinc/confluent-kafka-go.v1/kafka/librdkafka\"\n)\n\nvar noLeader uuid.UUID\n\n// State of the Harvest instance.\ntype State int\n\nconst (\n\t// Created — initialised (configured) but not started.\n\tCreated State = iota\n\n\t// Running — currently running.\n\tRunning\n\n\t// Stopping — in the process of being stopped. I.e. Stop() has been invoked, but workers are still running.\n\tStopping\n\n\t// Stopped — has been completely disposed of.\n\tStopped\n)\n\ntype tracedPanic struct {\n\tcause interface{}\n\tstack string\n}\n\nfunc (e tracedPanic) Error() string {\n\treturn fmt.Sprintf(\"%v\\n%s\", e.cause, e.stack)\n}\n\n// Harvest performs background harvesting of a transactional outbox table.\ntype Harvest interface {\n\tStart() error\n\tStop()\n\tAwait() error\n\tState() State\n\tIsLeader() bool\n\tLeaderID() *uuid.UUID\n\tInFlightRecords() int\n\tInFlightRecordKeys() []string\n\tSetEventHandler(eventHandler EventHandler)\n}\n\nconst watcherTimeout = 60 * time.Second\n\ntype harvest struct {\n\tconfig              Config\n\tproducerConfigs     KafkaConfigMap\n\tscribe              scribe.Scribe\n\tstate               concurrent.AtomicReference\n\tshouldBeRunningFlag concurrent.AtomicCounter\n\tneli                goneli.Neli\n\tleaderID            atomic.Value\n\tdb                  DatabaseBinding\n\tqueuedRecords       concurrent.AtomicCounter\n\tinFlightRecords     concurrent.AtomicCounter\n\tinFlightKeys        concurrent.Scoreboard\n\tthroughput          *metric.Meter\n\tthroughputLock      sync.Mutex\n\tpanicCause          atomic.Value\n\teventHandler        EventHandler\n\tforceRemarkFlag     concurrent.AtomicCounter\n\tsendBattery         battery\n}\n\n// New creates a new Harvest instance from the supplied config.\nfunc New(config Config) (Harvest, error) {\n\tconfig.SetDefaults()\n\tif err := config.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\th := &harvest{\n\t\tconfig:              config,\n\t\tscribe:              config.Scribe,\n\t\tstate:               concurrent.NewAtomicReference(Created),\n\t\tshouldBeRunningFlag: concurrent.NewAtomicCounter(1),\n\t\tqueuedRecords:       concurrent.NewAtomicCounter(),\n\t\tinFlightRecords:     concurrent.NewAtomicCounter(),\n\t\tinFlightKeys:        concurrent.NewScoreboard(*config.Limits.SendConcurrency),\n\t\tforceRemarkFlag:     concurrent.NewAtomicCounter(),\n\t\teventHandler:        func(e Event) {},\n\t}\n\th.leaderID.Store(noLeader)\n\n\th.producerConfigs = copyKafkaConfig(h.config.BaseKafkaConfig)\n\tputAllKafkaConfig(h.config.ProducerKafkaConfig, h.producerConfigs)\n\terr := setKafkaConfigs(h.producerConfigs, KafkaConfigMap{\n\t\t\"enable.idempotence\": true,\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn h, nil\n}\n\n// State obtains the present state of this Harvest instance.\nfunc (h *harvest) State() State {\n\treturn h.state.Get().(State)\n}\n\nfunc (h *harvest) logger() scribe.StdLogAPI {\n\treturn h.scribe.Capture(h.scene())\n}\n\nfunc (h *harvest) scene() scribe.Scene {\n\treturn scribe.Scene{Fields: scribe.Fields{\n\t\t\"name\": h.config.Name,\n\t\t\"lib\":  \"goharvest\",\n\t}}\n}\nfunc (h *harvest) cleanupFailedStart() {\n\tif h.State() != Created {\n\t\treturn\n\t}\n\n\tif h.db != nil {\n\t\th.db.Dispose()\n\t}\n}\n\n// Start the harvester.\nfunc (h *harvest) Start() error {\n\tensureState(h.State() == Created, \"Cannot start at this time\")\n\tdefer h.cleanupFailedStart()\n\n\tdb, err := h.config.DatabaseBindingProvider(h.config.DataSource, h.config.OutboxTable)\n\tif err != nil {\n\t\treturn err\n\t}\n\th.db = db\n\n\tneliConfig := goneli.Config{\n\t\tKafkaConfig:           configToNeli(h.config.BaseKafkaConfig),\n\t\tLeaderTopic:           h.config.LeaderTopic,\n\t\tLeaderGroupID:         h.config.LeaderGroupID,\n\t\tKafkaConsumerProvider: convertKafkaConsumerProvider(h.config.KafkaConsumerProvider),\n\t\tKafkaProducerProvider: convertKafkaProducerProvider(h.config.KafkaProducerProvider),\n\t\tScribe:                h.config.Scribe,\n\t\tName:                  h.config.Name,\n\t\tPollDuration:          h.config.Limits.PollDuration,\n\t\tMinPollInterval:       h.config.Limits.MinPollInterval,\n\t\tHeartbeatTimeout:      h.config.Limits.HeartbeatTimeout,\n\t}\n\th.logger().T()(\"Creating NELI with config %v\", neliConfig)\n\tn, err := h.config.NeliProvider(neliConfig, func(e goneli.Event) {\n\t\tswitch e.(type) {\n\t\tcase goneli.LeaderAcquired:\n\t\t\th.onAcquired()\n\t\tcase goneli.LeaderRevoked:\n\t\t\th.onRevoked()\n\t\tcase goneli.LeaderFenced:\n\t\t\th.onFenced()\n\t\t}\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\th.neli = n\n\n\th.throughput = metric.NewMeter(\"throughput\", *h.config.Limits.MinMetricsInterval)\n\n\th.state.Set(Running)\n\tgo backgroundPoller(h)\n\treturn nil\n}\n\n// IsLeader returns true if the current Harvest is the leader among competing instances.\nfunc (h *harvest) IsLeader() bool {\n\treturn h.LeaderID() != nil\n}\n\n// LeaderID returns the leader UUID of the current instance, if it is a leader at the time of this call.\n// Otherwise, a nil is returned.\nfunc (h *harvest) LeaderID() *uuid.UUID {\n\tif stored := h.leaderID.Load().(uuid.UUID); stored != noLeader {\n\t\treturn &stored\n\t}\n\treturn nil\n}\n\n// InFlightRecords returns the number of in-flight records; i.e. records that have been published on Kafka for which an\n// acknowledgement is still pending.\nfunc (h *harvest) InFlightRecords() int {\n\treturn h.inFlightRecords.GetInt()\n}\n\n// InFlightRecordKeys returns the keys of records that are still in-flight. For any given key, there will be at most one\n// record pending acknowledgement.\nfunc (h *harvest) InFlightRecordKeys() []string {\n\tview := h.inFlightKeys.View()\n\tkeys := make([]string, len(view))\n\n\ti := 0\n\tfor k := range view {\n\t\tkeys[i] = k\n\t\ti++\n\t}\n\treturn keys\n}\n\n// SetEventHandler assigns an optional event handler callback to be notified of changes in leader state as well as other\n// events of interest.\n//\n// This method must be invoked prior to Start().\nfunc (h *harvest) SetEventHandler(eventHandler EventHandler) {\n\tensureState(h.State() == Created, \"Cannot set event handler at this time\")\n\th.eventHandler = eventHandler\n}\n\nfunc (h *harvest) shouldBeRunning() bool {\n\treturn h.shouldBeRunningFlag.Get() == 1\n}\n\nfunc (h *harvest) reportPanic(goroutineName string) {\n\tif r := recover(); r != nil {\n\t\th.logger().E()(\"Caught panic in %s: %v\", goroutineName, r)\n\t\th.panicCause.Store(tracedPanic{r, string(debug.Stack())})\n\t\th.logger().E()(string(debug.Stack()))\n\t\th.Stop()\n\t}\n}\n\nfunc ensureState(expected bool, format string, args ...interface{}) {\n\tif !expected {\n\t\tpanic(fmt.Errorf(\"state assertion failed: \"+format, args...))\n\t}\n}\n\nfunc backgroundPoller(h *harvest) {\n\th.logger().I()(\"Starting background poller\")\n\tdefer h.logger().I()(\"Stopped\")\n\tdefer h.state.Set(Stopped)\n\tdefer h.reportPanic(\"background poller\")\n\tdefer h.db.Dispose()\n\tdefer h.neli.Close()\n\tdefer h.shutdownSendBattery()\n\tdefer h.state.Set(Stopping)\n\tdefer h.logger().I()(\"Stopping\")\n\n\tfor h.shouldBeRunning() {\n\t\tisLeader, err := h.neli.Pulse(1 * time.Millisecond)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tif isLeader {\n\t\t\tif h.forceRemarkFlag.Get() == 1 {\n\t\t\t\th.logger().D()(\"Remark requested\")\n\t\t\t\th.shutdownSendBattery()\n\t\t\t\th.refreshLeader()\n\t\t\t}\n\t\t\tif h.sendBattery == nil {\n\t\t\t\tinFlightRecordsValue := h.inFlightRecords.Get()\n\t\t\t\tensureState(inFlightRecordsValue == 0, \"inFlightRecords=%d\", inFlightRecordsValue)\n\t\t\t\tinFlightKeysView := h.inFlightKeys.View()\n\t\t\t\tensureState(len(inFlightKeysView) == 0, \"inFlightKeys=%d\", inFlightKeysView)\n\t\t\t\th.spawnSendBattery()\n\t\t\t}\n\t\t\tonLeaderPoll(h)\n\t\t}\n\t}\n}\n\nfunc (h *harvest) spawnSendBattery() {\n\tensureState(h.sendBattery == nil, \"send battery not nil before spawn\")\n\th.logger().D()(\"Spawning send battery\")\n\th.sendBattery = newConcurrentBattery(*h.config.Limits.SendConcurrency, *h.config.Limits.SendBuffer, func(records chan OutboxRecord) {\n\t\tdefer h.reportPanic(\"send cell\")\n\n\t\th.logger().T()(\"Creating Kafka producer with config %v\", h.producerConfigs)\n\t\tprod, err := h.config.KafkaProducerProvider(&h.producerConfigs)\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tdeliveryHandlerDone := make(chan int)\n\t\tgo backgroundDeliveryHandler(h, prod, deliveryHandlerDone)\n\n\t\tdefer func() {\n\t\t\t<-deliveryHandlerDone\n\t\t}()\n\t\tdefer func() {\n\t\t\tgo func() {\n\t\t\t\t// A bug in confluent-kafka-go (#463) occasionally causes an indefinite syscall hang in Close(), after it closes\n\t\t\t\t// the Events channel. So we delegate this to a separate goroutine — better an orphaned goroutine than a\n\t\t\t\t// frozen harvester. (The rest of the battery will still unwind normally.)\n\t\t\t\tcloseWatcher := h.watch(\"close producer\")\n\t\t\t\tprod.Close()\n\t\t\t\tcloseWatcher.End()\n\t\t\t}()\n\t\t}()\n\n\t\tvar lastID *int64\n\t\tfor rec := range records {\n\t\t\tensureState(lastID == nil || rec.ID >= *lastID, \"discontinuity for key %s: ID %s, lastID: %v\", rec.KafkaKey, rec.ID, lastID)\n\t\t\tlastID = &rec.ID\n\n\t\t\tm := &kafka.Message{\n\t\t\t\tTopicPartition: kafka.TopicPartition{Topic: &rec.KafkaTopic, Partition: kafka.PartitionAny},\n\t\t\t\tKey:            []byte(rec.KafkaKey),\n\t\t\t\tValue:          stringPointerToByteArray(rec.KafkaValue),\n\t\t\t\tOpaque:         rec,\n\t\t\t\tHeaders:        toNativeKafkaHeaders(rec.KafkaHeaders),\n\t\t\t}\n\n\t\t\th.inFlightRecords.Drain(int64(*h.config.Limits.MaxInFlightRecords-1), concurrent.Indefinitely)\n\n\t\t\tstartTime := time.Now()\n\t\t\tfor {\n\t\t\t\tif h.deadlineExceeded(\"poll\", h.neli.Deadline().Elapsed(), *h.config.Limits.MaxPollInterval) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif h.deadlineExceeded(\"message queueing\", time.Now().Sub(startTime), *h.config.Limits.QueueTimeout) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif remaining := h.inFlightKeys.Drain(rec.KafkaKey, 0, *h.config.Limits.DrainInterval); remaining <= 0 {\n\t\t\t\t\tensureState(remaining == 0, \"drain failed: %d remaining in-flight records for key %s\", remaining, rec.KafkaKey)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\th.logger().D()(\"Drain stalled for record %d (key %s)\", rec.ID, rec.KafkaKey)\n\t\t\t}\n\n\t\t\tif h.forceRemarkFlag.Get() == 1 {\n\t\t\t\th.queuedRecords.Dec()\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\th.inFlightRecords.Inc()\n\t\t\th.queuedRecords.Dec()\n\t\t\th.inFlightKeys.Inc(rec.KafkaKey)\n\n\t\t\terr := prod.Produce(m, nil)\n\t\t\tif err != nil {\n\t\t\t\th.logger().W()(\"Error publishing record %v: %v\", rec, err)\n\t\t\t\th.inFlightKeys.Dec(rec.KafkaKey)\n\t\t\t\th.inFlightRecords.Dec()\n\t\t\t\th.forceRemarkFlag.Set(1)\n\t\t\t}\n\t\t}\n\t})\n}\n\nfunc stringPointerToByteArray(str *string) []byte {\n\tif str != nil {\n\t\treturn []byte(*str)\n\t}\n\treturn nil\n}\n\nfunc (h *harvest) shutdownSendBattery() {\n\tif h.sendBattery != nil {\n\t\tshutdownWatcher := h.watch(\"shutdown send battery\")\n\t\th.logger().D()(\"Shutting down send battery\")\n\n\t\t// Expedite shutdown by raising the remark flag, forcing any queued records to be skipped.\n\t\th.forceRemarkFlag.Set(1)\n\n\t\t// Take the battery down, waiting for all goroutines to complete.\n\t\th.sendBattery.shutdown()\n\t\th.sendBattery = nil\n\n\t\t// Reset flags and counters for next time.\n\t\th.forceRemarkFlag.Set(0)\n\t\th.inFlightRecords.Set(0)\n\t\th.inFlightKeys.Clear()\n\t\th.logger().D()(\"Send battery terminated\")\n\t\tshutdownWatcher.End()\n\t}\n}\n\nfunc onLeaderPoll(h *harvest) {\n\tmarkBegin := time.Now()\n\trecords, err := h.db.Mark(*h.LeaderID(), *h.config.Limits.MarkQueryRecords)\n\n\tif err != nil {\n\t\th.logger().W()(\"Error executing mark query: %v\", err)\n\t\t// When an error occurs during marking, we cannot just backoff and retry, as the error could have\n\t\t// occurred on the return leg (i.e. DB operation succeeded on the server, but timed out on the client).\n\t\th.forceRemarkFlag.Set(1)\n\t\ttime.Sleep(*h.config.Limits.IOErrorBackoff)\n\t\treturn\n\t}\n\n\tif len(records) > 0 {\n\t\tsendBegin := time.Now()\n\t\th.logger().T()(\"Leader poll: marked %d in the range %d-%d, took %v\",\n\t\t\tlen(records), records[0].ID, records[len(records)-1].ID, sendBegin.Sub(markBegin))\n\n\t\tenqueueWatcher := h.watch(\"enqueue marked records\")\n\t\tfor _, rec := range records {\n\t\t\th.queuedRecords.Inc()\n\t\t\th.sendBattery.enqueue(rec)\n\t\t}\n\t\tenqueueWatcher.End()\n\t\th.logger().T()(\"Send took %v\", time.Now().Sub(sendBegin))\n\t} else {\n\t\ttime.Sleep(*h.config.Limits.MarkBackoff)\n\t}\n}\n\nfunc (h *harvest) watch(operation string) *diags.Watcher {\n\treturn diags.Watch(operation, watcherTimeout, diags.Print(h.logger().W()))\n}\n\nfunc (h *harvest) refreshLeader() {\n\tnewLeaderID, _ := uuid.NewRandom()\n\th.leaderID.Store(newLeaderID)\n\th.logger().W()(\"Refreshed leader ID: %v\", newLeaderID)\n\th.eventHandler(LeaderRefreshed{newLeaderID})\n}\n\nfunc (h *harvest) deadlineExceeded(deadline string, elapsed time.Duration, threshold time.Duration) bool {\n\tif excess := elapsed - threshold; excess > 0 {\n\t\tif h.forceRemarkFlag.CompareAndSwap(0, 1) {\n\t\t\th.logger().W()(\"Exceeded %s deadline by %v\", deadline, excess)\n\t\t}\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc backgroundDeliveryHandler(h *harvest, prod KafkaProducer, done chan int) {\n\th.logger().I()(\"Starting background delivery handler\")\n\tdefer h.reportPanic(\"background delivery handler\")\n\tdefer close(done)\n\n\tfor e := range prod.Events() {\n\t\tswitch ev := e.(type) {\n\t\tcase *kafka.Message:\n\t\t\trec := ev.Opaque.(OutboxRecord)\n\t\t\tif ev.TopicPartition.Error != nil {\n\t\t\t\tonFailedDelivery(h, rec, ev.TopicPartition.Error)\n\t\t\t} else {\n\t\t\t\tonSuccessfulDelivery(h, rec)\n\t\t\t\th.updateStats()\n\t\t\t}\n\t\tdefault:\n\t\t\th.logger().I()(\"Observed event: %v (%T)\", e, e)\n\t\t}\n\t}\n}\n\nfunc (h *harvest) updateStats() {\n\th.throughputLock.Lock()\n\tdefer h.throughputLock.Unlock()\n\th.throughput.MaybeStatsCall(func(stats metric.MeterStats) {\n\t\th.logger().D()(\"%v\", stats)\n\t\th.eventHandler(MeterRead{stats})\n\t})\n\th.throughput.Add(1)\n}\n\nfunc onSuccessfulDelivery(h *harvest, rec OutboxRecord) {\n\tfor {\n\t\tdone, err := h.db.Purge(rec.ID)\n\t\tif err == nil {\n\t\t\tif !done {\n\t\t\t\th.logger().W()(\"Did not purge record %v\", rec)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\th.logger().W()(\"Error executing purge query for record %v: %v\", rec, err)\n\t\ttime.Sleep(*h.config.Limits.IOErrorBackoff)\n\t}\n\th.inFlightKeys.Dec(rec.KafkaKey)\n\th.inFlightRecords.Dec()\n}\n\nfunc onFailedDelivery(h *harvest, rec OutboxRecord, err error) {\n\th.logger().W()(\"Delivery failed for %v, err: %v\", rec, err)\n\tfor {\n\t\tdone, err := h.db.Reset(rec.ID)\n\t\tif err == nil {\n\t\t\tif !done {\n\t\t\t\th.logger().W()(\"Did not reset record %v\", rec)\n\t\t\t} else {\n\t\t\t\th.forceRemarkFlag.Set(1)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t\th.logger().W()(\"Error executing reset query for record %v: %v\", rec, err)\n\t\ttime.Sleep(*h.config.Limits.IOErrorBackoff)\n\t}\n\th.inFlightKeys.Dec(rec.KafkaKey)\n\th.inFlightRecords.Dec()\n}\n\nfunc (h *harvest) onAcquired() {\n\tnewLeaderID, _ := uuid.NewRandom()\n\th.leaderID.Store(newLeaderID)\n\th.logger().I()(\"Elected as leader, ID: %v\", newLeaderID)\n\th.eventHandler(LeaderAcquired{newLeaderID})\n}\n\nfunc (h *harvest) onRevoked() {\n\th.logger().I()(\"Lost leader status\")\n\th.cleanupLeaderState()\n\th.eventHandler(LeaderRevoked{})\n}\n\nfunc (h *harvest) onFenced() {\n\th.logger().W()(\"Leader fenced\")\n\th.cleanupLeaderState()\n\th.eventHandler(LeaderFenced{})\n}\n\nfunc (h *harvest) cleanupLeaderState() {\n\th.shutdownSendBattery()\n\th.leaderID.Store(noLeader)\n}\n\n// Stop the harvester, returning immediately.\n//\n// This method does not wait until the underlying Goroutines have been terminated\n// and all resources have been disposed off properly. This is accomplished by calling Await()\nfunc (h *harvest) Stop() {\n\th.shouldBeRunningFlag.Set(0)\n}\n\n// Await the termination of this Harvest instance.\n//\n// This method blocks indefinitely, returning only when this instance has completed an orderly shutdown. I.e.\n// when all Goroutines have returned and all resources have been disposed of.\nfunc (h *harvest) Await() error {\n\th.state.Await(concurrent.RefEqual(Stopped), concurrent.Indefinitely)\n\tpanicCause := h.panicCause.Load()\n\tif panicCause != nil {\n\t\treturn panicCause.(tracedPanic)\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "harvest_test.go",
    "content": "package goharvest\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/obsidiandynamics/goneli\"\n\t\"github.com/obsidiandynamics/libstdgo/check\"\n\t\"github.com/obsidiandynamics/libstdgo/concurrent\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"gopkg.in/confluentinc/confluent-kafka-go.v1/kafka\"\n)\n\nfunc wait(t check.Tester) check.Timesert {\n\treturn check.Wait(t, 10*time.Second)\n}\n\n// Aggressive limits used for (fast) testing and without send concurrency to simplify assertions.\nfunc testLimits() Limits {\n\treturn Limits{\n\t\tIOErrorBackoff:     Duration(1 * time.Millisecond),\n\t\tPollDuration:       Duration(1 * time.Millisecond),\n\t\tMinPollInterval:    Duration(1 * time.Millisecond),\n\t\tMaxPollInterval:    Duration(60 * time.Second),\n\t\tHeartbeatTimeout:   Duration(60 * time.Second),\n\t\tDrainInterval:      Duration(60 * time.Second),\n\t\tQueueTimeout:       Duration(60 * time.Second),\n\t\tMarkBackoff:        Duration(1 * time.Millisecond),\n\t\tMaxInFlightRecords: Int(math.MaxInt64),\n\t\tSendConcurrency:    Int(1),\n\t\tSendBuffer:         Int(0),\n\t}\n}\n\ntype fixtures struct {\n\tproducerMockSetup producerMockSetup\n}\n\nfunc (f *fixtures) setDefaults() {\n\tif f.producerMockSetup == nil {\n\t\tf.producerMockSetup = func(prodMock *prodMock) {}\n\t}\n}\n\ntype producerMockSetup func(prodMock *prodMock)\n\nfunc (f fixtures) create() (scribe.MockScribe, *dbMock, *goneli.MockNeli, Config) {\n\tf.setDefaults()\n\tm := scribe.NewMock()\n\n\tdb := &dbMock{}\n\tdb.fillDefaults()\n\n\tvar neli goneli.MockNeli\n\n\tconfig := Config{\n\t\tLimits:                  testLimits(),\n\t\tScribe:                  scribe.New(m.Factories()),\n\t\tDatabaseBindingProvider: mockDatabaseBindingProvider(db),\n\t\tNeliProvider: func(config goneli.Config, barrier goneli.Barrier) (goneli.Neli, error) {\n\t\t\tn, err := goneli.NewMock(goneli.MockConfig{\n\t\t\t\tMinPollInterval: config.MinPollInterval,\n\t\t\t}, barrier)\n\t\t\tif err != nil {\n\t\t\t\tpanic(err)\n\t\t\t}\n\t\t\tneli = n\n\t\t\treturn n, nil\n\t\t},\n\t\tKafkaProducerProvider: func(conf *KafkaConfigMap) (KafkaProducer, error) {\n\t\t\tprod := &prodMock{}\n\t\t\tprod.fillDefaults()\n\t\t\tf.producerMockSetup(prod)\n\t\t\treturn prod, nil\n\t\t},\n\t}\n\tconfig.Scribe.SetEnabled(scribe.All)\n\n\treturn m, db, &neli, config\n}\n\ntype testEventHandler struct {\n\tmutex  sync.Mutex\n\tevents []Event\n}\n\nfunc (c *testEventHandler) handler() EventHandler {\n\treturn func(e Event) {\n\t\tc.mutex.Lock()\n\t\tdefer c.mutex.Unlock()\n\t\tc.events = append(c.events, e)\n\t}\n}\n\nfunc (c *testEventHandler) list() []Event {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\teventsCopy := make([]Event, len(c.events))\n\tcopy(eventsCopy, c.events)\n\treturn eventsCopy\n}\n\nfunc (c *testEventHandler) length() int {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\treturn len(c.events)\n}\n\nfunc TestCorrectInitialisation(t *testing.T) {\n\t_, db, neli, config := fixtures{}.create()\n\n\tvar givenDataSource string\n\tvar givenOutboxTable string\n\n\tconfig.DatabaseBindingProvider = func(dataSource string, outboxTable string) (DatabaseBinding, error) {\n\t\tgivenDataSource = dataSource\n\t\tgivenOutboxTable = outboxTable\n\t\treturn db, nil\n\t}\n\tconfig.DataSource = \"test data source\"\n\tconfig.OutboxTable = \"test table name\"\n\tconfig.LeaderGroupID = \"test leader group ID\"\n\tconfig.BaseKafkaConfig = KafkaConfigMap{\n\t\t\"bootstrap.servers\": \"localhost:9092\",\n\t}\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\tassert.Equal(t, Created, h.State())\n\tassertNoError(t, h.Start)\n\tassert.Equal(t, Running, h.State())\n\n\tassert.Equal(t, config.DataSource, givenDataSource)\n\tassert.Equal(t, config.OutboxTable, givenOutboxTable)\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n\tassert.Equal(t, Stopped, h.State())\n\n\tassert.Equal(t, 1, db.c.Dispose.GetInt())\n\tassert.Equal(t, goneli.Closed, (*neli).State())\n}\n\nfunc TestConfigError(t *testing.T) {\n\th, err := New(Config{\n\t\tLimits: Limits{\n\t\t\tIOErrorBackoff: Duration(-1),\n\t\t},\n\t})\n\tassert.Nil(t, h)\n\tassert.NotNil(t, err)\n}\n\nfunc TestErrorDuringDBInitialisation(t *testing.T) {\n\t_, _, _, config := fixtures{}.create()\n\n\tconfig.DatabaseBindingProvider = func(dataSource string, outboxTable string) (DatabaseBinding, error) {\n\t\treturn nil, check.ErrSimulated\n\t}\n\th, err := New(config)\n\trequire.Nil(t, err)\n\n\tassertErrorContaining(t, h.Start, \"simulated\")\n\tassert.Equal(t, Created, h.State())\n}\n\nfunc TestErrorDuringNeliInitialisation(t *testing.T) {\n\t_, db, _, config := fixtures{}.create()\n\n\tconfig.NeliProvider = func(config goneli.Config, barrier goneli.Barrier) (goneli.Neli, error) {\n\t\treturn nil, check.ErrSimulated\n\t}\n\th, err := New(config)\n\trequire.Nil(t, err)\n\n\tassertErrorContaining(t, h.Start, \"simulated\")\n\tassert.Equal(t, Created, h.State())\n\tassert.Equal(t, 1, db.c.Dispose.GetInt())\n}\n\nfunc TestErrorDuringProducerConfiguration(t *testing.T) {\n\t_, _, _, config := fixtures{}.create()\n\n\tconfig.ProducerKafkaConfig = KafkaConfigMap{\n\t\t\"enable.idempotence\": false,\n\t}\n\th, err := New(config)\n\trequire.NotNil(t, err)\n\tassert.Contains(t, err.Error(), \"cannot override configuration 'enable.idempotence'\")\n\tassert.Nil(t, h)\n}\n\nfunc TestErrorDuringProducerInitialisation(t *testing.T) {\n\tm, db, neli, config := fixtures{}.create()\n\n\tconfig.KafkaProducerProvider = func(conf *KafkaConfigMap) (KafkaProducer, error) {\n\t\treturn nil, check.ErrSimulated\n\t}\n\th, err := New(config)\n\trequire.Nil(t, err)\n\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership and wait until leader.\n\t(*neli).AcquireLeader()\n\twait(t).Until(h.IsLeader)\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tassert.Equal(t, 1, eh.length())\n\t})\n\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Error)).\n\t\tHaving(scribe.MessageEqual(\"Caught panic in send cell: simulated\")).\n\t\tPasses(scribe.Count(1)))\n\n\t// Having detected a panic, it should self-destruct\n\tassertErrorContaining(t, h.Await, \"simulated\")\n\n\tassert.Equal(t, 1, db.c.Dispose.GetInt())\n\tassert.Equal(t, (*neli).State(), goneli.Closed)\n}\n\nfunc TestUncaughtPanic_backgroundPoller(t *testing.T) {\n\tm, _, neli, config := fixtures{}.create()\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t(*neli).PulseError(check.ErrSimulated)\n\n\t// Having detected a panic, it should self-destruct\n\tassertErrorContaining(t, h.Await, \"simulated\")\n\tassert.Equal(t, 0, eh.length())\n\n\tt.Log(m.Entries().List())\n\tm.Entries().\n\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\tHaving(scribe.MessageEqual(\"Starting background poller\")).\n\t\tAssert(t, scribe.Count(1))\n\n\tm.Entries().\n\t\tHaving(scribe.LogLevel(scribe.Error)).\n\t\tHaving(scribe.MessageEqual(\"Caught panic in background poller: simulated\")).\n\t\tAssert(t, scribe.Count(1))\n}\n\nfunc TestUncaughtPanic_backgroundDeliveryHandler(t *testing.T) {\n\tprodRef := concurrent.NewAtomicReference()\n\tm, db, neli, config := fixtures{producerMockSetup: func(prodMock *prodMock) {\n\t\tprodRef.Set(prodMock)\n\t}}.create()\n\n\tdb.f.Reset = func(m *dbMock, id int64) (bool, error) {\n\t\tpanic(check.ErrSimulated)\n\t}\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership and await\n\t(*neli).AcquireLeader()\n\twait(t).Until(h.IsLeader)\n\n\t// Feed a delivery event to cause a DB reset query\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\tprodRef.Get().(*prodMock).events <- message(OutboxRecord{ID: 777}, check.ErrSimulated)\n\n\t// Having detected a panic, it should self-destruct\n\tassertErrorContaining(t, h.Await, \"simulated\")\n\n\tt.Log(m.Entries().List())\n\tm.Entries().\n\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\tHaving(scribe.MessageEqual(\"Starting background delivery handler\")).\n\t\tAssert(t, scribe.Count(1))\n\n\tm.Entries().\n\t\tHaving(scribe.LogLevel(scribe.Error)).\n\t\tHaving(scribe.MessageEqual(\"Caught panic in background delivery handler: simulated\")).\n\t\tAssert(t, scribe.Count(1))\n}\n\nfunc TestBasicLeaderElectionAndRevocation(t *testing.T) {\n\tm, _, neli, config := fixtures{}.create()\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Starts off in a non-leader state\n\tassert.Equal(t, false, h.IsLeader())\n\tassert.Nil(t, h.LeaderID())\n\n\t// Assign leadership via the rebalance listener and wait for the assignment to take effect\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isTrue(h.IsLeader))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\tHaving(scribe.MessageEqual(fmt.Sprintf(\"Elected as leader, ID: %s\", h.LeaderID()))).\n\t\tPasses(scribe.Count(1)))\n\tm.Reset()\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tif assert.Equal(t, 1, eh.length()) {\n\t\t\te := eh.list()[0].(LeaderAcquired)\n\t\t\tassert.Equal(t, e.LeaderID(), *(h.LeaderID()))\n\t\t}\n\t})\n\n\t// Revoke leadership via the rebalance listener and await its effect\n\t(*neli).RevokeLeader()\n\twait(t).UntilAsserted(isFalse(h.IsLeader))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\tHaving(scribe.MessageEqual(\"Lost leader status\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Shutting down send battery\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Send battery terminated\")).\n\t\tPasses(scribe.Count(1)))\n\tm.Reset()\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tif assert.Equal(t, 2, eh.length()) {\n\t\t\t_ = eh.list()[1].(LeaderRevoked)\n\t\t}\n\t})\n\n\t// Reassign leadership via the rebalance listener and wait for the assignment to take effect\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isTrue(h.IsLeader))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\tHaving(scribe.MessageEqual(fmt.Sprintf(\"Elected as leader, ID: %s\", h.LeaderID()))).\n\t\tPasses(scribe.Count(1)))\n\tm.Reset()\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tif assert.Equal(t, 3, eh.length()) {\n\t\t\te := eh.list()[2].(LeaderAcquired)\n\t\t\tassert.Equal(t, e.LeaderID(), *(h.LeaderID()))\n\t\t}\n\t})\n\n\t// Fence the leader\n\t(*neli).FenceLeader()\n\twait(t).UntilAsserted(isFalse(h.IsLeader))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageEqual(\"Leader fenced\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Shutting down send battery\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Send battery terminated\")).\n\t\tPasses(scribe.Count(1)))\n\tm.Reset()\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tif assert.Equal(t, 4, eh.length()) {\n\t\t\t_ = eh.list()[3].(LeaderFenced)\n\t\t}\n\t})\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestMetrics(t *testing.T) {\n\tprodRef := concurrent.NewAtomicReference()\n\tm, _, neli, config := fixtures{producerMockSetup: func(prodMock *prodMock) {\n\t\tprodRef.Set(prodMock)\n\t}}.create()\n\tconfig.Limits.MinMetricsInterval = Duration(1 * time.Millisecond)\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership and wait for the leadership event\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tassert.Equal(t, 1, eh.length())\n\t})\n\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tbacklogRecords := generateRecords(1, 0)\n\t\tdeliverAll(backlogRecords, nil, prodRef.Get().(*prodMock).events)\n\t\tif assert.GreaterOrEqual(t, eh.length(), 2) {\n\t\t\te := eh.list()[1].(MeterRead)\n\t\t\tif stats := e.Stats(); assert.NotNil(t, stats) {\n\t\t\t\tassert.Equal(t, stats.Name, \"throughput\")\n\t\t\t}\n\t\t}\n\t})\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageContaining(\"throughput\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestHandleNonMessageEvent(t *testing.T) {\n\tprodRef := concurrent.NewAtomicReference()\n\tm, _, neli, config := fixtures{producerMockSetup: func(prodMock *prodMock) {\n\t\tprodRef.Set(prodMock)\n\t}}.create()\n\tconfig.Limits.MinMetricsInterval = Duration(1 * time.Millisecond)\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership and wait for the leadership event\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\tprod := prodRef.Get().(*prodMock)\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tassert.Equal(t, 1, eh.length())\n\t})\n\n\tprod.events <- kafka.NewError(kafka.ErrAllBrokersDown, \"brokers down\", false)\n\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\tHaving(scribe.MessageContaining(\"Observed event: brokers down\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestThrottleKeys(t *testing.T) {\n\tprod := concurrent.NewAtomicReference()\n\tlastPublished := concurrent.NewAtomicReference()\n\tm, db, neli, config := fixtures{producerMockSetup: func(pm *prodMock) {\n\t\tpm.f.Produce = func(m *prodMock, msg *kafka.Message, deliveryChan chan kafka.Event) error {\n\t\t\tlastPublished.Set(msg)\n\t\t\treturn nil\n\t\t}\n\t\tprod.Set(pm)\n\t}}.create()\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Starts off with no backlog.\n\tassert.Equal(t, 0, h.InFlightRecords())\n\n\t// Induce leadership and wait until a producer has been spawned.\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isNotNil(prod.Get))\n\n\tconst backlog = 10\n\tbacklogRecords := generateCyclicKeyedRecords(1, backlog, 0)\n\tdb.markedRecords <- backlogRecords\n\n\t// Even though we pushed several records through, they all had a common key, so only one should\n\t// should be published.\n\twait(t).UntilAsserted(intEqual(1, h.InFlightRecords))\n\tassert.True(t, h.IsLeader()) // should definitely be leader by now\n\twait(t).UntilAsserted(intEqual(1, prod.Get().(*prodMock).c.Produce.GetInt))\n\tmsg := lastPublished.Get().(*kafka.Message)\n\tassert.Equal(t, msg.Value, []byte(*backlogRecords[0].KafkaValue))\n\tassert.ElementsMatch(t, h.InFlightRecordKeys(), []string{backlogRecords[0].KafkaKey})\n\n\t// Drain the in-flight record... another one should then be published.\n\tdeliverAll(backlogRecords[0:1], nil, prod.Get().(*prodMock).events)\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tmsg := lastPublished.Get()\n\t\tif assert.NotNil(t, msg) {\n\t\t\tassert.Equal(t, msg.(*kafka.Message).Value, []byte(*backlogRecords[1].KafkaValue))\n\t\t}\n\t})\n\n\t// Drain the backlog by feeding in delivery confirmations one at a time.\n\tfor i := 1; i < backlog; i++ {\n\t\twait(t).UntilAsserted(intEqual(1, h.InFlightRecords))\n\t\twait(t).UntilAsserted(func(t check.Tester) {\n\t\t\tmsg := lastPublished.Get()\n\t\t\tif assert.NotNil(t, msg) {\n\t\t\t\tassert.Equal(t, []byte(*backlogRecords[i].KafkaValue), msg.(*kafka.Message).Value)\n\t\t\t}\n\t\t})\n\t\tdeliverAll(backlogRecords[i:i+1], nil, prod.Get().(*prodMock).events)\n\t}\n\n\t// Revoke leadership...\n\t(*neli).RevokeLeader()\n\n\t// Wait for the backlog to drain... leadership status will be cleared when done.\n\twait(t).Until(check.Not(h.IsLeader))\n\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Shutting down send battery\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Send battery terminated\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\tHaving(scribe.MessageContaining(\"Lost leader status\")).\n\t\tPasses(scribe.Count(1)))\n\n\tassert.Equal(t, backlog, db.c.Purge.GetInt())\n\tassert.Equal(t, backlog, prod.Get().(*prodMock).c.Produce.GetInt())\n\tassert.Equal(t, 0, h.InFlightRecords())\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestPollDeadlineExceeded(t *testing.T) {\n\tm, db, neli, config := fixtures{}.create()\n\n\tconfig.Limits.DrainInterval = Duration(time.Millisecond)\n\tconfig.Limits.MaxPollInterval = Duration(time.Millisecond)\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Starts off with no backlog.\n\tassert.Equal(t, 0, h.InFlightRecords())\n\n\t// Induce leadership and wait until a producer has been spawned.\n\t(*neli).AcquireLeader()\n\n\tdb.markedRecords <- generateCyclicKeyedRecords(1, 2, 0)\n\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Exceeded poll deadline\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Shutting down send battery\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Send battery terminated\")).\n\t\tPasses(scribe.Count(1)))\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestQueueLimitExceeded(t *testing.T) {\n\tm, db, neli, config := fixtures{}.create()\n\n\tconfig.Limits.DrainInterval = Duration(time.Millisecond)\n\tconfig.Limits.QueueTimeout = Duration(time.Millisecond)\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Starts off with no backlog.\n\tassert.Equal(t, 0, h.InFlightRecords())\n\n\t// Induce leadership and wait until a producer has been spawned.\n\t(*neli).AcquireLeader()\n\n\tdb.markedRecords <- generateCyclicKeyedRecords(1, 2, 0)\n\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Exceeded message queueing deadline\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Shutting down send battery\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Send battery terminated\")).\n\t\tPasses(scribe.Count(1)))\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestDrainInFlightRecords_failedDelivery(t *testing.T) {\n\tprod := concurrent.NewAtomicReference()\n\tlastPublished := concurrent.NewAtomicReference()\n\tm, db, neli, config := fixtures{producerMockSetup: func(pm *prodMock) {\n\t\tpm.f.Produce = func(m *prodMock, msg *kafka.Message, deliveryChan chan kafka.Event) error {\n\t\t\tlastPublished.Set(msg)\n\t\t\treturn nil\n\t\t}\n\t\tprod.Set(pm)\n\t}}.create()\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\tassertNoError(t, h.Start)\n\n\t// Starts off with no backlog\n\tassert.Equal(t, 0, h.InFlightRecords())\n\n\t// Induce leadership\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isNotNil(prod.Get))\n\n\t// Generate a backlog\n\tconst backlog = 10\n\tbacklogRecords := generateRecords(backlog, 0)\n\tdb.markedRecords <- backlogRecords\n\n\t// Wait for the backlog to register.\n\twait(t).UntilAsserted(intEqual(backlog, h.InFlightRecords))\n\twait(t).UntilAsserted(intEqual(backlog, prod.Get().(*prodMock).c.Produce.GetInt))\n\tassert.True(t, h.IsLeader()) // should be leader by now\n\n\t// Revoke leadership... this will start the backlog drain.\n\t(*neli).RevokeLeader()\n\n\twait(t).Until(check.Not(h.IsLeader))\n\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Shutting down send battery\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageEqual(\"Send battery terminated\")).\n\t\tPasses(scribe.Count(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\tHaving(scribe.MessageContaining(\"Lost leader status\")).\n\t\tPasses(scribe.Count(1)))\n\tassert.Equal(t, h.InFlightRecords(), 0)\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestErrorInMarkQuery(t *testing.T) {\n\tm, db, neli, config := fixtures{}.create()\n\n\tdb.f.Mark = func(m *dbMock, leaderID uuid.UUID, limit int) ([]OutboxRecord, error) {\n\t\treturn nil, check.ErrSimulated\n\t}\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership\n\t(*neli).AcquireLeader()\n\n\t// Wait for the error to be logged\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Error executing mark query\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Debug)).\n\t\tHaving(scribe.MessageContaining(\"Remark requested\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\tassert.Equal(t, Running, h.State())\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestErrorInProduce(t *testing.T) {\n\tprodRef := concurrent.NewAtomicReference()\n\tproduceError := concurrent.NewAtomicCounter(1) // 1=true, 0=false\n\tm, db, neli, config := fixtures{producerMockSetup: func(pm *prodMock) {\n\t\tpm.f.Produce = func(m *prodMock, msg *kafka.Message, deliveryChan chan kafka.Event) error {\n\t\t\tif produceError.Get() == 1 {\n\t\t\t\treturn kafka.NewError(kafka.ErrFail, \"simulated\", false)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\t\tprodRef.Set(pm)\n\t}}.create()\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\tprod := prodRef.Get().(*prodMock)\n\tprodRef.Set(nil)\n\n\t// Mark one record\n\trecords := generateRecords(1, 0)\n\tdb.markedRecords <- records\n\n\t// Wait for the error to be logged\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Error publishing record\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Refreshed leader ID\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\tm.Reset()\n\tassert.Equal(t, Running, h.State())\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\tprod = prodRef.Get().(*prodMock)\n\n\t// Resume normal production... error should clear but the record count should not go up, as\n\t// there can only be one in-flight record for a given key\n\tproduceError.Set(0)\n\tdb.markedRecords <- records\n\twait(t).UntilAsserted(intEqual(1, h.InFlightRecords))\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tassert.ElementsMatch(t, h.InFlightRecordKeys(), []string{records[0].KafkaKey})\n\t})\n\n\tif assert.GreaterOrEqual(t, eh.length(), 2) {\n\t\t_ = eh.list()[0].(LeaderAcquired)\n\t\t_ = eh.list()[1].(LeaderRefreshed)\n\t}\n\n\t// Feed successful delivery report for the first record\n\tprod.events <- message(records[0], nil)\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\n// Tests remarking by feeding through two records for the same key, forcing them to come through in sequence.\n// The first is published, but fails upon delivery, which raises the forceRemark flag.\n// As the second on is processed, the forceRemark flag raised by the first should be spotted, and a leader\n// refresh should occur.\nfunc TestReset(t *testing.T) {\n\tprodRef := concurrent.NewAtomicReference()\n\tlastPublished := concurrent.NewAtomicReference()\n\tm, db, neli, config := fixtures{producerMockSetup: func(pm *prodMock) {\n\t\tpm.f.Produce = func(m *prodMock, msg *kafka.Message, deliveryChan chan kafka.Event) error {\n\t\t\tlastPublished.Set(msg)\n\t\t\treturn nil\n\t\t}\n\t\tprodRef.Set(pm)\n\t}}.create()\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\teh := &testEventHandler{}\n\th.SetEventHandler(eh.handler())\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\tprod := prodRef.Get().(*prodMock)\n\n\t// Mark two records for the same key\n\trecords := generateCyclicKeyedRecords(1, 2, 0)\n\tdb.markedRecords <- records\n\n\t// Wait for the backlog to register\n\twait(t).UntilAsserted(intEqual(1, h.InFlightRecords))\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tif msg := lastPublished.Get(); assert.NotNil(t, msg) {\n\t\t\tassert.Equal(t, *records[0].KafkaValue, string(msg.(*kafka.Message).Value))\n\t\t}\n\t})\n\n\t// Feed an error\n\tprod.events <- message(records[0], check.ErrSimulated)\n\n\t// Wait for the error to be logged\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Delivery failed\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Refreshed leader ID\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\tm.Reset()\n\tassert.Equal(t, Running, h.State())\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestErrorInPurgeAndResetQueries(t *testing.T) {\n\tprodRef := concurrent.NewAtomicReference()\n\tm, db, neli, config := fixtures{producerMockSetup: func(pm *prodMock) {\n\t\tprodRef.Set(pm)\n\t}}.create()\n\n\trecords := generateRecords(2, 0)\n\tpurgeError := concurrent.NewAtomicCounter(1) // 1=true, 0=false\n\tresetError := concurrent.NewAtomicCounter(1) // 1=true, 0=false\n\tdb.f.Mark = func(m *dbMock, leaderID uuid.UUID, limit int) ([]OutboxRecord, error) {\n\t\tif db.c.Mark.Get() == 0 {\n\t\t\treturn records, nil\n\t\t}\n\t\treturn []OutboxRecord{}, nil\n\t}\n\tdb.f.Purge = func(m *dbMock, id int64) (bool, error) {\n\t\tif purgeError.Get() == 1 {\n\t\t\treturn false, check.ErrSimulated\n\t\t}\n\t\treturn true, nil\n\t}\n\tdb.f.Reset = func(m *dbMock, id int64) (bool, error) {\n\t\tif resetError.Get() == 1 {\n\t\t\treturn false, check.ErrSimulated\n\t\t}\n\t\treturn true, nil\n\t}\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership and await its registration\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\tprod := prodRef.Get().(*prodMock)\n\n\twait(t).UntilAsserted(isTrue(h.IsLeader))\n\twait(t).UntilAsserted(intEqual(2, h.InFlightRecords))\n\n\t// Feed successful delivery report for the first record\n\tprod.events <- message(records[0], nil)\n\n\t// Wait for the error to be logged\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Error executing purge query for record\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\tm.Reset()\n\tassert.Equal(t, Running, h.State())\n\tassert.Equal(t, 2, h.InFlightRecords())\n\n\t// Resume normal production... error should clear\n\tpurgeError.Set(0)\n\twait(t).UntilAsserted(intEqual(1, h.InFlightRecords))\n\n\t// Feed failed delivery report for the first record\n\tprodRef.Get().(*prodMock).events <- message(records[1], kafka.NewError(kafka.ErrFail, \"simulated\", false))\n\n\t// Wait for the error to be logged\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Error executing reset query for record\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\tm.Reset()\n\tassert.Equal(t, Running, h.State())\n\tassert.Equal(t, 1, h.InFlightRecords())\n\n\t// Resume normal production... error should clear\n\tresetError.Set(0)\n\twait(t).UntilAsserted(intEqual(0, h.InFlightRecords))\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestIncompletePurgeAndResetQueries(t *testing.T) {\n\tprodRef := concurrent.NewAtomicReference()\n\tm, db, neli, config := fixtures{producerMockSetup: func(pm *prodMock) {\n\t\tprodRef.Set(pm)\n\t}}.create()\n\n\trecords := generateRecords(2, 0)\n\tdb.f.Mark = func(m *dbMock, leaderID uuid.UUID, limit int) ([]OutboxRecord, error) {\n\t\tif db.c.Mark.Get() == 0 {\n\t\t\treturn records, nil\n\t\t}\n\t\treturn []OutboxRecord{}, nil\n\t}\n\tdb.f.Purge = func(m *dbMock, id int64) (bool, error) {\n\t\treturn false, nil\n\t}\n\tdb.f.Reset = func(m *dbMock, id int64) (bool, error) {\n\t\treturn false, nil\n\t}\n\n\th, err := New(config)\n\trequire.Nil(t, err)\n\tassertNoError(t, h.Start)\n\n\t// Induce leadership and await its registration\n\t(*neli).AcquireLeader()\n\twait(t).UntilAsserted(isTrue(h.IsLeader))\n\twait(t).UntilAsserted(intEqual(2, h.InFlightRecords))\n\twait(t).UntilAsserted(isNotNil(prodRef.Get))\n\tprod := prodRef.Get().(*prodMock)\n\n\t// Feed successful delivery report for the first record\n\tprod.events <- message(records[0], nil)\n\n\t// Wait for the warning to be logged\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Did not purge record\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\tm.Reset()\n\tassert.Equal(t, Running, h.State())\n\twait(t).UntilAsserted(intEqual(1, h.InFlightRecords))\n\n\t// Feed failed delivery report for the first record\n\tprod.events <- message(records[1], kafka.NewError(kafka.ErrFail, \"simulated\", false))\n\n\t// Wait for the warning to be logged\n\twait(t).UntilAsserted(m.ContainsEntries().\n\t\tHaving(scribe.LogLevel(scribe.Warn)).\n\t\tHaving(scribe.MessageContaining(\"Did not reset record\")).\n\t\tPasses(scribe.CountAtLeast(1)))\n\tm.Reset()\n\tassert.Equal(t, Running, h.State())\n\twait(t).UntilAsserted(intEqual(0, h.InFlightRecords))\n\n\th.Stop()\n\tassert.Nil(t, h.Await())\n}\n\nfunc TestEnsureState(t *testing.T) {\n\tcheck.ThatPanicsAsExpected(t, check.ErrorContaining(\"must not be false\"), func() {\n\t\tensureState(false, \"must not be false\")\n\t})\n\n\tensureState(true, \"must not be false\")\n}\n\nfunc intEqual(expected int, intSupplier func() int) func(t check.Tester) {\n\treturn func(t check.Tester) {\n\t\tassert.Equal(t, expected, intSupplier())\n\t}\n}\n\nfunc lengthEqual(expected int, sliceSupplier func() []string) func(t check.Tester) {\n\treturn func(t check.Tester) {\n\t\tassert.Len(t, sliceSupplier(), expected)\n\t}\n}\n\nfunc atLeast(min int, f func() int) check.Assertion {\n\treturn func(t check.Tester) {\n\t\tassert.GreaterOrEqual(t, f(), min)\n\t}\n}\n\nfunc isTrue(f func() bool) check.Assertion {\n\treturn func(t check.Tester) {\n\t\tassert.True(t, f())\n\t}\n}\n\nfunc isFalse(f func() bool) check.Assertion {\n\treturn func(t check.Tester) {\n\t\tassert.False(t, f())\n\t}\n}\n\nfunc isNotNil(f func() interface{}) check.Assertion {\n\treturn func(t check.Tester) {\n\t\tassert.NotNil(t, f())\n\t}\n}\n\nfunc assertErrorContaining(t *testing.T, f func() error, substr string) {\n\terr := f()\n\tif assert.NotNil(t, err) {\n\t\tassert.Contains(t, err.Error(), substr)\n\t}\n}\n\nfunc assertNoError(t *testing.T, f func() error) {\n\terr := f()\n\trequire.Nil(t, err)\n}\n\nfunc newTimedOutError() kafka.Error {\n\treturn kafka.NewError(kafka.ErrTimedOut, \"Timed out\", false)\n}\n\nfunc generatePartitions(indexes ...int32) []kafka.TopicPartition {\n\tparts := make([]kafka.TopicPartition, len(indexes))\n\tfor i, index := range indexes {\n\t\tparts[i] = kafka.TopicPartition{Partition: index}\n\t}\n\treturn parts\n}\n\nfunc generateRecords(numRecords int, startID int) []OutboxRecord {\n\trecords := make([]OutboxRecord, numRecords)\n\tnow := time.Now()\n\tfor i := 0; i < numRecords; i++ {\n\t\trecords[i] = OutboxRecord{\n\t\t\tID:         int64(startID + i),\n\t\t\tCreateTime: now,\n\t\t\tKafkaTopic: \"test_topic\",\n\t\t\tKafkaKey:   fmt.Sprintf(\"key-%x\", i),\n\t\t\tKafkaValue: String(fmt.Sprintf(\"value-%x\", i)),\n\t\t\tKafkaHeaders: KafkaHeaders{\n\t\t\t\tKafkaHeader{Key: \"ID\", Value: strconv.FormatInt(int64(startID+i), 10)},\n\t\t\t},\n\t\t}\n\t}\n\treturn records\n}\n\nfunc generateCyclicKeyedRecords(numKeys int, numRecords int, startID int) []OutboxRecord {\n\trecords := make([]OutboxRecord, numRecords)\n\tnow := time.Now()\n\tfor i := 0; i < numRecords; i++ {\n\t\trecords[i] = OutboxRecord{\n\t\t\tID:         int64(startID + i),\n\t\t\tCreateTime: now,\n\t\t\tKafkaTopic: \"test_topic\",\n\t\t\tKafkaKey:   fmt.Sprintf(\"key-%x\", i%numKeys),\n\t\t\tKafkaValue: String(fmt.Sprintf(\"value-%x\", i)),\n\t\t\tKafkaHeaders: KafkaHeaders{\n\t\t\t\tKafkaHeader{Key: \"ID\", Value: strconv.FormatInt(int64(startID+i), 10)},\n\t\t\t},\n\t\t}\n\t}\n\treturn records\n}\n\nfunc message(record OutboxRecord, err error) *kafka.Message {\n\treturn &kafka.Message{\n\t\tTopicPartition: kafka.TopicPartition{Topic: &record.KafkaTopic, Error: err},\n\t\tKey:            []byte(record.KafkaKey),\n\t\tValue:          stringPointerToByteArray(record.KafkaValue),\n\t\tTimestamp:      record.CreateTime,\n\t\tTimestampType:  kafka.TimestampCreateTime,\n\t\tOpaque:         record,\n\t}\n}\n\nfunc deliverAll(records []OutboxRecord, err error, events chan kafka.Event) {\n\tfor _, record := range records {\n\t\tevents <- message(record, err)\n\t}\n}\n"
  },
  {
    "path": "int/faulty_kafka_test.go",
    "content": "package int\n\nimport (\n\t\"github.com/obsidiandynamics/goharvest\"\n\t\"github.com/obsidiandynamics/libstdgo/fault\"\n\t\"gopkg.in/confluentinc/confluent-kafka-go.v1/kafka\"\n)\n\ntype ProducerFaultSpecs struct {\n\tOnProduce  fault.Spec\n\tOnDelivery fault.Spec\n}\n\nfunc (specs ProducerFaultSpecs) build() producerFaults {\n\treturn producerFaults{\n\t\tonProduce:  specs.OnProduce.Build(),\n\t\tonDelivery: specs.OnDelivery.Build(),\n\t}\n}\n\nfunc FaultyKafkaProducerProvider(realProvider goharvest.KafkaProducerProvider, specs ProducerFaultSpecs) goharvest.KafkaProducerProvider {\n\treturn func(conf *goharvest.KafkaConfigMap) (goharvest.KafkaProducer, error) {\n\t\treal, err := realProvider(conf)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn newFaultyProducer(real, specs.build()), nil\n\t}\n}\n\ntype producerFaults struct {\n\tonProduce  fault.Fault\n\tonDelivery fault.Fault\n}\n\ntype faultyProducer struct {\n\treal   goharvest.KafkaProducer\n\tfaults producerFaults\n\tevents chan kafka.Event\n}\n\nfunc newFaultyProducer(real goharvest.KafkaProducer, faults producerFaults) *faultyProducer {\n\tf := &faultyProducer{\n\t\treal:   real,\n\t\tfaults: faults,\n\t\tevents: make(chan kafka.Event),\n\t}\n\n\tgo func() {\n\t\tdefer close(f.events)\n\n\t\tfor e := range real.Events() {\n\t\t\tswitch ev := e.(type) {\n\t\t\tcase *kafka.Message:\n\t\t\t\tif ev.TopicPartition.Error != nil {\n\t\t\t\t\tf.events <- e\n\t\t\t\t} else if err := f.faults.onDelivery.Try(); err != nil {\n\t\t\t\t\trewrittenMessage := *ev\n\t\t\t\t\trewrittenMessage.TopicPartition = kafka.TopicPartition{\n\t\t\t\t\t\tTopic:     ev.TopicPartition.Topic,\n\t\t\t\t\t\tPartition: ev.TopicPartition.Partition,\n\t\t\t\t\t\tOffset:    ev.TopicPartition.Offset,\n\t\t\t\t\t\tMetadata:  ev.TopicPartition.Metadata,\n\t\t\t\t\t\tError:     err,\n\t\t\t\t\t}\n\t\t\t\t\tf.events <- &rewrittenMessage\n\t\t\t\t} else {\n\t\t\t\t\tf.events <- e\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\tf.events <- e\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn f\n}\n\nfunc (f *faultyProducer) Events() chan kafka.Event {\n\treturn f.events\n}\n\nfunc (f *faultyProducer) Produce(msg *kafka.Message, deliveryChan chan kafka.Event) error {\n\tif err := f.faults.onProduce.Try(); err != nil {\n\t\treturn err\n\t}\n\treturn f.real.Produce(msg, deliveryChan)\n}\n\nfunc (f *faultyProducer) Close() {\n\tf.real.Close()\n}\n"
  },
  {
    "path": "int/harvest_int_test.go",
    "content": "package int\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\t\"strconv\"\n\t\"strings\"\n\t\"syscall\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/google/uuid\"\n\t. \"github.com/obsidiandynamics/goharvest\"\n\t\"github.com/obsidiandynamics/goharvest/stasher\"\n\t\"github.com/obsidiandynamics/libstdgo/check\"\n\t\"github.com/obsidiandynamics/libstdgo/concurrent\"\n\t\"github.com/obsidiandynamics/libstdgo/diags\"\n\t\"github.com/obsidiandynamics/libstdgo/fault\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe/overlog\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n\t\"gopkg.in/confluentinc/confluent-kafka-go.v1/kafka\"\n)\n\ntype externals struct {\n\tcons  *kafka.Consumer\n\tadmin *kafka.AdminClient\n\tdb    *sql.DB\n}\n\nconst (\n\tkafkaNamespace                = \"goharvest_test\"\n\ttopic                         = kafkaNamespace + \".topic\"\n\tpartitions                    = 10\n\tdbSchema                      = \"goharvest_test\"\n\toutboxTable                   = dbSchema + \".outbox\"\n\tleaderTopic                   = kafkaNamespace + \".neli\"\n\tleaderGroupID                 = kafkaNamespace + \".group\"\n\treceiverGroupID               = kafkaNamespace + \".receiver_group\"\n\tbootstrapServers              = \"localhost:9092\"\n\tdataSource                    = \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\"\n\tgenerateInterval              = 5 * time.Millisecond\n\tgenerateRecordsPerTxn         = 20\n\tgenerateMinRecords            = 100\n\tgenerateUniqueKeys            = 10\n\treceiverPollDuration          = 500 * time.Millisecond\n\treceiverNoMessagesWarningTime = 10 * time.Second\n\twaitTimeout                   = 90 * time.Second\n)\n\nvar logger = overlog.New(overlog.StandardFormat())\nvar scr = scribe.New(overlog.Bind(logger))\n\nfunc openExternals() externals {\n\tcons, err := kafka.NewConsumer(&kafka.ConfigMap{\n\t\t\"bootstrap.servers\":  bootstrapServers,\n\t\t\"group.id\":           receiverGroupID,\n\t\t\"enable.auto.commit\": true,\n\t\t\"auto.offset.reset\":  \"earliest\",\n\t\t\"socket.timeout.ms\":  10000,\n\t\t// \"debug\":              \"all\",\n\t})\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tadmin, err := kafka.NewAdminClientFromConsumer(cons)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tfor {\n\t\tresult, err := admin.CreateTopics(context.Background(), []kafka.TopicSpecification{\n\t\t\t{\n\t\t\t\tTopic:             topic,\n\t\t\t\tNumPartitions:     partitions,\n\t\t\t\tReplicationFactor: 1,\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\tif isFatalError(err) {\n\t\t\t\tpanic(err)\n\t\t\t} else {\n\t\t\t\t// Allow for timeouts and other non-fatal errors.\n\t\t\t\tscr.W()(\"Non-fatal error creating topic: %v\", err)\n\t\t\t}\n\t\t} else {\n\t\t\tif result[0].Error.Code() == kafka.ErrTopicAlreadyExists {\n\t\t\t\tscr.I()(\"Topic %s already exists\", topic)\n\t\t\t} else if result[0].Error.Code() != kafka.ErrNoError {\n\t\t\t\tpanic(result[0].Error)\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\n\tdb, err := sql.Open(\"postgres\", dataSource)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tconst ddlTemplate = `\n\t\tCREATE SCHEMA IF NOT EXISTS %s;\n\t\tDROP TABLE IF EXISTS %s;\n\t\tCREATE TABLE %s (\n\t\t\tid                  BIGSERIAL PRIMARY KEY,\n\t\t\tcreate_time         TIMESTAMP WITH TIME ZONE NOT NULL,\n\t\t\tkafka_topic         VARCHAR(249) NOT NULL,\n\t\t\tkafka_key           VARCHAR(5) NOT NULL,\n\t\t\tkafka_value         VARCHAR(50),\n\t\t\tkafka_header_keys   TEXT[] NOT NULL,\n\t\t\tkafka_header_values TEXT[] NOT NULL,\n\t\t\tleader_id           UUID\n\t\t)\n\t`\n\t_, err = db.Exec(fmt.Sprintf(ddlTemplate, dbSchema, outboxTable, outboxTable))\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn externals{cons, admin, db}\n}\n\nfunc (x *externals) close() {\n\tx.cons.Close()\n\tx.db.Close()\n\tx.admin.Close()\n}\n\nfunc wait(t check.Tester) check.Timesert {\n\treturn check.Wait(t, waitTimeout)\n}\n\nfunc TestOneNode_withFailures(t *testing.T) {\n\ttest(t, 1, 5*time.Second, ProducerFaultSpecs{\n\t\tOnProduce:  fault.Spec{Cnt: fault.Random(0.02), Err: check.ErrSimulated},\n\t\tOnDelivery: fault.Spec{Cnt: fault.Random(0.02), Err: check.ErrSimulated},\n\t})\n}\n\nfunc TestFourNodes_withFailures(t *testing.T) {\n\ttest(t, 4, 5*time.Second, ProducerFaultSpecs{\n\t\tOnProduce:  fault.Spec{Cnt: fault.Random(0.02), Err: check.ErrSimulated},\n\t\tOnDelivery: fault.Spec{Cnt: fault.Random(0.02), Err: check.ErrSimulated},\n\t})\n}\n\nfunc TestEightNodes_withoutFailures(t *testing.T) {\n\ttest(t, 8, 2*time.Second, ProducerFaultSpecs{})\n}\n\nfunc test(t *testing.T, numHarvests int, spawnInterval time.Duration, producerFaultSpecs ProducerFaultSpecs) {\n\tcheck.RequireLabel(t, \"int\")\n\tinstallSigQuitHandler()\n\n\ttestID, _ := uuid.NewRandom()\n\tx := openExternals()\n\tdefer x.close()\n\n\tscr.I()(\"Starting generator\")\n\tgenerator := startGenerator(t, testID, x.db, generateInterval, generateUniqueKeys)\n\tdefer func() { <-generator.stop() }()\n\n\tscr.I()(\"Starting receiver\")\n\treceiver := startReceiver(t, testID, x.cons)\n\tdefer func() { <-receiver.stop() }()\n\n\tharvests := make([]Harvest, numHarvests)\n\tdefer func() {\n\t\tfor _, h := range harvests {\n\t\t\tif h != nil {\n\t\t\t\th.Stop()\n\t\t\t}\n\t\t}\n\t}()\n\t// Start harvests at a set interval.\n\tfor i := 0; i < numHarvests; i++ {\n\t\tconfig := Config{\n\t\t\tKafkaProducerProvider: FaultyKafkaProducerProvider(StandardKafkaProducerProvider(), producerFaultSpecs),\n\t\t\tName:                  fmt.Sprintf(\"harvest-#%d\", i+1),\n\t\t\tScribe:                scribe.New(overlog.Bind(logger)),\n\t\t\tBaseKafkaConfig: KafkaConfigMap{\n\t\t\t\t\"bootstrap.servers\": bootstrapServers,\n\t\t\t\t\"socket.timeout.ms\": 10000,\n\t\t\t},\n\t\t\tProducerKafkaConfig: KafkaConfigMap{\n\t\t\t\t\"delivery.timeout.ms\": 10000,\n\t\t\t\t// \"debug\":               \"broker,topic,metadata\",\n\t\t\t},\n\t\t\tLeaderTopic:   leaderTopic,\n\t\t\tOutboxTable:   outboxTable,\n\t\t\tLeaderGroupID: leaderGroupID,\n\t\t\tDataSource:    dataSource,\n\t\t\tLimits: Limits{\n\t\t\t\tMinPollInterval: Duration(100 * time.Millisecond),\n\t\t\t\tMarkBackoff:     Duration(1 * time.Millisecond),\n\t\t\t\tIOErrorBackoff:  Duration(1 * time.Millisecond),\n\t\t\t},\n\t\t}\n\t\tconfig.Scribe.SetEnabled(scribe.Trace)\n\n\t\tscr.I()(\"Starting harvest %d/%d\", i+1, numHarvests)\n\t\th, err := New(config)\n\t\trequire.Nil(t, err)\n\t\tharvests[i] = h\n\t\trequire.Nil(t, h.Start())\n\n\t\tscr.I()(\"Sleeping\")\n\t\tsleepWithDeadline(spawnInterval)\n\t}\n\n\t// Stop harvests in the order they were started, except for the last one. The last harvest will be stopped\n\t// only after we've asserted the receipt of all messages.\n\tfor i := 0; i < numHarvests-1; i++ {\n\t\tscr.I()(\"Stopping harvest %d/%d\", i+1, numHarvests)\n\t\tharvests[i].Stop()\n\t\tscr.I()(\"In-flight records: %d\", harvests[i].InFlightRecords())\n\t\tsleepWithDeadline(spawnInterval)\n\t}\n\n\t// Wait until the generator produces some records. Once we've produced enough records, stop the\n\t// generator so that we can assert receipt.\n\tgenerator.recs.Fill(generateMinRecords, concurrent.Indefinitely)\n\tscr.I()(\"Stopping generator\")\n\t<-generator.stop()\n\tgenerated := generator.recs.GetInt()\n\tscr.I()(\"Generated %d records\", generated)\n\n\t// Wait until we received all records. Keep sliding in bite-sized chunks through successive assertions so that, as\n\t// long as we keep on receiving records, the assertion does not fail. This deals with slow harvesters (when we are\n\t// simulating lots of faults).\n\tconst waitBatchSize = 100\n\tfor r := waitBatchSize; r < generated; r += waitBatchSize {\n\t\tadvanced := wait(t).UntilAsserted(func(t check.Tester) {\n\t\t\tassert.GreaterOrEqual(t, receiver.recs.GetInt(), r)\n\t\t})\n\t\tif !advanced {\n\t\t\tscr.E()(\"Stack traces:\\n%s\", diags.DumpAllStacks())\n\t\t}\n\t\trequire.True(t, advanced)\n\t\tscr.I()(\"Received %d messages\", r)\n\t}\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tassert.GreaterOrEqual(t, receiver.recs.GetInt(), generated)\n\t})\n\tassert.Equal(t, generated, receiver.recs.GetInt())\n\tscr.I()(\"Stopping receiver\")\n\t<-receiver.stop()\n\n\t// Stop the last harvest as we've already received all messages and there's nothing more to publish.\n\tscr.I()(\"Stopping harvest %d/%d\", numHarvests, numHarvests)\n\tharvests[numHarvests-1].Stop()\n\n\t// Await harvests.\n\tfor i, h := range harvests {\n\t\tscr.I()(\"Awaiting harvest %d/%d\", i+1, numHarvests)\n\t\tassert.Nil(t, h.Await())\n\t}\n\tscr.I()(\"Done\")\n}\n\nfunc sleepWithDeadline(duration time.Duration) {\n\tbeforeSleep := time.Now()\n\ttime.Sleep(duration)\n\tif elapsed := time.Now().Sub(beforeSleep); elapsed > 2*duration {\n\t\tscr.W()(\"Sleep deadline exceeded; expected %v but slept for %v\", duration, elapsed)\n\t}\n}\n\ntype generator struct {\n\tcancel  context.CancelFunc\n\trecs    concurrent.AtomicCounter\n\tstopped chan int\n}\n\nfunc (g generator) stop() chan int {\n\tg.cancel()\n\treturn g.stopped\n}\n\nfunc startGenerator(t *testing.T, testID uuid.UUID, db *sql.DB, interval time.Duration, keys int) generator {\n\tst := stasher.New(outboxTable)\n\tctx, cancel := concurrent.Forever(context.Background())\n\trecs := concurrent.NewAtomicCounter()\n\tstopped := make(chan int, 1)\n\n\tgo func() {\n\t\tdefer scr.T()(\"Generator exiting\")\n\t\tdefer close(stopped)\n\t\tticker := time.NewTicker(interval)\n\t\tdefer ticker.Stop()\n\n\t\tvar tx *sql.Tx\n\t\tdefer func() {\n\t\t\terr := finaliseTx(t, tx)\n\t\t\tif err != nil {\n\t\t\t\tscr.E()(\"Could not finalise transaction: %v\", err)\n\t\t\t\tt.Errorf(\"Could not finalise transaction: %v\", err)\n\t\t\t}\n\t\t}()\n\n\t\tvar pre stasher.PreStash\n\t\tseq := 0\n\t\tfor {\n\t\t\tif seq%generateRecordsPerTxn == 0 {\n\t\t\t\terr := finaliseTx(t, tx)\n\t\t\t\tif err != nil {\n\t\t\t\t\tscr.E()(\"Could not finalise transaction: %v\", err)\n\t\t\t\t\tt.Errorf(\"Could not finalise transaction: %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tnewTx, err := db.Begin()\n\t\t\t\ttx = newTx\n\t\t\t\tif err != nil {\n\t\t\t\t\tscr.E()(\"Could not begin transaction: %v\", err)\n\t\t\t\t\tt.Errorf(\"Could not begin transaction: %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpre, err = st.Prepare(tx)\n\t\t\t\tif err != nil {\n\t\t\t\t\tscr.E()(\"Could not prepare: %v\", err)\n\t\t\t\t\tt.Errorf(\"Could not prepare: %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttestIDStr := testID.String()\n\t\t\trec := OutboxRecord{\n\t\t\t\tKafkaTopic: topic,\n\t\t\t\tKafkaKey:   strconv.Itoa(seq % keys),\n\t\t\t\tKafkaValue: String(testIDStr + \"_\" + strconv.Itoa(seq)),\n\t\t\t\tKafkaHeaders: KafkaHeaders{\n\t\t\t\t\tKafkaHeader{Key: \"testId\", Value: testIDStr},\n\t\t\t\t},\n\t\t\t}\n\t\t\terr := pre.Stash(rec)\n\t\t\tif err != nil {\n\t\t\t\tscr.E()(\"Could not stash: %v\", err)\n\t\t\t\tt.Errorf(\"Could not stash: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tseq = int(recs.Inc())\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase <-ticker.C:\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn generator{cancel, recs, stopped}\n}\n\nfunc finaliseTx(t *testing.T, tx *sql.Tx) error {\n\tif tx != nil {\n\t\treturn tx.Commit()\n\t}\n\treturn nil\n}\n\ntype receiver struct {\n\tcancel   context.CancelFunc\n\treceived map[string]int\n\trecs     concurrent.AtomicCounter\n\tstopped  chan int\n}\n\nfunc (r receiver) stop() chan int {\n\tr.cancel()\n\treturn r.stopped\n}\n\nfunc startReceiver(t *testing.T, testID uuid.UUID, cons *kafka.Consumer) receiver {\n\treceived := make(map[string]int)\n\tctx, cancel := concurrent.Forever(context.Background())\n\trecs := concurrent.NewAtomicCounter()\n\tstopped := make(chan int, 1)\n\n\tgo func() {\n\t\tdefer scr.T()(\"Receiver exiting\")\n\t\tdefer close(stopped)\n\n\t\tsuccessiveTimeouts := 0\n\t\tresetTimeouts := func() {\n\t\t\tif successiveTimeouts > 0 {\n\t\t\t\tsuccessiveTimeouts = 0\n\t\t\t}\n\t\t}\n\n\t\terr := cons.Subscribe(topic, func(_ *kafka.Consumer, event kafka.Event) error {\n\t\t\tswitch e := event.(type) {\n\t\t\tcase kafka.AssignedPartitions:\n\t\t\t\tresetTimeouts()\n\t\t\t\tscr.I()(\"Receiver: assigned partitions %v\", e.Partitions)\n\t\t\tcase kafka.RevokedPartitions:\n\t\t\t\tresetTimeouts()\n\t\t\t\tscr.I()(\"Receiver: revoked partitions %v\", e.Partitions)\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tscr.E()(\"Could not subscribe: %v\", err)\n\t\t\tt.Errorf(\"Could not subscribe: %v\", err)\n\t\t\treturn\n\t\t}\n\n\t\tlastMessageReceivedTime := time.Now()\n\t\tmessageAbsencePrinted := false\n\t\texpectedTestID := testID.String()\n\t\tconst partitions = 64\n\t\tlastReceivedOffsets := make([]kafka.Offset, partitions)\n\t\tfor i := 0; i < partitions; i++ {\n\t\t\tlastReceivedOffsets[i] = kafka.Offset(-1)\n\t\t}\n\n\t\tfor {\n\t\t\tmsg, err := cons.ReadMessage(receiverPollDuration)\n\t\t\tif err != nil {\n\t\t\t\tif isFatalError(err) {\n\t\t\t\t\tscr.E()(\"Fatal error during poll: %v\", err)\n\t\t\t\t\tt.Errorf(\"Fatal error during poll: %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t} else if !isTimedOutError(err) {\n\t\t\t\t\tscr.W()(\"Error during poll: %v\", err)\n\t\t\t\t} else {\n\t\t\t\t\tsuccessiveTimeouts++\n\t\t\t\t\tlogger.Raw(\".\")\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif msg != nil {\n\t\t\t\tif msg.TopicPartition.Offset <= lastReceivedOffsets[msg.TopicPartition.Partition] {\n\t\t\t\t\tscr.D()(\"Skipping duplicate delivery at offset %d\", msg.TopicPartition.Offset)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tlastReceivedOffsets[msg.TopicPartition.Partition] = msg.TopicPartition.Offset\n\t\t\t\tlastMessageReceivedTime = time.Now()\n\t\t\t\tmessageAbsencePrinted = false\n\n\t\t\t\tresetTimeouts()\n\n\t\t\t\tvalueFrags := strings.Split(string(msg.Value), \"_\")\n\t\t\t\tif len(valueFrags) != 2 {\n\t\t\t\t\tscr.E()(\"invalid value '%s'\", string(msg.Value))\n\t\t\t\t\tt.Errorf(\"invalid value '%s'\", string(msg.Value))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\treceivedTestID, value := valueFrags[0], valueFrags[1]\n\t\t\t\tif receivedTestID != expectedTestID {\n\t\t\t\t\tscr.I()(\"Skipping %s (test ID %s)\", string(msg.Value), expectedTestID)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tkey := string(msg.Key)\n\n\t\t\t\treceivedSeq, err := strconv.Atoi(value)\n\t\t\t\tif err != nil {\n\t\t\t\t\tscr.E()(\"Could not convert message value to sequence: '%s'\", value)\n\t\t\t\t\tt.Errorf(\"Could not convert message value to sequence: '%s'\", value)\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif assert.Equal(t, 1, len(msg.Headers)) {\n\t\t\t\t\tassert.Equal(t, expectedTestID, string(msg.Headers[0].Value))\n\t\t\t\t}\n\n\t\t\t\tif existingSeq, ok := received[key]; ok {\n\t\t\t\t\tif assert.GreaterOrEqual(t, receivedSeq, existingSeq) {\n\t\t\t\t\t\tif receivedSeq > existingSeq {\n\t\t\t\t\t\t\treceived[key] = receivedSeq\n\t\t\t\t\t\t\trecs.Inc()\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tscr.I()(\"Received duplicate %d for key %s (this is okay)\", existingSeq, key)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tscr.E()(\"Received records out of order, %d is behind %d\", receivedSeq, existingSeq)\n\t\t\t\t\t\tt.Errorf(\"Received records out of order, %d is behind %d\", receivedSeq, existingSeq)\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tkeyInt, err := strconv.Atoi(key)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tscr.E()(\"Could not convert message key '%s'\", key)\n\t\t\t\t\t\tt.Errorf(\"Could not convert message key '%s'\", key)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tif assert.Equal(t, keyInt, receivedSeq) {\n\t\t\t\t\t\trecs.Inc()\n\t\t\t\t\t\treceived[key] = receivedSeq\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\telapsed := time.Now().Sub(lastMessageReceivedTime)\n\t\t\t\tif elapsed > receiverNoMessagesWarningTime && !messageAbsencePrinted {\n\t\t\t\t\tscr.W()(\"No messages received since %v\", lastMessageReceivedTime)\n\t\t\t\t\tmessageAbsencePrinted = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn receiver{cancel, received, recs, stopped}\n}\n\nfunc isTimedOutError(err error) bool {\n\tkafkaError, ok := err.(kafka.Error)\n\treturn ok && kafkaError.Code() == kafka.ErrTimedOut\n}\n\nfunc isFatalError(err error) bool {\n\tkafkaError, ok := err.(kafka.Error)\n\treturn ok && kafkaError.IsFatal()\n}\n\nvar sigQuitHandlerInstalled = concurrent.NewAtomicCounter()\n\nfunc installSigQuitHandler() {\n\tif sigQuitHandlerInstalled.CompareAndSwap(0, 1) {\n\t\tsig := make(chan os.Signal, 1)\n\t\tgo func() {\n\t\t\tsignal.Notify(sig, syscall.SIGQUIT)\n\t\t\tselect {\n\t\t\tcase <-sig:\n\t\t\t\tscr.I()(\"Stack\\n%s\", diags.DumpAllStacks())\n\t\t\t}\n\t\t}()\n\t}\n}\n"
  },
  {
    "path": "kafka.go",
    "content": "package goharvest\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"gopkg.in/confluentinc/confluent-kafka-go.v1/kafka\"\n)\n\n/*\nInterfaces.\n*/\n\n// KafkaConsumer specifies the methods of a minimal consumer.\ntype KafkaConsumer interface {\n\tSubscribe(topic string, rebalanceCb kafka.RebalanceCb) error\n\tReadMessage(timeout time.Duration) (*kafka.Message, error)\n\tClose() error\n}\n\n// KafkaConsumerProvider is a factory for creating KafkaConsumer instances.\ntype KafkaConsumerProvider func(conf *KafkaConfigMap) (KafkaConsumer, error)\n\n// KafkaProducer specifies the methods of a minimal producer.\ntype KafkaProducer interface {\n\tEvents() chan kafka.Event\n\tProduce(msg *kafka.Message, deliveryChan chan kafka.Event) error\n\tClose()\n}\n\n// KafkaProducerProvider is a factory for creating KafkaProducer instances.\ntype KafkaProducerProvider func(conf *KafkaConfigMap) (KafkaProducer, error)\n\n/*\nStandard provider implementations.\n*/\n\n// StandardKafkaConsumerProvider returns a factory for creating a conventional KafkaConsumer, backed by the real client API.\nfunc StandardKafkaConsumerProvider() KafkaConsumerProvider {\n\treturn func(conf *KafkaConfigMap) (KafkaConsumer, error) {\n\t\treturn kafka.NewConsumer(toKafkaNativeConfig(conf))\n\t}\n}\n\n// StandardKafkaProducerProvider returns a factory for creating a conventional KafkaProducer, backed by the real client API.\nfunc StandardKafkaProducerProvider() KafkaProducerProvider {\n\treturn func(conf *KafkaConfigMap) (KafkaProducer, error) {\n\t\treturn kafka.NewProducer(toKafkaNativeConfig(conf))\n\t}\n}\n\n/*\nVarious helpers.\n*/\n\nfunc toKafkaNativeConfig(conf *KafkaConfigMap) *kafka.ConfigMap {\n\tresult := kafka.ConfigMap{}\n\tfor k, v := range *conf {\n\t\tresult[k] = v\n\t}\n\treturn &result\n}\n\nfunc copyKafkaConfig(configMap KafkaConfigMap) KafkaConfigMap {\n\tcopy := KafkaConfigMap{}\n\tputAllKafkaConfig(configMap, copy)\n\treturn copy\n}\n\nfunc putAllKafkaConfig(source, target KafkaConfigMap) {\n\tfor k, v := range source {\n\t\ttarget[k] = v\n\t}\n}\n\nfunc setKafkaConfig(configMap KafkaConfigMap, key string, value interface{}) error {\n\t_, containsKey := configMap[key]\n\tif containsKey {\n\t\treturn fmt.Errorf(\"cannot override configuration '%s'\", key)\n\t}\n\n\tconfigMap[key] = value\n\treturn nil\n}\n\nfunc setKafkaConfigs(configMap, toSet KafkaConfigMap) error {\n\tfor k, v := range toSet {\n\t\terr := setKafkaConfig(configMap, k, v)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc toNativeKafkaHeaders(headers KafkaHeaders) (nativeHeaders []kafka.Header) {\n\tif numHeaders := len(headers); numHeaders > 0 {\n\t\tnativeHeaders = make([]kafka.Header, numHeaders)\n\t\tfor i, header := range headers {\n\t\t\tnativeHeaders[i] = kafka.Header{Key: header.Key, Value: []byte(header.Value)}\n\t\t}\n\t}\n\treturn\n}\n"
  },
  {
    "path": "kafka_mock_test.go",
    "content": "package goharvest\n\nimport (\n\t\"time\"\n\n\t\"github.com/obsidiandynamics/libstdgo/concurrent\"\n\t\"gopkg.in/confluentinc/confluent-kafka-go.v1/kafka\"\n)\n\ntype consMockFuncs struct {\n\tSubscribe   func(m *consMock, topic string, rebalanceCb kafka.RebalanceCb) error\n\tReadMessage func(m *consMock, timeout time.Duration) (*kafka.Message, error)\n\tClose       func(m *consMock) error\n}\n\ntype consMockCounts struct {\n\tSubscribe,\n\tReadMessage,\n\tClose concurrent.AtomicCounter\n}\n\ntype consMock struct {\n\trebalanceCallback kafka.RebalanceCb\n\trebalanceEvents   chan kafka.Event\n\tf                 consMockFuncs\n\tc                 consMockCounts\n}\n\nfunc (m *consMock) Subscribe(topic string, rebalanceCb kafka.RebalanceCb) error {\n\tdefer m.c.Subscribe.Inc()\n\tm.rebalanceCallback = rebalanceCb\n\treturn m.f.Subscribe(m, topic, rebalanceCb)\n}\n\nfunc (m *consMock) ReadMessage(timeout time.Duration) (*kafka.Message, error) {\n\tdefer m.c.ReadMessage.Inc()\n\tif m.rebalanceCallback != nil {\n\t\t// The rebalance events should only be delivered in the polling thread, which is why we wait for\n\t\t// ReadMessage before forwarding the events to the rebalance callback\n\t\tselect {\n\t\tcase e := <-m.rebalanceEvents:\n\t\t\tm.rebalanceCallback(nil, e)\n\t\tdefault:\n\t\t}\n\t}\n\treturn m.f.ReadMessage(m, timeout)\n}\n\nfunc (m *consMock) Close() error {\n\tdefer m.c.Close.Inc()\n\treturn m.f.Close(m)\n}\n\nfunc (m *consMock) fillDefaults() {\n\tif m.rebalanceEvents == nil {\n\t\tm.rebalanceEvents = make(chan kafka.Event)\n\t}\n\tif m.f.Subscribe == nil {\n\t\tm.f.Subscribe = func(m *consMock, topic string, rebalanceCb kafka.RebalanceCb) error {\n\t\t\treturn nil\n\t\t}\n\t}\n\tif m.f.ReadMessage == nil {\n\t\tm.f.ReadMessage = func(m *consMock, timeout time.Duration) (*kafka.Message, error) {\n\t\t\treturn nil, newTimedOutError()\n\t\t}\n\t}\n\tif m.f.Close == nil {\n\t\tm.f.Close = func(m *consMock) error {\n\t\t\treturn nil\n\t\t}\n\t}\n\tm.c.Subscribe = concurrent.NewAtomicCounter()\n\tm.c.ReadMessage = concurrent.NewAtomicCounter()\n\tm.c.Close = concurrent.NewAtomicCounter()\n}\n\nfunc mockKafkaConsumerProvider(m *consMock) func(conf *KafkaConfigMap) (KafkaConsumer, error) {\n\treturn func(conf *KafkaConfigMap) (KafkaConsumer, error) {\n\t\treturn m, nil\n\t}\n}\n\ntype prodMockFuncs struct {\n\tEvents  func(m *prodMock) chan kafka.Event\n\tProduce func(m *prodMock, msg *kafka.Message, deliveryChan chan kafka.Event) error\n\tClose   func(m *prodMock)\n}\n\ntype prodMockCounts struct {\n\tEvents,\n\tProduce,\n\tClose concurrent.AtomicCounter\n}\n\ntype prodMock struct {\n\tevents chan kafka.Event\n\tf      prodMockFuncs\n\tc      prodMockCounts\n}\n\nfunc (m *prodMock) Events() chan kafka.Event {\n\tdefer m.c.Events.Inc()\n\treturn m.f.Events(m)\n}\n\nfunc (m *prodMock) Produce(msg *kafka.Message, deliveryChan chan kafka.Event) error {\n\tdefer m.c.Produce.Inc()\n\treturn m.f.Produce(m, msg, deliveryChan)\n}\n\nfunc (m *prodMock) Close() {\n\tdefer m.c.Close.Inc()\n\tm.f.Close(m)\n}\n\nfunc (m *prodMock) fillDefaults() {\n\tif m.events == nil {\n\t\tm.events = make(chan kafka.Event)\n\t}\n\tif m.f.Events == nil {\n\t\tm.f.Events = func(m *prodMock) chan kafka.Event {\n\t\t\treturn m.events\n\t\t}\n\t}\n\tif m.f.Produce == nil {\n\t\tm.f.Produce = func(m *prodMock, msg *kafka.Message, deliveryChan chan kafka.Event) error {\n\t\t\treturn nil\n\t\t}\n\t}\n\tif m.f.Close == nil {\n\t\tm.f.Close = func(m *prodMock) {\n\t\t\tclose(m.events)\n\t\t}\n\t}\n\tm.c.Events = concurrent.NewAtomicCounter()\n\tm.c.Produce = concurrent.NewAtomicCounter()\n\tm.c.Close = concurrent.NewAtomicCounter()\n}\n\nfunc mockKafkaProducerProvider(m *prodMock) func(conf *KafkaConfigMap) (KafkaProducer, error) {\n\treturn func(conf *KafkaConfigMap) (KafkaProducer, error) {\n\t\treturn m, nil\n\t}\n}\n"
  },
  {
    "path": "metric/meter.go",
    "content": "package metric\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n)\n\n// MeterStats is an immutable snapshot of meter statistics.\ntype MeterStats struct {\n\tName             string\n\tStart            time.Time\n\tTotalCount       int64\n\tTotalRatePerS    float64\n\tIntervalCount    int64\n\tIntervalRatePerS float64\n}\n\n// String produces a textual representation of a MeterStats object.\nfunc (s MeterStats) String() string {\n\treturn fmt.Sprintf(\"Meter <%s>: %d since %v, rate: %.3f current, %.3f average\\n\",\n\t\ts.Name, s.TotalCount, s.Start.Format(timeFormat), s.IntervalRatePerS, s.TotalRatePerS)\n}\n\n// Meter is a simple structure for tracking the volume of events observed from two points in time:\n// \t 1. When the Meter object was created (or when it was last reset)\n//   2. From the last snapshot point.\n//\n// A meter can be updated by adding more observations. Statistics can be periodically extracted from the\n// meter, reflecting the total observed volume as well as the volume in the most recent period.\n//\n// A meter is not thread-safe. In the absence of locking, it should only be accessed from a single\n// goroutine.\ntype Meter struct {\n\tname              string\n\tprintInterval     time.Duration\n\tstart             time.Time\n\ttotalCount        int64\n\tlastIntervalStart time.Time\n\tlastCount         int64\n\tlastStats         MeterStats\n}\n\nconst timeFormat = \"2006-01-02T15:04:05\"\n\n// String produces a textual representation of a Meter object.\nfunc (m Meter) String() string {\n\treturn fmt.Sprint(\"Meter[name=\", m.name,\n\t\t\", snapshotInterval=\", m.printInterval,\n\t\t\", start=\", m.start.Format(timeFormat),\n\t\t\", totalCount=\", m.totalCount,\n\t\t\", lastIntervalStart=\", m.lastIntervalStart.Format(timeFormat),\n\t\t\", lastCount=\", m.lastCount,\n\t\t\", lastStats=\", m.lastStats, \"]\")\n}\n\n// NewMeter constructs a new meter object, with a given name and snapshot interval. The actual snapshotting\n// of meter statistics is the responsibility of the goroutine that owns the meter.\nfunc NewMeter(name string, snapshotInterval time.Duration) *Meter {\n\tm := Meter{}\n\tm.name = name\n\tm.printInterval = snapshotInterval\n\tm.Reset()\n\treturn &m\n}\n\n// Reset the meter to its initial state — clearing all counters and resetting the clocks.\nfunc (m *Meter) Reset() {\n\tm.start = time.Now()\n\tm.totalCount = 0\n\tm.lastIntervalStart = m.start\n\tm.lastCount = 0\n}\n\n// Add a value to the meter, contributing to the overall count and to the current interval.\nfunc (m *Meter) Add(amount int64) {\n\tm.totalCount += amount\n}\n\n// MaybeStats conditionally returns a stats snapshot if the current sampling interval has lapsed. Otherwise, if the\n// sampling interval is still valid, a nil is returned.\nfunc (m *Meter) MaybeStats() *MeterStats {\n\tnow := time.Now()\n\telapsedInIntervalMs := now.Sub(m.lastIntervalStart).Milliseconds()\n\tif elapsedInIntervalMs > m.printInterval.Milliseconds() {\n\t\tintervalCount := m.totalCount - m.lastCount\n\t\tintervalRatePerS := float64(intervalCount) / float64(elapsedInIntervalMs) * 1000.0\n\t\tm.lastCount = m.totalCount\n\t\tm.lastIntervalStart = now\n\n\t\telapsedTotalMs := now.Sub(m.start).Milliseconds()\n\t\ttotalRatePerS := float64(m.totalCount) / float64(elapsedTotalMs) * 1000.0\n\n\t\tm.lastStats = MeterStats{\n\t\t\tName:             m.name,\n\t\t\tStart:            m.start,\n\t\t\tTotalCount:       m.totalCount,\n\t\t\tTotalRatePerS:    totalRatePerS,\n\t\t\tIntervalCount:    intervalCount,\n\t\t\tIntervalRatePerS: intervalRatePerS,\n\t\t}\n\t\treturn &m.lastStats\n\t}\n\treturn nil\n}\n\n// MeterStatsCallback is invoked by MaybeStatsCall().\ntype MeterStatsCallback func(stats MeterStats)\n\n// MaybeStatsCall conditionally invokes the given MeterStatsCallback if the current sampling interval has lapsed, returning true\n// if the callback was invoked.\nfunc (m *Meter) MaybeStatsCall(cb MeterStatsCallback) bool {\n\ts := m.MaybeStats()\n\tif s != nil {\n\t\tcb(*s)\n\t\treturn true\n\t}\n\treturn false\n}\n\n// MaybeStatsLog conditionally logs the snapshot of the recent sampling interval if the latter has lapsed, returning true if an\n// entry was logged.\nfunc (m *Meter) MaybeStatsLog(logger scribe.Logger) bool {\n\treturn m.MaybeStatsCall(func(stats MeterStats) {\n\t\tlogger(\"%v\", stats)\n\t})\n}\n"
  },
  {
    "path": "metric/meter_test.go",
    "content": "package metric\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/obsidiandynamics/libstdgo/check\"\n\t\"github.com/obsidiandynamics/libstdgo/scribe\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc wait(t *testing.T) check.Timesert {\n\treturn check.Wait(t, 10*time.Second)\n}\n\nfunc TestMeterString(t *testing.T) {\n\tm := NewMeter(\"test-name\", time.Second)\n\tstr := m.String()\n\trequire.Contains(t, str, \"Meter[\")\n\trequire.Contains(t, str, m.name)\n}\n\nfunc TestMeterMaybeStats(t *testing.T) {\n\tm := NewMeter(\"test-name\", time.Millisecond)\n\tm.Add(1)\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\ts := m.MaybeStats()\n\t\tif assert.NotNil(t, s) {\n\t\t\tassert.Equal(t, \"test-name\", s.Name)\n\t\t\tassert.Equal(t, int64(1), s.TotalCount)\n\t\t\tassert.Equal(t, int64(1), s.IntervalCount)\n\t\t}\n\t})\n\n\tm.Add(2)\n\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\ts := m.MaybeStats()\n\t\tif assert.NotNil(t, s) {\n\t\t\tassert.Equal(t, \"test-name\", s.Name)\n\t\t\tassert.Equal(t, int64(3), s.TotalCount)\n\t\t\tassert.Equal(t, int64(2), s.IntervalCount)\n\t\t}\n\t})\n\n\tm.Add(1)\n\tm.Reset()\n\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\ts := m.MaybeStats()\n\t\tif assert.NotNil(t, s) {\n\t\t\tassert.Equal(t, \"test-name\", s.Name)\n\t\t\tassert.Equal(t, int64(0), s.TotalCount)\n\t\t\tassert.Equal(t, int64(0), s.IntervalCount)\n\t\t}\n\t})\n}\n\nfunc TestMeterMaybeStatsCall(t *testing.T) {\n\tm := NewMeter(\"test-name\", time.Millisecond)\n\tm.Add(1)\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tvar statsPtr *MeterStats\n\t\tcalled := m.MaybeStatsCall(func(stats MeterStats) {\n\t\t\tstatsPtr = &stats\n\t\t})\n\t\tif assert.True(t, called) {\n\t\t\tassert.NotNil(t, statsPtr)\n\t\t\tassert.Equal(t, \"test-name\", statsPtr.Name)\n\t\t\tassert.Equal(t, int64(1), statsPtr.TotalCount)\n\t\t\tassert.Equal(t, int64(1), statsPtr.IntervalCount)\n\t\t} else {\n\t\t\tassert.Nil(t, statsPtr)\n\t\t}\n\t})\n}\n\nfunc TestMeterMaybeStatsLog(t *testing.T) {\n\tm := NewMeter(\"test-name\", time.Millisecond)\n\tm.Add(1)\n\n\tmockscribe := scribe.NewMock()\n\tscr := scribe.New(mockscribe.Factories())\n\twait(t).UntilAsserted(func(t check.Tester) {\n\t\tcalled := m.MaybeStatsLog(scr.I())\n\t\tif assert.True(t, called) {\n\t\t\tmockscribe.Entries().\n\t\t\t\tHaving(scribe.LogLevel(scribe.Info)).\n\t\t\t\tHaving(scribe.MessageContaining(\"test-name\")).\n\t\t\t\tAssert(t, scribe.Count(1))\n\t\t} else {\n\t\t\tmockscribe.Entries().\n\t\t\t\tAssert(t, scribe.Count(0))\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "metric/metric.go",
    "content": "// Package metric contains data structures for working with metrics.\npackage metric\n"
  },
  {
    "path": "neli.go",
    "content": "package goharvest\n\nimport \"github.com/obsidiandynamics/goneli\"\n\n// NeliProvider is a factory for creating Neli instances.\ntype NeliProvider func(config goneli.Config, barrier goneli.Barrier) (goneli.Neli, error)\n\n// StandardNeliProvider returns a factory for creating a conventional Neli instance, backed by the real client API.\nfunc StandardNeliProvider() NeliProvider {\n\treturn func(config goneli.Config, barrier goneli.Barrier) (goneli.Neli, error) {\n\t\treturn goneli.New(config, barrier)\n\t}\n}\n\nfunc configToNeli(hConfigMap KafkaConfigMap) goneli.KafkaConfigMap {\n\treturn map[string]interface{}(hConfigMap)\n}\n\nfunc configToHarvest(nConfigMap goneli.KafkaConfigMap) KafkaConfigMap {\n\treturn map[string]interface{}(nConfigMap)\n}\n\nfunc convertKafkaConsumerProvider(hProvider KafkaConsumerProvider) goneli.KafkaConsumerProvider {\n\treturn func(conf *goneli.KafkaConfigMap) (goneli.KafkaConsumer, error) {\n\t\thCfg := configToHarvest(*conf)\n\t\treturn hProvider(&hCfg)\n\t}\n}\n\nfunc convertKafkaProducerProvider(hProvider KafkaProducerProvider) goneli.KafkaProducerProvider {\n\treturn func(conf *goneli.KafkaConfigMap) (goneli.KafkaProducer, error) {\n\t\thCfg := configToHarvest(*conf)\n\t\treturn hProvider(&hCfg)\n\t}\n}\n"
  },
  {
    "path": "postgres.go",
    "content": "package goharvest\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"sort\"\n\n\t\"github.com/google/uuid\"\n\n\t// init postgres driver\n\t\"github.com/lib/pq\"\n)\n\ntype database struct {\n\tdb        *sql.DB\n\tmarkStmt  *sql.Stmt\n\tpurgeStmt *sql.Stmt\n\tresetStmt *sql.Stmt\n}\n\nconst markQueryTemplate = `\n-- mark query\nUPDATE %s\nSET leader_id = $1\nWHERE id IN (\n  SELECT id FROM %s\n  WHERE leader_id IS NULL OR leader_id != $1\n  ORDER BY id\n  LIMIT $2\n)\nRETURNING id, create_time, kafka_topic, kafka_key, kafka_value, kafka_header_keys, kafka_header_values, leader_id\n`\n\nconst purgeQueryTemplate = `\n-- purge query\nDELETE FROM %s\nWHERE id = $1\n`\n\nconst resetQueryTemplate = `\n-- reset query\nUPDATE %s\nSET leader_id = NULL\nWHERE id = $1\n`\n\nfunc closeResource(stmt *sql.Stmt) {\n\tif stmt != nil {\n\t\tstmt.Close()\n\t}\n}\n\nfunc closeResources(stmts ...*sql.Stmt) {\n\tfor _, resource := range stmts {\n\t\tcloseResource(resource)\n\t}\n}\n\ntype databaseProvider func() (*sql.DB, error)\n\n// StandardPostgresBindingProvider returns a DatabaseBindingProvider that connects to a real Postgres database.\nfunc StandardPostgresBindingProvider() DatabaseBindingProvider {\n\treturn NewPostgresBinding\n}\n\n// NewPostgresBinding creates a Postgres binding for the given dataSource and outboxTable args.\nfunc NewPostgresBinding(dataSource string, outboxTable string) (DatabaseBinding, error) {\n\treturn newPostgresBinding(func() (*sql.DB, error) {\n\t\treturn sql.Open(\"postgres\", dataSource)\n\t}, outboxTable)\n}\n\nfunc newPostgresBinding(dbProvider databaseProvider, outboxTable string) (DatabaseBinding, error) {\n\tsuccess := false\n\tvar db *sql.DB\n\tvar markStmt, purgeStmt, resetStmt *sql.Stmt\n\tdefer func() {\n\t\tif !success {\n\t\t\tif db != nil {\n\t\t\t\tdb.Close()\n\t\t\t}\n\t\t\tcloseResources(markStmt, purgeStmt, resetStmt)\n\t\t}\n\t}()\n\n\tdb, err := dbProvider()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdb.SetMaxOpenConns(2)\n\tdb.SetMaxIdleConns(2)\n\n\tmarkStmt, err = db.Prepare(fmt.Sprintf(markQueryTemplate, outboxTable, outboxTable))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpurgeStmt, err = db.Prepare(fmt.Sprintf(purgeQueryTemplate, outboxTable))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tresetStmt, err = db.Prepare(fmt.Sprintf(resetQueryTemplate, outboxTable))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tsuccess = true\n\treturn &database{\n\t\tdb:        db,\n\t\tmarkStmt:  markStmt,\n\t\tpurgeStmt: purgeStmt,\n\t\tresetStmt: resetStmt,\n\t}, nil\n}\n\nfunc (db *database) Mark(leaderID uuid.UUID, limit int) ([]OutboxRecord, error) {\n\trows, err := db.markStmt.Query(leaderID, limit)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer rows.Close()\n\trecords := make([]OutboxRecord, 0, limit)\n\tfor rows.Next() {\n\t\trecord := OutboxRecord{}\n\t\tvar keys []string\n\t\tvar values []string\n\t\terr := rows.Scan(\n\t\t\t&record.ID,\n\t\t\t&record.CreateTime,\n\t\t\t&record.KafkaTopic,\n\t\t\t&record.KafkaKey,\n\t\t\t&record.KafkaValue,\n\t\t\tpq.Array(&keys),\n\t\t\tpq.Array(&values),\n\t\t\t&record.LeaderID,\n\t\t)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tnumKeys := len(keys)\n\t\tif len(keys) != len(values) {\n\t\t\treturn nil, fmt.Errorf(\"unequal number of header keys (%d) and values (%d)\", numKeys, len(values))\n\t\t}\n\n\t\trecord.KafkaHeaders = make(KafkaHeaders, numKeys)\n\t\tfor i := 0; i < numKeys; i++ {\n\t\t\trecord.KafkaHeaders[i] = KafkaHeader{keys[i], values[i]}\n\t\t}\n\t\trecords = append(records, record)\n\t}\n\n\tsort.Slice(records, func(i, j int) bool {\n\t\treturn records[i].ID < records[j].ID\n\t})\n\n\treturn records, nil\n}\n\nfunc (db *database) Purge(id int64) (bool, error) {\n\tres, err := db.purgeStmt.Exec(id)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\taffected, _ := res.RowsAffected()\n\tif affected != 1 {\n\t\treturn false, nil\n\t}\n\treturn true, err\n}\n\nfunc (db *database) Reset(id int64) (bool, error) {\n\tres, err := db.resetStmt.Exec(id)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\taffected, _ := res.RowsAffected()\n\tif affected != 1 {\n\t\treturn false, nil\n\t}\n\treturn true, err\n}\n\nfunc (db *database) Dispose() {\n\tdb.db.Close()\n\tcloseResources(db.markStmt, db.purgeStmt, db.resetStmt)\n}\n"
  },
  {
    "path": "postgres_test.go",
    "content": "package goharvest\n\nimport (\n\t\"database/sql\"\n\t\"database/sql/driver\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/DATA-DOG/go-sqlmock\"\n\t\"github.com/google/uuid\"\n\t\"github.com/lib/pq\"\n\t\"github.com/obsidiandynamics/libstdgo/check\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nconst outboxTable = \"outbox\"\nconst markPrepare = \"-- mark query\"\nconst purgePrepare = \"-- purge query\"\nconst resetPrepare = \"-- reset query\"\n\nfunc pgFixtures() (databaseProvider, sqlmock.Sqlmock) {\n\tdb, mock, err := sqlmock.New()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdbProvider := func() (*sql.DB, error) {\n\t\treturn db, nil\n\t}\n\treturn dbProvider, mock\n}\n\nfunc TestErrorInDBProvider(t *testing.T) {\n\tdbProvider := func() (*sql.DB, error) {\n\t\treturn nil, check.ErrSimulated\n\t}\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.Nil(t, b)\n\tassert.Equal(t, check.ErrSimulated, err)\n}\n\nfunc TestErrorInPrepareMarkQuery(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmock.ExpectPrepare(markPrepare).WillReturnError(check.ErrSimulated)\n\n\tmock.ExpectClose()\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.Nil(t, b)\n\tassert.Equal(t, check.ErrSimulated, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestErrorInPreparePurgeQuery(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmark := mock.ExpectPrepare(markPrepare)\n\tmock.ExpectPrepare(purgePrepare).WillReturnError(check.ErrSimulated)\n\n\tmark.WillBeClosed()\n\tmock.ExpectClose()\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.Nil(t, b)\n\tassert.Equal(t, check.ErrSimulated, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestErrorInPrepareResetQuery(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmark := mock.ExpectPrepare(markPrepare)\n\tpurge := mock.ExpectPrepare(purgePrepare)\n\tmock.ExpectPrepare(resetPrepare).WillReturnError(check.ErrSimulated)\n\n\tmark.WillBeClosed()\n\tpurge.WillBeClosed()\n\tmock.ExpectClose()\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.Nil(t, b)\n\tassert.Equal(t, check.ErrSimulated, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nconst testMarkQueryLimit = 100\n\nfunc TestExecuteMark_queryError(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmark := mock.ExpectPrepare(markPrepare)\n\tpurge := mock.ExpectPrepare(purgePrepare)\n\treset := mock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tleaderID, _ := uuid.NewRandom()\n\tmark.ExpectQuery().WithArgs(leaderID, testMarkQueryLimit).WillReturnError(check.ErrSimulated)\n\n\trecords, err := b.Mark(leaderID, testMarkQueryLimit)\n\tassert.Nil(t, records)\n\tassert.Equal(t, check.ErrSimulated, err)\n\n\tmock.ExpectClose()\n\tmark.WillBeClosed()\n\tpurge.WillBeClosed()\n\treset.WillBeClosed()\n\tb.Dispose()\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\n// Tests error when one of the columns is of the wrong data type.\nfunc TestExecuteMarkQuery_scanError(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmark := mock.ExpectPrepare(markPrepare)\n\tmock.ExpectPrepare(purgePrepare)\n\tmock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tleaderID, _ := uuid.NewRandom()\n\trows := sqlmock.NewRows([]string{\n\t\t\"id\",\n\t\t\"create_time\",\n\t\t\"kafka_topic\",\n\t\t\"kafka_key\",\n\t\t\"kafka_value\",\n\t\t\"kafka_header_keys\",\n\t\t\"kafka_header_values\",\n\t\t\"leader_id\",\n\t})\n\trows.AddRow(\"non-int\", \"\", \"\", \"\", \"\", pq.Array([]string{\"some-key\"}), pq.Array([]string{\"some-value\"}), leaderID)\n\tmark.ExpectQuery().WithArgs(leaderID, testMarkQueryLimit).WillReturnRows(rows)\n\n\trecords, err := b.Mark(leaderID, testMarkQueryLimit)\n\tassert.Nil(t, records)\n\tif assert.NotNil(t, err) {\n\t\tassert.Contains(t, err.Error(), \"Scan error on column\")\n\t}\n}\n\nfunc TestExecuteMark_success(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmark := mock.ExpectPrepare(markPrepare)\n\tmock.ExpectPrepare(purgePrepare)\n\tmock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tleaderID, _ := uuid.NewRandom()\n\texp := []OutboxRecord{\n\t\t{\n\t\t\tID:         77,\n\t\t\tCreateTime: time.Now(),\n\t\t\tKafkaTopic: \"kafka_topic\",\n\t\t\tKafkaKey:   \"kafka_key\",\n\t\t\tKafkaValue: String(\"kafka_value\"),\n\t\t\tKafkaHeaders: KafkaHeaders{\n\t\t\t\tKafkaHeader{Key: \"some-key\", Value: \"some-value\"},\n\t\t\t},\n\t\t\tLeaderID: nil,\n\t\t},\n\t\t{\n\t\t\tID:           78,\n\t\t\tCreateTime:   time.Now(),\n\t\t\tKafkaTopic:   \"kafka_topic\",\n\t\t\tKafkaKey:     \"kafka_key\",\n\t\t\tKafkaValue:   String(\"kafka_value\"),\n\t\t\tKafkaHeaders: KafkaHeaders{},\n\t\t\tLeaderID:     nil,\n\t\t},\n\t}\n\treverse := func(recs []OutboxRecord) []OutboxRecord {\n\t\treversed := make([]OutboxRecord, len(recs))\n\t\tfor i, j := len(recs)-1, 0; i >= 0; i, j = i-1, j+1 {\n\t\t\treversed[i] = recs[j]\n\t\t}\n\t\treturn reversed\n\t}\n\n\trows := sqlmock.NewRows([]string{\n\t\t\"id\",\n\t\t\"create_time\",\n\t\t\"kafka_topic\",\n\t\t\"kafka_key\",\n\t\t\"kafka_value\",\n\t\t\"kafka_header_keys\",\n\t\t\"kafka_header_values\",\n\t\t\"leader_id\",\n\t})\n\t// Reverse the order before returning to test the sorter inside the marker implementation.\n\tfor _, expRec := range reverse(exp) {\n\t\theaderKeys, headerValues := flattenHeaders(expRec.KafkaHeaders)\n\t\trows.AddRow(\n\t\t\texpRec.ID,\n\t\t\texpRec.CreateTime,\n\t\t\texpRec.KafkaTopic,\n\t\t\texpRec.KafkaKey,\n\t\t\texpRec.KafkaValue,\n\t\t\tpq.Array(headerKeys),\n\t\t\tpq.Array(headerValues),\n\t\t\texpRec.LeaderID,\n\t\t)\n\t}\n\tmark.ExpectQuery().WithArgs(leaderID, testMarkQueryLimit).WillReturnRows(rows)\n\n\trecords, err := b.Mark(leaderID, testMarkQueryLimit)\n\tassert.Nil(t, err)\n\tassert.ElementsMatch(t, []interface{}{exp[0], exp[1]}, records)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestExecuteMark_headerLengthMismatch(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmark := mock.ExpectPrepare(markPrepare)\n\tmock.ExpectPrepare(purgePrepare)\n\tmock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tleaderID, _ := uuid.NewRandom()\n\n\trows := sqlmock.NewRows([]string{\n\t\t\"id\",\n\t\t\"create_time\",\n\t\t\"kafka_topic\",\n\t\t\"kafka_key\",\n\t\t\"kafka_value\",\n\t\t\"kafka_header_keys\",\n\t\t\"kafka_header_values\",\n\t\t\"leader_id\",\n\t})\n\trows.AddRow(\n\t\t1,\n\t\ttime.Now(),\n\t\t\"some-topic\",\n\t\t\"some-key\",\n\t\t\"some-value\",\n\t\tpq.Array([]string{\"k0\"}),\n\t\tpq.Array([]string{\"v0\", \"v1\"}),\n\t\tleaderID,\n\t)\n\tmark.ExpectQuery().WithArgs(leaderID, testMarkQueryLimit).WillReturnRows(rows)\n\n\trecords, err := b.Mark(leaderID, testMarkQueryLimit)\n\tassert.Nil(t, records)\n\trequire.NotNil(t, err)\n\tassert.Equal(t, \"unequal number of header keys (1) and values (2)\", err.Error())\n}\n\nfunc flattenHeaders(headers KafkaHeaders) (headerKeys, headerValues []string) {\n\tif numHeaders := len(headers); numHeaders > 0 {\n\t\theaderKeys = make([]string, numHeaders)\n\t\theaderValues = make([]string, numHeaders)\n\t\tfor i, header := range headers {\n\t\t\theaderKeys[i], headerValues[i] = header.Key, header.Value\n\t\t}\n\t} else {\n\t\theaderKeys, headerValues = []string{}, []string{}\n\t}\n\treturn\n}\n\nfunc TestExecutePurge_error(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmock.ExpectPrepare(markPrepare)\n\tpurge := mock.ExpectPrepare(purgePrepare)\n\tmock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tconst id = 77\n\tpurge.ExpectExec().WithArgs(id).WillReturnError(check.ErrSimulated)\n\n\tdone, err := b.Purge(id)\n\tassert.False(t, done)\n\tassert.Equal(t, check.ErrSimulated, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestExecutePurge_success(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmock.ExpectPrepare(markPrepare)\n\tpurge := mock.ExpectPrepare(purgePrepare)\n\tmock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tconst id = 77\n\tpurge.ExpectExec().WithArgs(id).WillReturnResult(sqlmock.NewResult(-1, 1))\n\n\tdone, err := b.Purge(id)\n\tassert.True(t, done)\n\tassert.Nil(t, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestExecutePurge_notDone(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmock.ExpectPrepare(markPrepare)\n\tpurge := mock.ExpectPrepare(purgePrepare)\n\tmock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tconst id = 77\n\tpurge.ExpectExec().WithArgs(id).WillReturnResult(driver.ResultNoRows)\n\n\tdone, err := b.Purge(id)\n\tassert.False(t, done)\n\tassert.Nil(t, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestExecuteReset_error(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmock.ExpectPrepare(markPrepare)\n\tmock.ExpectPrepare(purgePrepare)\n\treset := mock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tconst id = 77\n\treset.ExpectExec().WithArgs(id).WillReturnError(check.ErrSimulated)\n\n\tdone, err := b.Reset(id)\n\tassert.False(t, done)\n\tassert.Equal(t, check.ErrSimulated, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestExecuteReset_success(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmock.ExpectPrepare(markPrepare)\n\tmock.ExpectPrepare(purgePrepare)\n\treset := mock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tconst id = 77\n\treset.ExpectExec().WithArgs(id).WillReturnResult(sqlmock.NewResult(-1, 1))\n\n\tdone, err := b.Reset(id)\n\tassert.True(t, done)\n\tassert.Nil(t, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestExecuteReset_notDone(t *testing.T) {\n\tdbProvider, mock := pgFixtures()\n\tmock.ExpectPrepare(markPrepare)\n\tmock.ExpectPrepare(purgePrepare)\n\treset := mock.ExpectPrepare(resetPrepare)\n\n\tb, err := newPostgresBinding(dbProvider, outboxTable)\n\tassert.NotNil(t, b)\n\tassert.Nil(t, err)\n\n\tconst id = 77\n\treset.ExpectExec().WithArgs(id).WillReturnResult(driver.ResultNoRows)\n\n\tdone, err := b.Reset(id)\n\tassert.False(t, done)\n\tassert.Nil(t, err)\n\tassert.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestRealPostgresBinding(t *testing.T) {\n\tb, err := NewPostgresBinding(\"***corrupt connection info string***\", outboxTable)\n\tassert.Nil(t, b)\n\tassert.NotNil(t, err)\n}\n"
  },
  {
    "path": "sh/.gitignore",
    "content": "librdkafka\n"
  },
  {
    "path": "sh/build-librdkafka.sh",
    "content": "#!/bin/sh\n\ncd $(dirname $0)\n\nset -e\n\nif [ -d librdkafka ]; then\n  cd librdkafka\n  git pull\n  cd ..\nelse\n  git clone https://github.com/edenhill/librdkafka.git\nfi\n\ncd librdkafka\n./configure --prefix /usr\nmake\nsudo make install\nrm -rf librdkafka\n"
  },
  {
    "path": "sh/init-outbox.sh",
    "content": "#!/bin/sh\n\ncat <<EOF | psql -U postgres -h localhost\nCREATE TABLE IF NOT EXISTS outbox (\n  id                  BIGSERIAL PRIMARY KEY,\n  create_time         TIMESTAMP WITH TIME ZONE NOT NULL,\n  kafka_topic         VARCHAR(249) NOT NULL,\n  kafka_key           VARCHAR(100) NOT NULL,\n  kafka_value         VARCHAR(10000),\n  kafka_header_keys   TEXT[] NOT NULL,\n  kafka_header_values TEXT[] NOT NULL,\n  leader_id           UUID\n)\nEOF\n"
  },
  {
    "path": "sh/soak.sh",
    "content": "#!/bin/bash\n\nif [ \"$SOAK_CMD\" == \"\" ]; then\n  echo \"SOAK_CMD is not set\"\n  exit 1\nfi\nif [ \"$SOAK_RUNS\" == \"\" ]; then\n  SOAK_RUNS=10\nfi\nif [ \"$SOAK_INTERVAL\" == \"\" ]; then\n  SOAK_INTERVAL=0\nfi\n\nif [ \"$SOAK_GITPULL\" == \"\" ]; then\n  SOAK_GITPULL=true\nfi\n\nGREEN='\\033[0;32m'\nRED='\\033[0;31m'\nYELLOW='\\033[0;33m'\nCYAN='\\033[0;36m'\nGREY='\\033[0;90m'\nNC='\\033[0m'\n\necho -e \"${GREY}SOAK_CMD:      $SOAK_CMD${NC}\"\necho -e \"${GREY}SOAK_RUNS:     $SOAK_RUNS${NC}\"\necho -e \"${GREY}SOAK_INTERVAL: $SOAK_INTERVAL${NC}\"\necho -e \"${GREY}SOAK_GITPULL:  $SOAK_GITPULL${NC}\"\n\ncd $(dirname $0)/..\n\nset -e\ncycle=1\nwhile [ true ]; do\n  echo -e \"${CYAN}=============================================================================================================${NC}\"\n  echo -e \"${CYAN}Cycle ${cycle}${NC}\"\n\n  for run in $(seq 1 $SOAK_RUNS)\n  do\n    timestamp=$(date +%'F %H:%M:%S')\n    echo -e \"${GREEN}-------------------------------------------------------------------------------------------------------------${NC}\"\n    echo -e \"${GREEN}${timestamp}: Starting run ${run}/${SOAK_RUNS}${NC}\"\n    echo -e \"${GREEN}-------------------------------------------------------------------------------------------------------------${NC}\"\n    $SOAK_CMD\n\n    sleep $SOAK_INTERVAL\n  done\n\n  if [ $SOAK_GITPULL == \"true\" ]; then\n    git pull\n  fi\n  cycle=$(($cycle + 1))\ndone\n\n"
  },
  {
    "path": "stasher/stasher.go",
    "content": "// Package stasher is a helper for inserting records into an outbox table within transaction scope.\npackage stasher\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\n\t\"github.com/lib/pq\"\n\t\"github.com/obsidiandynamics/goharvest\"\n)\n\n// Stasher writes records into the outbox table.\ntype Stasher interface {\n\tStash(tx *sql.Tx, rec goharvest.OutboxRecord) error\n\tPrepare(tx *sql.Tx) (PreStash, error)\n}\n\ntype stasher struct {\n\tquery string\n}\n\n// New creates a new Stasher instance for the given outboxTable.\nfunc New(outboxTable string) Stasher {\n\treturn &stasher{fmt.Sprintf(insertQueryTemplate, outboxTable)}\n}\n\nconst insertQueryTemplate = `\n-- insert query\nINSERT INTO %s (create_time, kafka_topic, kafka_key, kafka_value, kafka_header_keys, kafka_header_values)\nVALUES (NOW(), $1, $2, $3, $4, $5)\n`\n\n// PreStash houses a prepared statement bound to the scope of a single transaction.\ntype PreStash struct {\n\tstmt *sql.Stmt\n}\n\n// Prepare a statement for stashing records, where the latter is expected to be invoked multiple times from\n// a given transaction.\nfunc (s *stasher) Prepare(tx *sql.Tx) (PreStash, error) {\n\tstmt, err := tx.Prepare(s.query)\n\treturn PreStash{stmt}, err\n}\n\n// Stash one record using the prepared statement.\nfunc (p PreStash) Stash(rec goharvest.OutboxRecord) error {\n\theaderKeys, headerValues := makeHeaders(rec)\n\t_, err := p.stmt.Exec(rec.KafkaTopic, rec.KafkaKey, rec.KafkaValue, pq.Array(headerKeys), pq.Array(headerValues))\n\treturn err\n}\n\nfunc makeHeaders(rec goharvest.OutboxRecord) ([]string, []string) {\n\tvar headerKeys, headerValues []string\n\tif numHeaders := len(rec.KafkaHeaders); numHeaders > 0 {\n\t\theaderKeys = make([]string, numHeaders)\n\t\theaderValues = make([]string, numHeaders)\n\t\tfor i, header := range rec.KafkaHeaders {\n\t\t\theaderKeys[i], headerValues[i] = header.Key, header.Value\n\t\t}\n\t} else {\n\t\theaderKeys, headerValues = []string{}, []string{}\n\t}\n\treturn headerKeys, headerValues\n}\n\n// Stash one record within the given transaction scope.\nfunc (s *stasher) Stash(tx *sql.Tx, rec goharvest.OutboxRecord) error {\n\theaderKeys, headerValues := makeHeaders(rec)\n\t_, err := tx.Exec(s.query, rec.KafkaTopic, rec.KafkaKey, rec.KafkaValue, pq.Array(headerKeys), pq.Array(headerValues))\n\treturn err\n}\n"
  },
  {
    "path": "stasher/stasher_doc_test.go",
    "content": "package stasher\n\nimport (\n\t\"database/sql\"\n\t\"testing\"\n\n\t\"github.com/obsidiandynamics/goharvest\"\n\t\"github.com/obsidiandynamics/libstdgo/check\"\n)\n\nfunc Example() {\n\tdb, err := sql.Open(\"postgres\", \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer db.Close()\n\n\tst := New(\"outbox\")\n\n\t// Begin a transaction.\n\ttx, _ := db.Begin()\n\tdefer tx.Rollback()\n\n\t// Update other database entities in transaction scope.\n\t// ...\n\n\t// Stash an outbox record for subsequent harvesting.\n\terr = st.Stash(tx, goharvest.OutboxRecord{\n\t\tKafkaTopic: \"my-app.topic\",\n\t\tKafkaKey:   \"hello\",\n\t\tKafkaValue: goharvest.String(\"world\"),\n\t\tKafkaHeaders: goharvest.KafkaHeaders{\n\t\t\t{Key: \"applicationId\", Value: \"my-app\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\t// Commit the transaction.\n\ttx.Commit()\n}\n\nfunc TestExample(t *testing.T) {\n\tcheck.RunTargetted(t, Example)\n}\n\nfunc Example_prepare() {\n\tdb, err := sql.Open(\"postgres\", \"host=localhost port=5432 user=postgres password= dbname=postgres sslmode=disable\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer db.Close()\n\n\tst := New(\"outbox\")\n\n\t// Begin a transaction.\n\ttx, _ := db.Begin()\n\tdefer tx.Rollback()\n\n\t// Update other database entities in transaction scope.\n\t// ...\n\n\t// Formulates a prepared statement that may be reused within the scope of the transaction.\n\tprestash, _ := st.Prepare(tx)\n\n\t// Publish a bunch of messages using the same prepared statement.\n\tfor i := 0; i < 10; i++ {\n\t\t// Stash an outbox record for subsequent harvesting.\n\t\terr = prestash.Stash(goharvest.OutboxRecord{\n\t\t\tKafkaTopic: \"my-app.topic\",\n\t\t\tKafkaKey:   \"hello\",\n\t\t\tKafkaValue: goharvest.String(\"world\"),\n\t\t\tKafkaHeaders: goharvest.KafkaHeaders{\n\t\t\t\t{Key: \"applicationId\", Value: \"my-app\"},\n\t\t\t},\n\t\t})\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\t}\n\n\t// Commit the transaction.\n\ttx.Commit()\n}\n\nfunc TestExample_prepare(t *testing.T) {\n\tcheck.RunTargetted(t, Example_prepare)\n}\n"
  },
  {
    "path": "stasher/statsher_test.go",
    "content": "package stasher\n\nimport (\n\t\"testing\"\n\n\t\"github.com/DATA-DOG/go-sqlmock\"\n\t\"github.com/lib/pq\"\n\t\"github.com/obsidiandynamics/goharvest\"\n\t\"github.com/stretchr/testify/require\"\n)\n\nconst (\n\ttestTopic       = \"topic\"\n\ttestKey         = \"key\"\n\ttestValue       = \"value\"\n\ttestHeaderKey   = \"header-key\"\n\ttestHeaderValue = \"header-value\"\n\ttestInsertQuery = \"-- insert query\"\n)\n\nfunc TestStash_withHeaders(t *testing.T) {\n\ts := New(\"outbox\")\n\n\tdb, mock, err := sqlmock.New()\n\trequire.Nil(t, err)\n\n\tmock.ExpectBegin()\n\ttx, err := db.Begin()\n\trequire.Nil(t, err)\n\n\tmock.ExpectExec(testInsertQuery).\n\t\tWithArgs(testTopic, testKey, testValue, pq.Array([]string{testHeaderKey}), pq.Array([]string{testHeaderValue})).\n\t\tWillReturnResult(sqlmock.NewResult(-1, 1))\n\terr = s.Stash(tx, goharvest.OutboxRecord{\n\t\tKafkaTopic: testTopic,\n\t\tKafkaKey:   testKey,\n\t\tKafkaValue: goharvest.String(testValue),\n\t\tKafkaHeaders: goharvest.KafkaHeaders{\n\t\t\t{Key: testHeaderKey, Value: testHeaderValue},\n\t\t},\n\t})\n\trequire.Nil(t, err)\n\n\trequire.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestStash_withoutHeaders(t *testing.T) {\n\ts := New(\"outbox\")\n\n\tdb, mock, err := sqlmock.New()\n\trequire.Nil(t, err)\n\n\tmock.ExpectBegin()\n\ttx, err := db.Begin()\n\trequire.Nil(t, err)\n\n\tmock.ExpectExec(testInsertQuery).\n\t\tWithArgs(testTopic, testKey, testValue, pq.Array([]string{}), pq.Array([]string{})).\n\t\tWillReturnResult(sqlmock.NewResult(-1, 1))\n\terr = s.Stash(tx, goharvest.OutboxRecord{\n\t\tKafkaTopic: testTopic,\n\t\tKafkaKey:   testKey,\n\t\tKafkaValue: goharvest.String(testValue),\n\t})\n\trequire.Nil(t, err)\n\n\trequire.Nil(t, mock.ExpectationsWereMet())\n}\n\nfunc TestStash_prepare(t *testing.T) {\n\ts := New(\"outbox\")\n\n\tdb, mock, err := sqlmock.New()\n\trequire.Nil(t, err)\n\n\tmock.ExpectBegin()\n\ttx, err := db.Begin()\n\trequire.Nil(t, err)\n\n\tmock.ExpectPrepare(testInsertQuery)\n\tprestash, err := s.Prepare(tx)\n\trequire.Nil(t, err)\n\trequire.NotNil(t, prestash)\n\n\tmock.ExpectExec(testInsertQuery).\n\t\tWithArgs(testTopic, testKey, testValue, pq.Array([]string{}), pq.Array([]string{})).\n\t\tWillReturnResult(sqlmock.NewResult(-1, 1))\n\terr = prestash.Stash(goharvest.OutboxRecord{\n\t\tKafkaTopic: testTopic,\n\t\tKafkaKey:   testKey,\n\t\tKafkaValue: goharvest.String(testValue),\n\t})\n\trequire.Nil(t, err)\n\n\trequire.Nil(t, mock.ExpectationsWereMet())\n}\n"
  }
]