A Go package for request throttling and rate limiting, heavily inspired by the Node.js bottleneck package.
- Local and Distributed Rate Limiting: Supports both in-memory (LocalStore) and Redis-based (RedisStore) backends
- Configurable Limits: Set maximum concurrent jobs and minimum time between jobs
- Priority Queue: Jobs are executed by priority, FIFO within a priority
- Event-Driven Scheduler: Dispatches as capacity frees up, with no idle polling
- Atomic Operations: Redis operations use Lua scripts, with the clock read from Redis so instances need not agree on time
- Renewable Leases: Capacity is held by tokenized leases, so a long job keeps its slot and a crashed process releases it
- Independent Rate Spacing:
MinTimeis measured from a job's start and outlives the lease that started it, so a crash cannot grant the next job a free start - Configuration Agreement: Instances sharing a limiter ID must agree on the distributed limits, or the disagreement is reported rather than silently resolved
- Easy Integration: Simple API for wrapping existing functions
- Direct datastore calls now enforce
Options.Validate.NewLimitervalidated its options, butLocalStore.Request,LocalStore.Acquire,RedisStore.RequestandRedisStore.Acquiredid not, so a caller using a store directly could pass a negativeMaxConcurrent,MinTimeorLeaseTTLand get the old silent behavior: the value failed the> 0guard in the admission path and every request was admitted. A limiter configured with a miscalculated limit enforced nothing, with no error to say so. All four now run the same validator the limiter does, before any token is generated, any Redis command is issued, any local state is created and any TTL or spacing record is touched. SchedBestFitaccounts for capacity held in other processes. The policy chose a candidate from the limiter's local running weight alone. With a sharedMaxConcurrentof 3, process A holding weight 2, and process B queueing a high-priority weight-2 job ahead of a weight-1 job, B's local weight of 0 said the head job fit; Redis refused it; the scheduler requeued it and ended the pass. The weight-1 job that fitted the real distributed capacity was never attempted, so throughput collapsed to the head job's retry rate. A capacity refusal is now read as information about the store — that weight does not fit, so nothing at least as heavy does either — which lowers a per-pass ceiling and lets strictly lighter queued jobs be considered in the same pass. Refused jobs keep their sequence numbers, so priority and FIFO order are unchanged; aMinTimerefusal still ends the pass, because spacing gates the limiter rather than one job.SchedStrictis untouched.- Redis orphan reconciliation compares membership, not cardinality. The
acquire script only reconciled when
HLEN(leases) ~= ZCARD(expirations), which misses equal-cardinality corruption: a lease hash holding token A and an expiry ZSET holding token B are both size 1, so nothing was repaired and token A consumed capacity forever with no expiry entry that could ever release it. The running weight is now summed by deterministic read-onlyZRANGErank pagination over the expiry ZSET and reading each token's weight withHMGET(at most 256 member names per rank page), avoidingZSCANduplicate count inflation. A weight with no expiry entry is never counted, and expiry entries with no weight are removed viaZSCAN/HSCAN(where duplicate scan returns are harmless for idempotent deletions).last-start, live leases and the configuration record are not touched. See "Orphan reconciliation" in the README for exact guarantees. - The legacy Redis spacing path clamps a backwards clock. The lease script
already capped a computed wait at
MinTime;Requestdid not, so a Redis server whose clock stepped backwards reported a wait far longer than the window itself and stalled the caller past the spacing it actually owed. - Arithmetic on state windows and running weight cannot overflow.
2 * MinTime— the legacy state TTL and the spacing window — wrapped negative past half theDurationrange, which compared as "shorter than the default" and left the window unprotected, the opposite of what the doubling is for. Weight sums now saturate instead of wrapping negative, since a negative total reads as free capacity, and capacity comparisons are written as subtraction against the limit so no sum is formed that could exceedinton a 32-bit build. Microsecond values arriving from Redis are clamped on conversion, so a nonsense reply cannot become a negative wait that the scheduler reads as "no deadline". - The release workflow gates publication instead of reporting on it. It was
tag-triggered: by the time it ran, the tag existed and the module was already
resolvable, so a failing test could not stop a release. It also called
gh release createunconditionally, which failed with "already exists" when the release had been created by hand — and took the dependent Go proxy warm-up down with it. That is why the historicalv1.1.0Release run is red. See "Releasing" in the README for the replacement.
ErrValueOutOfRange, returned whenMaxConcurrent, a job weight, orMinTimeorLeaseTTLas a microsecond count exceeds 2^53-1. Redis decides admission inside Lua, whose numbers are IEEE-754 doubles, so above that boundary two distinct limits can compare equal and a limit can shift as it crosses it — the limit being enforced would not be the limit that was configured. Rejecting the value is preferred to enforcing it approximately.- An
actionlintjob in CI, andmake lint-workflows, so a mistake in the release workflow surfaces on every push rather than when someone next tries to cut a release. - Regression tests for each of the above:
tests/store_validation_test.go(direct-call validation, numeric boundaries),tests/bestfit_test.go(distributed best fit, including two limiters over two Redis stores) andtests/redis_orphan_test.go(every shape of orphan corruption, bounded repair, and a command-count assertion that a healthy limiter does not scan the lease hash).
-
Direct datastore calls reject invalid options rather than proceeding. This is the intended fix above, but it is a behavior change for code that called
RequestorAcquiredirectly with options a limiter would have rejected: such a call now returns the corresponding sentinel instead of admitting the job. Every sentinel anderrors.Isrelationship is unchanged.The validator is
Options.Validatein full, including the scheduler-only fields (SchedPolicy,RetryInterval,MaxQueueSize) that no store reads. A negativeRetryIntervalis a configuration mistake wherever it appears, and one validator with one behavior is worth more than a store-specific subset where which mistakes are caught depends on the entry point used. -
The release workflow is
workflow_dispatchonly. Pushing a tag no longer triggers a release. See "Releasing" in the README.
go get github.com/AFZidan/gothrottlepackage main
import (
"fmt"
"time"
"github.com/AFZidan/gothrottle"
)
func main() {
// Create a limiter with local storage
limiter, err := gothrottle.NewLimiter(gothrottle.Options{
MaxConcurrent: 2, // Max 2 concurrent jobs
MinTime: 100 * time.Millisecond, // 100ms between jobs
})
if err != nil {
panic(err)
}
defer limiter.Stop()
// Schedule a job
result, err := limiter.Schedule(func() (interface{}, error) {
// Your work here
return "Hello, World!", nil
})
fmt.Println(result) // "Hello, World!"
}package main
import (
"time"
"github.com/AFZidan/gothrottle"
"github.com/go-redis/redis/v8"
)
func main() {
// Create Redis client
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer rdb.Close() // the client is yours; the limiter never closes it
// Create Redis store
store, err := gothrottle.NewRedisStore(rdb)
if err != nil {
panic(err)
}
defer store.Disconnect()
// Create limiter with Redis backend
limiter, err := gothrottle.NewLimiter(gothrottle.Options{
ID: "my-distributed-limiter", // Required for Redis
MaxConcurrent: 5,
MinTime: 200 * time.Millisecond,
Datastore: store,
})
if err != nil {
panic(err)
}
defer limiter.Stop()
// This limiter will now coordinate with other instances
// using the same Redis store and limiter ID
}type Options struct {
ID string // Unique ID for the limiter (required for Redis)
MaxConcurrent int // Maximum concurrent jobs (0 = unlimited)
MinTime time.Duration // Minimum time between jobs
Datastore Datastore // Storage backend (nil = LocalStore)
// Let Stop() disconnect an injected Datastore (default false)
CloseDatastoreOnStop bool
// How weighted jobs compete for capacity (default SchedStrict)
SchedPolicy SchedPolicy
// How often to re-check a distributed store while blocked (default 10ms)
RetryInterval time.Duration
// Cap on queued jobs; further submissions get ErrQueueFull (0 = unbounded)
MaxQueueSize int
// How long a capacity reservation survives without renewal (default 30s)
LeaseTTL time.Duration
// Receives errors that have no caller to return them to
OnError func(error)
}Negative values for MaxConcurrent, MinTime, MaxQueueSize, RetryInterval
or LeaseTTL are rejected rather than being treated as "unlimited" or "use the
default" — a miscalculated limit fails loudly instead of silently switching
throttling off. Zero keeps its meaning of "no limit" or "use the default". You
can call opts.Validate() yourself, and every admission path runs it: see
Validation applies to direct datastore calls.
MaxConcurrent and job weights are also capped at 2^53-1, and MinTime and
LeaseTTL at 2^53-1 microseconds (about 285 years). See Numeric
limits.
The scheduler is event-driven: it wakes when a job is enqueued, when a running
job releases capacity, or when a MinTime window expires. An idle limiter does
not wake at all, and a burst of jobs fills the available concurrency window
immediately instead of starting one job per tick.
Jobs are ordered by priority (higher first), and equal-priority jobs run in submission order (FIFO).
SchedPolicy decides what happens when the highest-priority job is too heavy
for the free capacity:
SchedStrict(default) — the heavy job holds the queue. Priority is never inverted, but capacity can sit idle while it waits.SchedBestFit— lighter, lower-priority jobs may use capacity the heavy job cannot fill yet. Better throughput, at the cost of letting light work overtake a heavy high-priority job.
RetryInterval only applies to distributed setups: when a shared store refuses
capacity, the release happens in another process and produces no local event, so
the scheduler re-checks on this interval.
Best fit works against the capacity the store has, not just what this limiter is running. That distinction matters because a limiter cannot see another process's reservations: its own running weight can be zero while the shared limit is fully committed elsewhere.
So when the store refuses a job for capacity, SchedBestFit treats the refusal
as information — that weight does not fit, therefore nothing at least as heavy
does either — and looks for a strictly lighter queued job in the same dispatch
pass. With a shared MaxConcurrent of 3, one process holding weight 2, and a
queue holding a high-priority weight-2 job ahead of a weight-1 job, the weight-1
job runs; the weight-2 job waits for capacity and runs when it frees up.
Three properties bound the behavior:
- Each refusal lowers the ceiling strictly, so every attempt in a pass is for a lighter job than the last. A pass cannot retry the same job or spin.
- A refused job keeps its sequence number, so priority and FIFO order among equal-priority jobs are unaffected by having been held back.
- A
MinTimerefusal ends the pass. Spacing gates the limiter rather than one job, so no lighter job could start either, and asking would only spend datastore round trips.
Under SchedStrict a refusal still ends the pass immediately: the head job holds
the queue until the store admits it.
Creates a new limiter instance.
Schedules a job with default priority (5) and weight (1). Blocks until completion.
Schedules a job with custom priority and weight. Higher priority jobs run first.
Returns ErrNilTask for nil tasks, ErrInvalidWeight for non-positive weights, and ErrWeightExceedsMax when a weighted job cannot fit within the configured MaxConcurrent limit.
Returns a wrapped version of the function that applies rate limiting.
Like Schedule, but bounded by a context. If ctx ends while the job is still
queued, the job is removed from the queue and ctx.Err() is returned. A job that
has already started runs to completion — the limiter cannot interrupt a task
function — and its real result is returned rather than a cancellation that did
not happen.
ScheduleWithOptionsContext is the same with a custom priority and weight.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
result, err := limiter.ScheduleContext(ctx, fetchPage)
if errors.Is(err, context.DeadlineExceeded) {
// gave up waiting in the queue
}Point-in-time queue depth and the total weight currently executing, for
monitoring against MaxQueueSize and MaxConcurrent.
Stops the limiter and cleans up resources.
Stop cancels queued jobs with ErrStoreClosed, waits for running jobs to
finish, and guarantees no task starts after shutdown begins — including a task
whose capacity request was already in flight. It is safe to call concurrently
and repeatedly; every caller blocks until shutdown completes and receives the
same error. Do not call Stop from inside a scheduled task: it waits for that
task to finish.
Two contexts are in play, and they answer different questions.
The context you pass to ScheduleContext bounds your wait. Cancel it while the
job is queued and the job is removed and ctx.Err() returned; cancel it after the
job has started and the limiter waits for the real result, because a func()
cannot be interrupted.
The limiter also owns a context, cancelled when Stop begins, that it passes to
LeaseDatastore calls. A store blocked in Acquire or Renew — on a slow
network, a lock, a queue of its own — is therefore released by shutdown rather
than holding it open. If shutdown cancels an acquisition, the queued caller
receives ErrStoreClosed: the job never ran, so the terminal shutdown error is
the honest answer, not the internal cancellation.
Release is deliberately exempt. Handing capacity back has to succeed because
the process is going away, so it runs under a deadline of its own —
max(LeaseTTL, 5s), covering every retry. Past that the store reclaims the lease
anyway, so a wedged store is reported through OnError and abandoned instead of
blocking Stop indefinitely.
These guarantees need a LeaseDatastore. The legacy Datastore interface takes
no context, so Request and RegisterDone can only be waited out — one more
reason the lease path is the one the limiter prefers.
Some failures have no caller to return them to. The most important is a failure
to hand capacity back to the datastore after a job finishes: the store keeps that
capacity reserved for work that has already completed. The limiter retries, then
reports through OnError:
limiter, _ := gothrottle.NewLimiter(gothrottle.Options{
ID: "api",
MaxConcurrent: 10,
Datastore: store,
OnError: func(err error) {
log.Printf("gothrottle: %v", err)
},
})OnError is called from limiter goroutines, so it must be safe for concurrent
use and must not block or call back into the limiter.
Task panics are recovered and returned as a *PanicError that matches
errors.Is(err, ErrTaskPanic) and carries the stack trace:
var panicErr *gothrottle.PanicError
if errors.As(err, &panicErr) {
log.Printf("task panicked: %v\n%s", panicErr.Value, panicErr.Stack)
}A datastore you pass in stays yours. Stop only disconnects the LocalStore
the limiter creates for itself, so stopping one limiter cannot break other
limiters sharing the same store, or other parts of your application sharing the
same Redis client:
store, _ := gothrottle.NewRedisStore(rdb)
a, _ := gothrottle.NewLimiter(gothrottle.Options{ID: "a", Datastore: store})
b, _ := gothrottle.NewLimiter(gothrottle.Options{ID: "b", Datastore: store})
a.Stop() // b and rdb are unaffected
b.Stop()
store.Disconnect() // release the store when you are done with it
rdb.Close() // you own the client, so you close itSet CloseDatastoreOnStop: true to transfer ownership to a single limiter.
RedisStore.Disconnect() leaves the client open; RedisStore.Close() also
closes the client, for when the store is its sole user.
ErrMissingID: returned when a datastore-backed limiter is created without an ID.ErrInvalidID: returned when a limiter ID is too long or contains control characters.ErrInvalidWeight: returned when a job or datastore operation uses a non-positive weight.ErrWeightExceedsMax: returned when a job weight exceeds the configuredMaxConcurrentlimit.ErrNilTask: returned when scheduling a nil task function.ErrTaskPanic: matched by the*PanicErrorreturned when a scheduled task panics.ErrStoreClosed: returned when scheduling against a stopped limiter or closed datastore.ErrQueueFull: returned when the queue has reachedMaxQueueSize.ErrLimiterConfigMismatch: returned byAcquirewhen another instance already registered this limiter ID with a differentMaxConcurrent,MinTimeorLeaseTTL. See Same-ID configuration consistency.ErrInvalidMaxConcurrent,ErrInvalidMinTime,ErrInvalidMaxQueueSize,ErrInvalidRetryInterval,ErrInvalidLeaseTTL,ErrInvalidSchedPolicy: returned byNewLimiter,Options.Validateand every datastore admission call for negative or unknown configuration values.ErrValueOutOfRange: returned whenMaxConcurrent, a job weight, orMinTimeorLeaseTTLin microseconds exceeds what Redis Lua can compare exactly. See Numeric limits.ErrNilClient: returned byNewRedisStore(nil), including a typed nil such as(*redis.Client)(nil). It unwraps toErrStoreClosed, which is what earlier versions returned.
Datastore and LeaseDatastore are exported, so Request, Acquire,
RegisterDone, Renew and Release can be called without a limiter. Those
calls enforce the same rules NewLimiter does.
Request and Acquire run Options.Validate() in full and additionally reject a
non-positive weight, a weight above a positive MaxConcurrent, an out-of-range
weight, and a malformed limiter ID — plus an empty ID for RedisStore, where the
ID becomes part of a key. Validation happens before anything is generated or
written: no lease token, no Redis command, no local state, no configuration
registration, and no change to a TTL or the spacing record. A rejected call leaves
the store exactly as it was.
Options.Validate() is applied whole, including the three fields no store reads —
SchedPolicy, RetryInterval and MaxQueueSize. They shape how one process
queues its own work, so a store has no use for them, but a negative
RetryInterval is a configuration mistake wherever it appears, and one validator
with one behavior is easier to rely on than a store-specific subset where which
mistakes get caught depends on which entry point was used.
RegisterDone is deliberately narrower: it carries no Options, so only the
limiter ID and weight are checked.
Redis decides admission inside a Lua script, and Lua 5.1 numbers are IEEE-754
doubles: integers are exact only up to 2^53-1. Past that boundary two different
limits can compare equal, and a limit can shift as it crosses it — the limit being
enforced would not be the limit that was configured. Rather than enforce a limit
approximately, values that cannot survive the trip are rejected with
ErrValueOutOfRange:
| Value | Maximum |
|---|---|
MaxConcurrent |
2^53-1 (9,007,199,254,740,991) |
| Job weight | 2^53-1 |
MinTime |
2^53-1 microseconds (~285 years) |
LeaseTTL |
2^53-1 microseconds |
Everything below those bounds is exact. Arithmetic on values that pass validation
saturates rather than wrapping — the doubled state windows, running-weight sums —
because a wrapped negative would read as a shorter window or as free capacity,
and capacity comparisons are written as subtraction against the limit so no sum
is formed that could exceed int on a 32-bit build.
In-memory storage for single-instance applications. This is the default when no Datastore is specified.
store := gothrottle.NewLocalStore()Redis-based storage for distributed rate limiting across multiple application instances.
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
store, err := gothrottle.NewRedisStore(rdb)Disconnect() releases the store but leaves rdb open, because the client is
yours. Use Close() when the store is the only user of the client.
NewRedisStore takes go-redis's redis.UniversalClient, which *redis.Client
satisfies — so the call above is unchanged — as do *redis.ClusterClient,
*redis.Ring and the Sentinel-backed failover client. They compile and construct;
what the tests actually demonstrate for each is set out under Cluster, Ring and
Sentinel support status. A typed nil such as
(*redis.Client)(nil) is rejected with ErrNilClient rather than accepted and
panicked on later.
A limiter ID names a shared policy, so every instance using it must agree on the settings that decide admission:
| Must match across instances | May differ per instance |
|---|---|
MaxConcurrent, MinTime, LeaseTTL |
RetryInterval, MaxQueueSize, SchedPolicy, OnError, CloseDatastoreOnStop |
The first acquisition for an ID records the left-hand column; a later one that
disagrees is refused with an error matching ErrLimiterConfigMismatch, naming
both configurations. Without this the effective distributed limit would be
whichever process happened to reach Redis first — a deployment mid-rollout would
enforce two different limits at once and neither reliably. The right-hand column
only shapes how one process queues its own work, so it is not compared.
_, err := limiter.Schedule(work)
if errors.Is(err, gothrottle.ErrLimiterConfigMismatch) {
// another instance registered this ID with different limits
log.Fatalf("gothrottle: %v", err)
}The record is not permanent. Once every lease has lapsed and the MinTime window
has closed — that is, once the limiter is genuinely idle — the ID can be
registered with new settings, so changing a limit is a matter of rolling out the
new configuration and letting the old one drain. Rolling out the change while
traffic is flowing will make the new instances report a mismatch until the old
ones stop.
Each limiter ID occupies four keys, all sharing one Redis Cluster hash tag:
gothrottle:{<tag>}:leases HASH lease token -> reserved weight
gothrottle:{<tag>}:expirations ZSET lease token -> expiry (µs, Redis clock)
gothrottle:{<tag>}:last-start STRING µs timestamp of the last admission
gothrottle:{<tag>}:config HASH MaxConcurrent, MinTime, LeaseTTL, ID
<tag> is a SHA-256 prefix of the limiter ID, not the ID itself: Redis takes the
slot from the first {...}, so an ID containing braces would otherwise choose its
own tag and could be steered onto another limiter's slot. RedisKeys(id) returns
the four names if you need to inspect or clear state operationally.
The legacy Request/RegisterDone path uses a single gothrottle:<id> hash,
available as RedisStateKey(id). It needs no tag, being single-key.
Two keys describe one reservation: a weight in the lease hash and an expiry score
in the ZSET. The scripts always write both together, so a mismatch is state this
package did not produce — a partial restore, a manual HDEL, an AOF truncated
between the two commands. It has to be survivable, because a weight the expiry
path can never reclaim would hold capacity until someone noticed by hand.
For a limiter with a positive MaxConcurrent, every acquisition reconciles by
membership. The expiry ZSET is authoritative for liveness, so the running weight
is summed by walking it with read-only ZRANGE rank pages and reading each token's
weight with HMGET. That gives three guarantees:
- A weight in the hash with no expiry entry is never counted, and is removed.
- An expiry entry with no weight is removed, so it cannot accumulate.
- A live lease is untouched: its weight still counts and it stays renewable.
The summation and repair operations use bounded pagination:
- Exact summation page size is bounded to at most 256 expiry-ZSET members per
ZRANGErank page. ZRANGEproduces at most 256 members by explicit rank range, which is passed directly toHMGET.- Orphan cleanup passes use
ZSCAN/HSCANbecause duplicate elements in scan replies are harmless for idempotentZREMandHDELoperations. COUNT 256onZSCAN/HSCANis a work hint, not a strict response-size bound. Depending on encoding and Redis implementation, a scan call may return more than the requested count, including an entire compactly encoded collection (reply size is not strictly bounded by 256).- Individual deletion command argument lists passed to
ZREMandHDELare explicitly re-chunked to <=256 arguments sounpack()never exceeds Lua's argument limits. - Total reconciliation work scales with the number of lease/expiry records that must be examined or repaired.
- The healthy lease hash is not
HSCANed:HSCANruns only when the live entry count disagrees withHLEN, which is precisely when an orphan field exists in the hash.
With MaxConcurrent at 0 the guarantee is narrower, deliberately. There is no
limit for an orphan to consume, so orphan state costs memory rather than
correctness — and the lease hash is not bounded by anything, so walking it on
every admission would be an unbounded cost paid by the configuration that needs it
least. An unlimited limiter therefore reconciles only when the O(1) cardinality
check shows a disagreement. Equal-cardinality corruption is left in place; it
blocks nothing, and the keys carry a TTL of twice the lease window, so one idle
window clears them.
last-start is never touched by reconciliation. Spacing is measured from when a
job started, so that record is not reservation state — see Spacing outlives
reservations.
Cluster, Ring and Sentinel support status. NewRedisStore takes
redis.UniversalClient, so a *redis.ClusterClient, a *redis.Ring and the
Sentinel-backed failover client all compile and construct. What the test suite
demonstrates is narrower than that, and the distinction is worth stating exactly:
| Configuration | Status |
|---|---|
| Standalone (Redis 6 and 7) | Covered by every Redis test in CI on both versions. |
*redis.Ring |
One test exercises acquire, renew, release, the concurrency limit and the legacy path through a single-node Ring. Multi-node sharding is not tested. |
*redis.ClusterClient |
Constructor and typed-nil handling only. No test runs against a real cluster. |
| Sentinel failover client | Not exercised by any test. |
The key layout is designed to be cluster-safe: all four keys for a limiter share
one hash tag, so the multi-key Lua scripts stay in a single slot, and the lease
scripts recover from NOSCRIPT with EVAL, which caches on whichever node serves
the key. That is a design property, not a tested one. Treat standalone as
supported; treat Cluster, multi-node Ring and Sentinel as untested until a real
multi-node test exists.
The package is built around a Datastore interface that allows pluggable storage backends:
type Datastore interface {
Request(limiterID string, weight int, opts Options) (canRun bool, waitTime time.Duration, err error)
RegisterDone(limiterID string, weight int) error
Disconnect() error
}A shared counter cannot distinguish a slow job from a dead one. Expiring the counter is the only way to keep a crashed process from holding capacity forever, but expiring it while a job is still running lets another job start over the limit — and the finished job's late decrement can then corrupt the newcomer's state.
LeaseDatastore tracks each reservation individually instead:
type LeaseDatastore interface {
Datastore
Acquire(ctx context.Context, limiterID string, weight int, opts Options) (*Lease, time.Duration, error)
Renew(ctx context.Context, lease *Lease) error
Release(ctx context.Context, lease *Lease) error
}Every lease has a unique token and its own expiry. The limiter renews while a job
runs, so a long job keeps its capacity; if the holder dies, renewal stops and the
capacity is reclaimed within LeaseTTL. Because Release names one token, a late
release from an expired job cannot disturb a newer lease.
Both LocalStore and RedisStore implement this, and the limiter uses it
automatically. The interface is additive: a custom Datastore that implements
only Request/RegisterDone still works, on the older counter semantics.
- LocalStore: Uses Go mutexes and in-memory state
- RedisStore: Uses atomic Lua scripts, with the clock read from Redis
TIMEso coordinating instances need not agree on the time
MinTime is measured from when a job started, so the record of that start has to
outlive the reservation it belongs to. The two are kept as separate state, and the
distinction matters in four cases:
| After | Why the window survives |
|---|---|
| Normal release | Release removes one lease token. It never touches the last-start record, so finishing early does not let the next job start early. |
| Renewal | Renewal knows only the lease TTL. It extends the reservation's lifetime and leaves the spacing record's alone, which is derived from MinTime. |
| Lease expiry | Reclaiming an expired lease purges reservation state only. The spacing record is not reservation state. |
| Process crash | Same path as expiry: the dead holder's capacity comes back, but the spacing window it opened runs its course. |
The failure mode this prevents is specific. Suppose MinTime: 45s with
LeaseTTL: 1s. If the spacing record's lifetime were tied to the lease — as it
was when start times were stored per lease token — a release or a renewal would
leave roughly two seconds protecting a forty-five second window. Once it expired,
the next job would start immediately, and nothing in the logs would say the limit
had stopped applying. A crashed holder was worse: purging its lease purged its
start time with it, so the crash itself granted the next job a free start.
So: the last-start value is written only on a successful admission, never deleted
or shortened by renewal, release or reclamation, and its own garbage-collection
window is derived from MinTime alone (at least 2 × MinTime). With MinTime
unset there is no window to enforce and no record is written at all.
Key lifetimes are only ever extended, never shortened. A short-lived operation —
a release with a one-second lease TTL, say — cannot cut short a key protecting a
longer window, in either the lease scripts or the legacy Request/RegisterDone
pair. The same asymmetry used to bite there: Request sized the state TTL to
cover MinTime, and RegisterDone reset it to a flat 30 seconds.
gothrottle/
├── datastore.go # Datastore interface definition
├── lease.go # LeaseDatastore interface and Lease type
├── options.go # Configuration options and validation
├── job.go # Job struct and priority queue
├── local_store.go # In-memory storage implementation
├── local_lease.go # In-memory lease implementation
├── redis_store.go # Redis-based storage implementation
├── redis_lease.go # Redis lease implementation and Lua scripts
├── limiter.go # Main Limiter struct and logic
├── bounds.go # Numeric policy: exact ranges and saturating arithmetic
├── errors.go # Common error definitions and PanicError
├── assets/ # Visual assets and branding
│ ├── logo.svg # Vector logo
│ ├── logo-*.png # PNG logos (64px, 128px, 256px, 512px)
│ ├── social-preview.svg # Social media preview (vector)
│ ├── social-preview-1280x640.png # GitHub social preview (PNG)
│ └── README.md # Asset documentation
├── tests/ # Test files
│ ├── examples_test.go # Basic usage examples
│ ├── limiter_test.go # Core limiter unit tests
│ ├── scheduler_test.go # Scheduler throughput and ordering
│ ├── shutdown_test.go # Shutdown and datastore ownership
│ ├── cancellation_test.go # Shutdown cancellation of store operations
│ ├── options_test.go # Configuration validation
│ ├── store_validation_test.go # Direct datastore validation and numeric bounds
│ ├── bestfit_test.go # SchedBestFit against distributed capacity
│ ├── context_test.go # Context cancellation and error reporting
│ ├── lease_test.go # Lease contract, both stores
│ ├── spacing_test.go # MinTime independence from lease lifecycle
│ ├── legacy_state_test.go # Request/RegisterDone state and TTL behavior
│ ├── config_consistency_test.go # Same-ID configuration agreement
│ ├── redis_keys_test.go # Key layout, hash tags, client types
│ ├── redis_orphan_test.go # Orphan reconciliation and bounded repair
│ ├── adversarial_test.go # Failure-scenario coverage
│ ├── integration_test.go # Integration tests and benchmarks
│ ├── redis_helpers_test.go # Redis test helpers
│ ├── database_test.go # Database throttling tests
│ └── advanced_database_test.go # Advanced DB operations with weights
├── .github/ # GitHub workflows and templates
│ ├── workflows/
│ │ ├── ci.yml # CI/CD pipeline
│ │ ├── release.yml # Gated, idempotent release (workflow_dispatch)
│ │ └── codeql.yml # Security analysis
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ ├── feature_request.md
│ │ └── documentation.md
│ └── pull_request_template.md
├── Makefile # Development commands and workflows
├── go.mod # Go module definition
├── go.sum # Go module checksums
├── docker-compose.test.yml # Docker testing environment
├── Dockerfile.test # Docker test container
├── README.md # This file
├── CONTRIBUTING.md # Contribution guidelines
├── CHANGELOG.md # Version history
├── SECURITY.md # Security policy
└── LICENSE # MIT License
See tests/examples_test.go for more detailed examples of usage patterns.
GoThrottle includes a comprehensive Makefile that provides all the common development commands. The Makefile offers a consistent and easy way to build, test, lint, and manage the project.
# Show all available commands
make help
# Install development tools (golangci-lint, gosec)
make install-tools
# Quick development workflow (format, vet, test)
make dev# Build and Test
make build # Build the project
make test # Run tests
make test-race # Run tests with race detector
make test-cover # Run tests with coverage
make test-bench # Run benchmarks
make test-all # Run all tests (race, coverage, benchmarks)
# Code Quality
make fmt # Format code
make fmt-check # Check if code is formatted
make vet # Run go vet
make lint # Run golangci-lint
make security # Run gosec security scan
make quality # Run all quality checks
# Coverage
make coverage-html # Generate HTML coverage report
make coverage-check # Check coverage meets minimum threshold (60%)
# Dependencies
make deps # Download dependencies
make verify # Verify dependencies
make mod-tidy # Tidy up go.mod and go.sum
make mod-update # Update dependencies to latest versions
# Cross-platform builds
make cross-build # Build for multiple platforms (Linux, macOS, Windows)
# CI Simulation
make ci # Simulate full CI pipeline locally
make release-check # Full release readiness check# Quick test cycle during development
make quick-test # Format → Vet → Test
# Quick build cycle
make quick-build # Format → Vet → Build
# Full quality gate (before committing)
make quality # Format check → Vet → Lint → Security scan
# Full CI simulation (before pushing)
make ci # Dependencies → Quality → All tests → Cross-buildThe project maintains a minimum code coverage of 60%. You can check if your changes meet this requirement:
make coverage-checkThis will run the tests with coverage and verify that the total coverage meets the minimum threshold.
For testing with Redis in an isolated environment:
make docker-test # Run tests in Docker with RedisFor continuous testing during development (requires entr):
make watch-test # Automatically run tests when files changeIf you prefer to run commands manually without the Makefile:
# Run all tests
go test ./tests/... -v
# Run benchmarks
go test ./tests/... -bench=. -benchmem
# Test with coverage
go test -v -race -coverprofile=coverage.out -coverpkg=./... ./tests/...
# Test a specific function
go test ./tests/... -run TestLimiter_MaxConcurrent -vThere is one authoritative release path: the Release workflow, triggered
manually. Pushing a tag does not release anything.
# Release the current tip of main
gh workflow run release.yml -f version=v1.1.1
# Or release an exact commit
gh workflow run release.yml -f version=v1.1.1 -f commit=<sha>
# Verify everything without creating a tag or a release
gh workflow run release.yml -f version=v1.1.1 -f dry_run=trueThe workflow verifies before it publishes, in this order:
- The version is a valid
vMAJOR.MINOR.PATCHsemantic version tag. - The commit exists and is an ancestor of
main. - If the tag already exists, it points at that same commit — otherwise the run fails rather than moving a published tag.
- Every check run for the commit has completed successfully, and no commit status is failing.
- The module path can carry this major version.
go vet, thegofmt -scheck, the full suite under-raceagainst Redis withREQUIRE_REDIS=true, a confirmation that Redis tests actually executed rather than skipped, and the cross-platform builds all pass — run against the exact commit being released, not against whatevermainhas moved on to.
Only then does the publish job create the tag and the release, and the Go proxy
warm-up runs after it.
Re-running is safe. An existing tag at the same commit is reused, never moved or
recreated; an existing release is inspected rather than recreated, so the Go proxy
warm-up still runs instead of being skipped by a failure. Two release runs cannot
overlap: a concurrency: release group makes the second wait.
Least privilege applies throughout — the workflow is contents: read and only the
publish job takes contents: write.
The v1.1.0 release was created by hand, then the old tag-triggered workflow ran
and called gh release create for a release that already existed, which failed
with Release.tag_name already exists. The dependent Go proxy job was skipped as
a result. Its Redis race tests had passed; nothing about v1.1.0's contents is in
question.
That run is left as it is. The tag and release are published artifacts that
consumers may already have resolved, and re-tagging to make an old workflow run
green would change what go get at that version means. The workflow that produced
it has been replaced, so the failure mode cannot recur.
MIT License - see LICENSE file for details.
GoThrottle is excellent for throttling database operations to prevent overwhelming your database with too many concurrent queries. This is especially useful for:
- Rate limiting API database calls
- Batch processing large datasets
- Preventing database connection pool exhaustion
- Distributed rate limiting across multiple application instances
package main
import (
"database/sql"
"gothrottle"
_ "github.com/lib/pq" // PostgreSQL driver
)
// DatabaseThrottler wraps database operations with rate limiting
type DatabaseThrottler struct {
db *sql.DB
limiter *gothrottle.Limiter
}
func NewDatabaseThrottler(db *sql.DB, opts gothrottle.Options) (*DatabaseThrottler, error) {
limiter, err := gothrottle.NewLimiter(opts)
if err != nil {
return nil, err
}
return &DatabaseThrottler{
db: db,
limiter: limiter,
}, nil
}
// Query executes a throttled database query
func (dt *DatabaseThrottler) Query(query string, args ...interface{}) (*sql.Rows, error) {
result, err := dt.limiter.Schedule(func() (interface{}, error) {
return dt.db.Query(query, args...)
})
if err != nil {
return nil, err
}
return result.(*sql.Rows), nil
}
func main() {
db, _ := sql.Open("postgres", "connection_string")
// Limit to 5 concurrent queries with 10ms between query starts
throttledDB, _ := NewDatabaseThrottler(db, gothrottle.Options{
MaxConcurrent: 5,
MinTime: 10 * time.Millisecond,
})
defer throttledDB.Close()
// Now all queries through throttledDB will be rate limited
rows, err := throttledDB.Query("SELECT * FROM users WHERE active = ?", true)
// ... handle results
}Different database operations can have different resource costs. You can assign weights:
// Light SELECT queries (weight 1)
rows, err := limiter.ScheduleWithOptions(func() (interface{}, error) {
return db.Query("SELECT id FROM users")
}, 5, 1) // Priority 5, Weight 1
// Heavy analytical queries (weight 5)
rows, err := limiter.ScheduleWithOptions(func() (interface{}, error) {
return db.Query("SELECT COUNT(*) FROM large_table GROUP BY complex_column")
}, 10, 5) // Priority 10, Weight 5
// With MaxConcurrent: 10, you can run either:
// - 10 light queries simultaneously, OR
// - 2 heavy queries simultaneously, OR
// - Some combination that doesn't exceed 10 total weightFor applications with multiple instances sharing the same database:
// Use Redis for distributed coordination
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
store, _ := gothrottle.NewRedisStore(rdb)
// All app instances using this same ID will share the rate limits
throttledDB, _ := NewDatabaseThrottler(db, gothrottle.Options{
ID: "shared-db-limiter",
MaxConcurrent: 20, // Total across ALL instances
MinTime: 5 * time.Millisecond,
Datastore: store,
})Protect your API endpoints from being overwhelmed:
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/AFZidan/gothrottle"
"github.com/go-redis/redis/v8"
)
// APIThrottler wraps HTTP handlers with rate limiting
type APIThrottler struct {
limiter *gothrottle.Limiter
}
func NewAPIThrottler(opts gothrottle.Options) (*APIThrottler, error) {
limiter, err := gothrottle.NewLimiter(opts)
if err != nil {
return nil, err
}
return &APIThrottler{limiter: limiter}, nil
}
// ThrottleHandler wraps an HTTP handler with rate limiting
func (at *APIThrottler) ThrottleHandler(handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, err := at.limiter.Schedule(func() (interface{}, error) {
handler(w, r)
return nil, nil
})
if err != nil {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
}
}
func main() {
// Create distributed rate limiter for API
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
store, _ := gothrottle.NewRedisStore(rdb)
throttler, _ := NewAPIThrottler(gothrottle.Options{
ID: "api-rate-limiter",
MaxConcurrent: 100, // Max 100 concurrent API requests
MinTime: 10 * time.Millisecond, // 10ms between requests
Datastore: store,
})
// Apply throttling to endpoints
http.HandleFunc("/api/users", throttler.ThrottleHandler(handleUsers))
http.HandleFunc("/api/orders", throttler.ThrottleHandler(handleOrders))
http.ListenAndServe(":8080", nil)
}
func handleUsers(w http.ResponseWriter, r *http.Request) {
// Simulate database query
time.Sleep(50 * time.Millisecond)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func handleOrders(w http.ResponseWriter, r *http.Request) {
// Simulate heavy database operation
time.Sleep(200 * time.Millisecond)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}Throttle file processing to prevent system overload:
package main
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"time"
"github.com/AFZidan/gothrottle"
)
type FileProcessor struct {
limiter *gothrottle.Limiter
}
func NewFileProcessor() *FileProcessor {
limiter, _ := gothrottle.NewLimiter(gothrottle.Options{
MaxConcurrent: 5, // Process max 5 files concurrently
MinTime: 100 * time.Millisecond, // 100ms between file processing
})
return &FileProcessor{limiter: limiter}
}
func (fp *FileProcessor) ProcessFile(filePath string) error {
_, err := fp.limiter.ScheduleWithOptions(func() (interface{}, error) {
// Determine file size for weight calculation
stat, err := os.Stat(filePath)
if err != nil {
return nil, err
}
// Read and process file
data, err := ioutil.ReadFile(filePath)
if err != nil {
return nil, err
}
// Simulate processing time based on file size
processingTime := time.Duration(len(data)/1024) * time.Millisecond
time.Sleep(processingTime)
fmt.Printf("Processed file: %s (%d bytes)\n", filePath, len(data))
return nil, nil
}, 5, fp.getFileWeight(filePath)) // Priority 5, weight based on file size
return err
}
func (fp *FileProcessor) getFileWeight(filePath string) int {
stat, err := os.Stat(filePath)
if err != nil {
return 1
}
// Weight based on file size (MB)
weight := int(stat.Size() / (1024 * 1024))
if weight < 1 {
weight = 1
}
if weight > 10 {
weight = 10 // Cap at weight 10
}
return weight
}
func (fp *FileProcessor) ProcessDirectory(dirPath string) error {
return filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
return fp.ProcessFile(path)
}
return nil
})
}
func (fp *FileProcessor) Close() {
fp.limiter.Stop()
}Respectful web scraping that doesn't overwhelm target servers:
package main
import (
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/AFZidan/gothrottle"
)
type WebScraper struct {
limiter *gothrottle.Limiter
client *http.Client
}
func NewWebScraper() *WebScraper {
// Respectful scraping limits
limiter, _ := gothrottle.NewLimiter(gothrottle.Options{
MaxConcurrent: 3, // Max 3 concurrent requests
MinTime: 2 * time.Second, // 2 seconds between requests
})
return &WebScraper{
limiter: limiter,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (ws *WebScraper) ScrapeURL(url string) (string, error) {
result, err := ws.limiter.Schedule(func() (interface{}, error) {
resp, err := ws.client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
fmt.Printf("Scraped: %s (%d bytes)\n", url, len(body))
return string(body), nil
})
if err != nil {
return "", err
}
return result.(string), nil
}
func (ws *WebScraper) ScrapeMultipleURLs(urls []string) []string {
results := make([]string, len(urls))
for i, url := range urls {
content, err := ws.ScrapeURL(url)
if err != nil {
fmt.Printf("Error scraping %s: %v\n", url, err)
continue
}
results[i] = content
}
return results
}
func (ws *WebScraper) Close() {
ws.limiter.Stop()
}Throttle background jobs to prevent resource exhaustion:
package main
import (
"fmt"
"sync"
"time"
"github.com/AFZidan/gothrottle"
)
type JobType int
const (
EmailJob JobType = iota
ReportJob
DataSyncJob
ImageProcessingJob
)
type Job struct {
ID string
Type JobType
Data interface{}
Priority int
}
type JobProcessor struct {
limiter *gothrottle.Limiter
}
func NewJobProcessor() *JobProcessor {
limiter, _ := gothrottle.NewLimiter(gothrottle.Options{
MaxConcurrent: 10, // Max 10 concurrent jobs
MinTime: 50 * time.Millisecond, // 50ms between job starts
})
return &JobProcessor{limiter: limiter}
}
func (jp *JobProcessor) ProcessJob(job Job) error {
priority := job.Priority
weight := jp.getJobWeight(job.Type)
_, err := jp.limiter.ScheduleWithOptions(func() (interface{}, error) {
return jp.executeJob(job)
}, priority, weight)
return err
}
func (jp *JobProcessor) getJobWeight(jobType JobType) int {
switch jobType {
case EmailJob:
return 1 // Light operation
case ReportJob:
return 3 // Medium operation
case DataSyncJob:
return 5 // Heavy operation
case ImageProcessingJob:
return 8 // Very heavy operation
default:
return 1
}
}
func (jp *JobProcessor) executeJob(job Job) (interface{}, error) {
start := time.Now()
switch job.Type {
case EmailJob:
return jp.processEmail(job)
case ReportJob:
return jp.generateReport(job)
case DataSyncJob:
return jp.syncData(job)
case ImageProcessingJob:
return jp.processImage(job)
}
fmt.Printf("Job %s completed in %v\n", job.ID, time.Since(start))
return nil, nil
}
func (jp *JobProcessor) processEmail(job Job) (interface{}, error) {
time.Sleep(100 * time.Millisecond) // Simulate email sending
fmt.Printf("Email sent: %s\n", job.ID)
return "email_sent", nil
}
func (jp *JobProcessor) generateReport(job Job) (interface{}, error) {
time.Sleep(2 * time.Second) // Simulate report generation
fmt.Printf("Report generated: %s\n", job.ID)
return "report_generated", nil
}
func (jp *JobProcessor) syncData(job Job) (interface{}, error) {
time.Sleep(5 * time.Second) // Simulate data sync
fmt.Printf("Data synced: %s\n", job.ID)
return "data_synced", nil
}
func (jp *JobProcessor) processImage(job Job) (interface{}, error) {
time.Sleep(10 * time.Second) // Simulate image processing
fmt.Printf("Image processed: %s\n", job.ID)
return "image_processed", nil
}
func (jp *JobProcessor) ProcessJobsConcurrently(jobs []Job) {
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func(j Job) {
defer wg.Done()
if err := jp.ProcessJob(j); err != nil {
fmt.Printf("Job %s failed: %v\n", j.ID, err)
}
}(job)
}
wg.Wait()
}
func (jp *JobProcessor) Close() {
jp.limiter.Stop()
}Rate limit calls between microservices:
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/AFZidan/gothrottle"
"github.com/go-redis/redis/v8"
)
type ServiceClient struct {
limiter *gothrottle.Limiter
baseURL string
httpClient *http.Client
}
func NewServiceClient(serviceName, baseURL string) *ServiceClient {
// Use Redis for distributed rate limiting across service instances
rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
store, _ := gothrottle.NewRedisStore(rdb)
limiter, _ := gothrottle.NewLimiter(gothrottle.Options{
ID: fmt.Sprintf("service-client-%s", serviceName),
MaxConcurrent: 20, // Max 20 concurrent calls to this service
MinTime: 10 * time.Millisecond, // 10ms between calls
Datastore: store,
})
return &ServiceClient{
limiter: limiter,
baseURL: baseURL,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
func (sc *ServiceClient) Get(endpoint string) (*http.Response, error) {
result, err := sc.limiter.ScheduleWithOptions(func() (interface{}, error) {
url := sc.baseURL + endpoint
return sc.httpClient.Get(url)
}, 5, 1) // Normal priority, weight 1
if err != nil {
return nil, err
}
return result.(*http.Response), nil
}
func (sc *ServiceClient) Post(endpoint string, data interface{}) (*http.Response, error) {
result, err := sc.limiter.ScheduleWithOptions(func() (interface{}, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
url := sc.baseURL + endpoint
return sc.httpClient.Post(url, "application/json", bytes.NewBuffer(jsonData))
}, 6, 2) // Higher priority, weight 2 (POST is heavier)
if err != nil {
return nil, err
}
return result.(*http.Response), nil
}
func (sc *ServiceClient) BulkOperation(endpoint string, items []interface{}) error {
_, err := sc.limiter.ScheduleWithOptions(func() (interface{}, error) {
// Bulk operations are heavy and should have high priority
jsonData, err := json.Marshal(items)
if err != nil {
return nil, err
}
url := sc.baseURL + endpoint
resp, err := sc.httpClient.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
defer resp.Body.Close()
return resp, nil
}, 10, 5) // Highest priority, weight 5 (very heavy operation)
return err
}
func (sc *ServiceClient) Close() {
sc.limiter.Stop()
}
// Example usage in a microservice
func main() {
userService := NewServiceClient("user-service", "http://user-service:8080")
orderService := NewServiceClient("order-service", "http://order-service:8080")
defer userService.Close()
defer orderService.Close()
// These calls will be rate limited
userResp, _ := userService.Get("/api/users/123")
orderResp, _ := orderService.Post("/api/orders", map[string]interface{}{
"user_id": 123,
"amount": 99.99,
})
fmt.Printf("User response status: %d\n", userResp.StatusCode)
fmt.Printf("Order response status: %d\n", orderResp.StatusCode)
}Control data extraction, transformation, and loading processes:
package main
import (
"database/sql"
"fmt"
"time"
"github.com/AFZidan/gothrottle"
_ "github.com/lib/pq"
)
type ETLPipeline struct {
extractLimiter *gothrottle.Limiter
transformLimiter *gothrottle.Limiter
loadLimiter *gothrottle.Limiter
sourceDB *sql.DB
targetDB *sql.DB
}
func NewETLPipeline(sourceDB, targetDB *sql.DB) *ETLPipeline {
// Different rate limits for different stages
extractLimiter, _ := gothrottle.NewLimiter(gothrottle.Options{
MaxConcurrent: 5, // Limit source DB queries
MinTime: 20 * time.Millisecond,
})
transformLimiter, _ := gothrottle.NewLimiter(gothrottle.Options{
MaxConcurrent: 10, // CPU-intensive, but can be parallel
MinTime: 10 * time.Millisecond,
})
loadLimiter, _ := gothrottle.NewLimiter(gothrottle.Options{
MaxConcurrent: 3, // Limit target DB writes
MinTime: 50 * time.Millisecond,
})
return &ETLPipeline{
extractLimiter: extractLimiter,
transformLimiter: transformLimiter,
loadLimiter: loadLimiter,
sourceDB: sourceDB,
targetDB: targetDB,
}
}
func (etl *ETLPipeline) ExtractData(query string) ([]map[string]interface{}, error) {
result, err := etl.extractLimiter.Schedule(func() (interface{}, error) {
rows, err := etl.sourceDB.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
// Process rows into data structure
var data []map[string]interface{}
// ... row processing logic
fmt.Printf("Extracted %d records\n", len(data))
return data, nil
})
if err != nil {
return nil, err
}
return result.([]map[string]interface{}), nil
}
func (etl *ETLPipeline) TransformData(data []map[string]interface{}) ([]map[string]interface{}, error) {
result, err := etl.transformLimiter.Schedule(func() (interface{}, error) {
// Simulate data transformation
time.Sleep(100 * time.Millisecond)
var transformed []map[string]interface{}
for _, record := range data {
// Transform each record
transformedRecord := make(map[string]interface{})
for k, v := range record {
transformedRecord[k+"_transformed"] = v
}
transformed = append(transformed, transformedRecord)
}
fmt.Printf("Transformed %d records\n", len(transformed))
return transformed, nil
})
if err != nil {
return nil, err
}
return result.([]map[string]interface{}), nil
}
func (etl *ETLPipeline) LoadData(data []map[string]interface{}) error {
_, err := etl.loadLimiter.Schedule(func() (interface{}, error) {
tx, err := etl.targetDB.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
for _, record := range data {
// Insert transformed record
// ... insert logic
}
err = tx.Commit()
if err != nil {
return nil, err
}
fmt.Printf("Loaded %d records\n", len(data))
return nil, nil
})
return err
}
func (etl *ETLPipeline) ProcessBatch(query string) error {
// Extract -> Transform -> Load pipeline
data, err := etl.ExtractData(query)
if err != nil {
return err
}
transformedData, err := etl.TransformData(data)
if err != nil {
return err
}
return etl.LoadData(transformedData)
}
func (etl *ETLPipeline) Close() {
etl.extractLimiter.Stop()
etl.transformLimiter.Stop()
etl.loadLimiter.Stop()
}