Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
e573cb3
main, fuseadapter: stop on SIGTERM/SIGINT and detach stale FUSE mounts
claude Oct 15, 2026
d75aca7
docs: filesystem guide reflects signal shutdown and stale-mount recovery
claude Oct 15, 2026
90e339d
main, fuseadapter: address review findings on signal stop and stale m…
claude Oct 15, 2026
f541436
fuseadapter, main: bound pre-mount work by the run context, resolve r…
claude Oct 15, 2026
c784758
fuseadapter, main: keep non-Linux builds, drain on filesystem startup…
claude Oct 15, 2026
ea0c2a4
fuseadapter, main: bound the liveness probe and never hang a signal s…
claude Oct 15, 2026
bf34868
main, fuseadapter: bound the unmount itself and keep symlink resoluti…
claude Oct 15, 2026
01a3f73
main: never let the cleanup entry re-wait on a stuck FUSE unmount
claude Oct 15, 2026
432b09e
main, fuseadapter: one shutdown budget, cancellable kernel mount, ear…
claude Oct 15, 2026
d3ce3d4
fuseadapter: serve before unmounting a mount abandoned by a cancelled…
claude Oct 15, 2026
43e3e64
fuseadapter: keep the kernel detach fallback reachable when a fusermo…
claude Oct 15, 2026
128cf37
docs: point the stale-mount note at startup step 3
claude Oct 15, 2026
909090e
main: bound gRPC graceful stop and keep the serve-loop wait after a h…
claude Oct 15, 2026
9dcb445
docs: take main's README and filesystem guide before merging main
bootjp Oct 16, 2026
5a559a4
Merge branch 'main' into feature/modest-knuth-934a9a
bootjp Oct 16, 2026
a96874f
docs: filesystem guide and README describe signal stop and stale-moun…
bootjp Oct 16, 2026
9c2f9db
monitoring, adapter: bound HTTP shutdown and close what a stalled req…
bootjp Oct 16, 2026
8bbdead
adapter: SQS stop drains the HTTP listener under the shutdown bound
bootjp Oct 16, 2026
55c9336
adapter: bound the Redis shutdown wait and refuse commands once it began
bootjp Oct 16, 2026
59ae4ec
adapter: S3 stop drains HTTP and admin object operations before closi…
bootjp Oct 16, 2026
02a6201
adapter: count admin S3 object operations so Stop keeps the blob clus…
bootjp Oct 16, 2026
5687da4
adapter: join the SQS reaper and sweep workers before Stop returns
bootjp Oct 16, 2026
639f4b6
fuseadapter: detach only the server's own FUSE type, named after the …
bootjp Oct 16, 2026
de16949
main: widen the serve-loop timing margin and pin that Serve returns o…
bootjp Oct 16, 2026
5efa169
main: bound the admin listener shutdown and answer a stopping S3 node…
bootjp Oct 16, 2026
6c60784
fuseadapter: recheck the mount before trying the second detach helper
bootjp Oct 16, 2026
57241ff
fuseadapter: let a cancelled startup end the post-mount readiness wait
bootjp Oct 16, 2026
eba7347
fuseadapter: bound the mount-helper lookup by the detach budget
bootjp Oct 16, 2026
38965a9
fuseadapter: recheck the mount right before every detach helper
bootjp Oct 16, 2026
bea9aae
adapter: join the S3 background cleanups before Stop closes the blob …
bootjp Oct 16, 2026
283bc3f
adapter: join the S3 cleanups Stop cancelled before closing the blob …
bootjp Oct 16, 2026
ce411b7
docs: note that a stop during the store open needs SIGKILL if the ope…
bootjp Oct 16, 2026
4a4c47c
main: cancel before ending the serve loop in the cleanup-bound lifecy…
bootjp Oct 16, 2026
bbe0367
adapter: count admin object deletes before Stop closes the cleanup gate
bootjp Oct 16, 2026
816a0d1
fuseadapter: cancel in-flight requests when the shutdown detaches lazily
bootjp Oct 16, 2026
8cf7c48
adapter: join DynamoDB table cleanups before Stop returns
bootjp Oct 16, 2026
14ad332
main: cancel on the first shutdown signal before logging it
bootjp Oct 17, 2026
b152648
main: restore main.go content
bootjp Oct 17, 2026
512fc52
adapter: mark ErrAdminS3Stopping as not-leader ahead of the main merge
bootjp Oct 17, 2026
0b845d1
Merge branch 'main' into feature/modest-knuth-934a9a
bootjp Oct 17, 2026
5030ad7
fuseadapter: release a cancelled startup mount inside one window
bootjp Oct 17, 2026
66d7406
adapter: reserve a table cleanup before the delete that needs it commits
bootjp Oct 17, 2026
63bc505
fuseadapter: cancel and bound the release of a late or overdue mount
bootjp Oct 18, 2026
9ecaca9
adapter: let concurrent Stop calls share one drain and join deadline
bootjp Oct 18, 2026
bab4b5c
adapter: answer a stopping DynamoDB admin request as not-leader
bootjp Oct 18, 2026
69663e5
adapter: wrap the stopping sentinel at the table delete's return site
bootjp Oct 18, 2026
c1c7631
main: bound the wait for the signal handler goroutine on shutdown
bootjp Oct 18, 2026
3eb6989
adapter: join handlers on force-closed HTTP connections before Stop r…
bootjp Oct 22, 2026
86bca21
adapter: preserve cleanup reservations during shutdown
bootjp Oct 22, 2026
7f45508
Merge remote-tracking branch 'origin/main' into feature/pr1273-review…
bootjp Oct 22, 2026
7f7a74d
test: make FUSE detach helper markers portable
bootjp Oct 22, 2026
8b2f97b
Merge remote-tracking branch 'origin/main' into feature/pr1273-review…
bootjp Oct 22, 2026
a35bb91
main: join shutdown handlers and reap unmount helpers
bootjp Oct 22, 2026
6733614
test: supply contexts to admin HTTP request fixtures
bootjp Oct 22, 2026
8c5ad96
fuseadapter: reap startup unmount helpers before detach
bootjp Oct 22, 2026
4bc0239
Merge main while retaining shutdown and SQS audit state
bootjp Oct 22, 2026
95c62a6
Join late mount cleanup and close expired cleanup reservations
bootjp Oct 22, 2026
cd05729
Merge remote-tracking branch 'origin/main' into feature/pr1273-review…
bootjp Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ printf 'hello\n' > /tmp/elastickv-mount/docs/hello.txt
cat /tmp/elastickv-mount/docs/hello.txt
```

The root owner and mode flags apply when the filesystem root is first created; the defaults are UID/GID `0` and mode `0755`, and the mount enforces `default_permissions`, so set them to the user that will write to the mount. `--filesystemClientID` defaults to `--raftId` and identifies open-handle leases. `--filesystemCapacity` and `--filesystemMaxFiles` set values reported by `statfs`; they do not enforce write quotas. The server mounts through `fusermount3` (package `fuse3`) after its network listeners are up. Stopping the process with a signal leaves the mount attached in a `Transport endpoint is not connected` state; run `fusermount3 -uz <mount-point>` before restarting.
The root owner and mode flags apply when the filesystem root is first created; the defaults are UID/GID `0` and mode `0755`, and the mount enforces `default_permissions`, so set them to the user that will write to the mount. `--filesystemClientID` defaults to `--raftId` and identifies open-handle leases. `--filesystemCapacity` and `--filesystemMaxFiles` set values reported by `statfs`; they do not enforce write quotas. The server mounts through `fusermount3` (package `fuse3`) after its network listeners are up. The server unmounts the filesystem when it stops, including on `SIGTERM`/`SIGINT`, and a restart after `SIGKILL` or a crash detaches the dead mount it finds at the mount point before mounting again. See the [FUSE filesystem guide](docs/filesystem.md) for details.

This FUSE implementation supports regular files and directories, but not cross-directory rename, hard links, symbolic links, extended attributes, or full POSIX locking. Concurrent modifications from different mounts are resolved optimistically: the loser gets `EAGAIN`. Most operations can simply be retried, but `close` must not be, and an `O_APPEND` write that other mounts may also be appending to must not be either; the guide's Concurrency section has the per-operation rules. See the [FUSE filesystem guide](docs/filesystem.md) for prerequisites, multi-node setup, semantics, monitoring, and troubleshooting, and the [filesystem design](docs/design/2026_02_24_implemented_filesystem_on_elastickv.md) for the supported operations and placement model.

Expand Down
42 changes: 42 additions & 0 deletions adapter/background_reservation_shutdown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package adapter

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestBackgroundReservationStartsLiveAfterShutdownBound(t *testing.T) {
w := newBackgroundWorkers()
r, ok := w.reserve()
require.True(t, ok)
done := make(chan struct{})
go func() { w.stop("test", time.Millisecond, time.Second); close(done) }()
<-w.ctx.Done()
ran := make(chan error, 1)
r.start(time.Second, func(ctx context.Context) { ran <- ctx.Err() })
require.NoError(t, <-ran)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("stop did not join reserved cleanup")
}
}

func TestBackgroundReservationCannotStartAfterStopReturns(t *testing.T) {
w := newBackgroundWorkers()
r, ok := w.reserve()
require.True(t, ok)
w.stop("test", time.Millisecond, time.Millisecond)
ran := make(chan struct{}, 1)
r.start(time.Second, func(context.Context) { ran <- struct{}{} })
r.release()
require.Zero(t, w.gate.remaining())
select {
case <-ran:
t.Fatal("cleanup started after shutdown returned")
case <-time.After(time.Millisecond):
}
}
150 changes: 150 additions & 0 deletions adapter/background_workers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package adapter

import (
"context"
"log/slog"
"sync"
"time"
)

// backgroundWorkers runs the jobs a request starts but does not wait for,
// such as the cleanup of a deleted table's old generation. Each job is
// counted in an inflightGate so the server's Stop can join it, bounded,
// before the stores close underneath it. Stop cancels jobs already started
// once the bound has passed; reservations that commit later get a live
// timeout and stay counted in the final join. Reservations cannot start
// cleanup after that final join ends. This bounded drain does not provide
// durable recovery of cleanup for mutations that outlive shutdown.
// A nil *backgroundWorkers
// runs every job detached under context.Background() and has nothing to
// stop, which keeps zero-value servers in tests working.
type backgroundWorkers struct {
gate *inflightGate
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
running map[*backgroundReservation]context.CancelFunc
closed bool
}

func newBackgroundWorkers() *backgroundWorkers {
ctx, cancel := context.WithCancel(context.Background())
return &backgroundWorkers{gate: newInflightGate(), ctx: ctx, cancel: cancel, running: make(map[*backgroundReservation]context.CancelFunc)}
}

// backgroundReservation is a job slot claimed before the commit that makes
// the job necessary, so the job can still start after that commit even when
// stop began in between: stop waits for the slot as for a running job. The
// holder either starts the job through it or releases it.
type backgroundReservation struct {
mu sync.Mutex
w *backgroundWorkers
used bool
}

// reserve claims a job slot, or reports false once stop has begun, in which
// case the caller must not commit the change the job would follow up on.
func (w *backgroundWorkers) reserve() (*backgroundReservation, bool) {
if w != nil && !w.gate.enter() {
return nil, false
}
return &backgroundReservation{w: w}, true
}

// start runs run on its own goroutine under a context bounded by timeout,
// handing it the reserved slot. A reservation starts at most one job.
func (r *backgroundReservation) start(timeout time.Duration, run func(ctx context.Context)) {
r.mu.Lock()
defer r.mu.Unlock()
if r.used {
return
}
r.used = true
ctx, cancel := context.WithTimeout(context.Background(), timeout)
if r.w != nil {
r.w.mu.Lock()
if r.w.closed {
r.w.mu.Unlock()
cancel()
r.w.gate.exit()
return
}
r.w.running[r] = cancel
r.w.mu.Unlock()
}
go func() {
defer cancel()
if r.w != nil {
defer r.w.gate.exit()
defer func() {
r.w.mu.Lock()
delete(r.w.running, r)
r.w.mu.Unlock()
}()
}
run(ctx)
}()
}

// release gives back a slot no job was started through; it is a no-op once
// start ran or after an earlier release.
func (r *backgroundReservation) release() {
r.mu.Lock()
defer r.mu.Unlock()
if r.used {
return
}
r.used = true
if r.w != nil {
r.w.gate.exit()
}
}

// stop refuses new reservations, waits up to timeout for the reserved slots
// and the running jobs, then cancels the context of any jobs that remain and
// waits up to joinTimeout for them to return, so none is still dispatching
// when the stores close. A call made while an earlier one is still waiting
// waits until the same deadlines (see inflightGate); name labels the
// warnings it logs.
func (w *backgroundWorkers) stop(name string, timeout, joinTimeout time.Duration) {
if w == nil {
return
}
defer w.closeReservations()
finished := w.gate.drain(timeout)
w.cancelRunning()
if finished {
return
}
slog.Warn(name+": background jobs still running after the shutdown bound, cancelling them",
slog.Int64("in_flight", w.gate.remaining()),
slog.Duration("timeout", timeout),
)
if !w.gate.join(joinTimeout) {
Comment thread
bootjp marked this conversation as resolved.
Resolved
slog.Warn(name+": cancelled background jobs did not return, continuing shutdown",
slog.Int64("in_flight", w.gate.remaining()),
slog.Duration("timeout", joinTimeout),
)
}
}

func (w *backgroundWorkers) closeReservations() {
w.mu.Lock()
defer w.mu.Unlock()
w.closed = true
for _, cancel := range w.running {
cancel()
}
}

func (w *backgroundWorkers) cancelRunning() {
w.mu.Lock()
defer w.mu.Unlock()
w.cancel()
// Reservations still owned by a mutation are not jobs yet. If that
// mutation commits later, its cleanup gets a live, independently bounded
// context and remains counted in the join window.
for _, cancel := range w.running {
cancel()
}
}
36 changes: 35 additions & 1 deletion adapter/dynamodb.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ type DynamoDBServer struct {
store store.MVCCStore
coordinator kv.Coordinator
httpServer *http.Server
httpConns *httpConnTracker
targetHandlers map[string]func(http.ResponseWriter, *http.Request)
readTracker *kv.ActiveTimestampTracker
requestObserver monitoring.DynamoDBRequestObserver
Expand All @@ -138,6 +139,13 @@ type DynamoDBServer struct {
// Set ELASTICKV_DYNAMODB_ONEPHASE_DEDUP=0 or pass
// WithDynamoOnePhaseTxnDedup(false) for an operator rollback.
onePhaseTxnDedup bool

// tableCleanups runs the cleanup of a deleted table's old generation
// (see launchDeletedTableCleanup) so Stop can join it, bounded, before
// the stores close underneath it. Its slot is reserved before the change
// that orphans the generation commits, whichever surface (public
// listener, admin HTTP or gRPC forwarder) the change came through.
tableCleanups *backgroundWorkers
}

// WithDynamoDBRequestObserver enables Prometheus-compatible request metrics.
Expand Down Expand Up @@ -264,6 +272,7 @@ func NewDynamoDBServer(listen net.Listener, st store.MVCCStore, coordinate kv.Co
store: st,
coordinator: kv.WithKeyVizLabel(coordinate, keyviz.LabelDynamo),
onePhaseTxnDedup: os.Getenv("ELASTICKV_DYNAMODB_ONEPHASE_DEDUP") != "0",
tableCleanups: newBackgroundWorkers(),
}
for i := range d.itemUpdateLocks {
d.itemUpdateLocks[i] = make(chan struct{}, 1)
Expand All @@ -287,6 +296,7 @@ func NewDynamoDBServer(listen net.Listener, st store.MVCCStore, coordinate kv.Co
mux := http.NewServeMux()
mux.HandleFunc("/", d.handle)
d.httpServer = &http.Server{Handler: mux, ReadHeaderTimeout: time.Second}
d.httpConns = trackHTTPConns(d.httpServer)
for _, opt := range opts {
if opt != nil {
opt(d)
Expand All @@ -308,12 +318,36 @@ func (d *DynamoDBServer) Run() error {
return nil
}

// Stop drains the HTTP listener (bounded, see shutdownHTTPServer), then
// waits, under one more bound, for the deleted-table cleanups already
// started or reserved by a delete still committing, cancelling and briefly
// joining any still running, so none is dispatching when the stores close
// after Stop. From then on a change that would need a cleanup is refused
// with ErrDynamoServerStopping.
func (d *DynamoDBServer) Stop() {
if d.httpServer != nil {
_ = d.httpServer.Shutdown(context.Background())
shutdownHTTPServer(d.httpServer, d.httpConns, "dynamodb")
}
d.tableCleanups.stop("dynamodb server", httpShutdownTimeout, dynamoTableCleanupJoinTimeout)
Comment thread
bootjp marked this conversation as resolved.
Resolved
}

// ErrDynamoServerStopping is returned by a table delete, or a legacy-table
// migration step, that would orphan a table generation once Stop has begun:
// the generation's cleanup can no longer start, so the change is refused
// before it commits, and the table is left as it was. The public listener
// answers it with 503 ServiceUnavailable, which clients retry. It is also
// marked as ErrAdminNotLeader, so the admin bridge answers it with the
// not-leader response and the admin client retries against another node.
var ErrDynamoServerStopping = errors.Mark(
newDynamoAPIError(http.StatusServiceUnavailable, dynamoErrServiceUnavailable, "server is stopping"),
ErrAdminNotLeader,
)

// dynamoTableCleanupJoinTimeout bounds how long Stop waits, after
// cancelling the table cleanups that outlived the shutdown bound, for them
// to return. A variable so tests can shorten it.
var dynamoTableCleanupJoinTimeout = 2 * time.Second

// proxyToLeader forwards the HTTP request to the current DynamoDB leader when
// this node is not the Raft leader. Returns true if the request was handled
// (either proxied or an error response was written), false if the request
Expand Down
9 changes: 8 additions & 1 deletion adapter/dynamodb_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,13 @@ func (d *DynamoDBServer) finalizeLegacyTableMigration(ctx context.Context, schem
if err != nil {
return errors.WithStack(err)
}
// As for a table delete, the old generation's cleanup slot is claimed
// before the schema switch can commit.
cleanup, ok := d.tableCleanups.reserve()
if !ok {
return errors.WithStack(ErrDynamoServerStopping)
}
defer cleanup.release()
readTimestamp, err := d.beginTxnReadTimestamp(ctx, "dynamodb finalize migration: begin read timestamp", dynamoTableMetaKey(schema.TableName))
if err != nil {
return errors.WithStack(err)
Expand All @@ -334,6 +341,6 @@ func (d *DynamoDBServer) finalizeLegacyTableMigration(ctx context.Context, schem
if _, err := kv.DispatchWithReadTimestamp(dispatchCtx, d.coordinator, req); err != nil {
return errors.WithStack(err)
}
d.launchDeletedTableCleanup(schema.TableName, oldGeneration)
d.launchDeletedTableCleanup(cleanup, schema.TableName, oldGeneration)
return nil
}
21 changes: 15 additions & 6 deletions adapter/dynamodb_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@ func decodeDeleteTableInput(bodyReader io.Reader) (deleteTableInput, error) {
}

func (d *DynamoDBServer) deleteTableWithRetry(ctx context.Context, tableName string) error {
// Claim the cleanup's slot before the metadata delete can commit: once
// Stop has begun the cleanup could no longer start, and a committed
// delete without it would leave the generation's items behind.
cleanup, ok := d.tableCleanups.reserve()
if !ok {
return errors.WithStack(ErrDynamoServerStopping)
}
defer cleanup.release()
backoff := transactRetryInitialBackoff
deadline := time.Now().Add(transactRetryMaxDuration)
for range transactRetryMaxAttempts {
Expand Down Expand Up @@ -324,7 +332,7 @@ func (d *DynamoDBServer) deleteTableWithRetry(ctx context.Context, tableName str
return errors.WithStack(err)
}
} else {
d.launchDeletedTableCleanup(tableName, schema.Generation)
d.launchDeletedTableCleanup(cleanup, tableName, schema.Generation)
return nil
}

Expand All @@ -336,18 +344,19 @@ func (d *DynamoDBServer) deleteTableWithRetry(ctx context.Context, tableName str
return newDynamoAPIError(http.StatusInternalServerError, dynamoErrInternal, "delete table retry attempts exhausted")
}

func (d *DynamoDBServer) launchDeletedTableCleanup(tableName string, generation uint64) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), tableCleanupAsyncTimeout)
defer cancel()
// launchDeletedTableCleanup removes a deleted generation's items, seals and
// index entries in the background, through the slot the caller reserved
// before committing the change that orphaned them.
func (d *DynamoDBServer) launchDeletedTableCleanup(cleanup *backgroundReservation, tableName string, generation uint64) {
cleanup.start(tableCleanupAsyncTimeout, func(ctx context.Context) {
if err := d.cleanupDeletedTableGeneration(ctx, tableName, generation); err != nil {
slog.Error("dynamodb delete table cleanup failed",
"table", tableName,
"generation", generation,
"error", err,
)
}
}()
})
}

func (d *DynamoDBServer) cleanupDeletedTableGeneration(ctx context.Context, tableName string, generation uint64) error {
Expand Down
Loading
Loading