Compare commits

..
Author SHA1 Message Date
patternihaandClaude Opus 5.5 5996286a89 XDRIVE transport: Retry "local" Get on Windows sharing violations
On Windows, os.Rename (MoveFileEx) opens the file with DELETE access,
renames it, then closes that handle. For a moment the new name is
already listed while the handle is still open, and os.ReadFile, which
shares only reading and writing, fails with ERROR_SHARING_VIOLATION.
The WAL reader lists a segment as soon as Put renames it into place,
and any Get error other than "not found" ends the stream:

  xdrive_test.go:143: ReadFull: transport/internet/xdrive: failed to read streams/<id>/c2s/000000001.seg > open ...\000000001.seg: The process cannot access the file because it is being used by another process.

Measured on Windows 11 (Defender real-time protection on, 22 logical
CPUs) with a reader that keeps opening the new name during the rename:
98.6% of 5,001 renames hit the error, every failed open started before
MoveFileEx returned, and none failed with FILE_SHARE_DELETE added to
the open. The window is about 0.3 ms when idle, but 10 ms median and
up to 124 ms with all CPUs busy, which is why it shows up in full
`go test ./...` runs. Under such load (22 busy-loop processes) the
unfixed TestMultiSegmentTransfer, TestRoundTrip, TestInterleaved and
TestCloseEOF failed 4 of 60 runs; with this change, 0 of 120.

Get now retries os.ReadFile on ERROR_SHARING_VIOLATION for up to 2
seconds, as cmd/internal/robustio does, backing off from 1 ms to 100 ms
and returning early once ctx is done. Other platforms still call
os.ReadFile once. TestLocalSharingViolation holds a segment open with
DELETE access, as MoveFileEx does, and checks that Get waits for it.

Opening with FILE_SHARE_DELETE would avoid the conflict, but os.Open
cannot set the share mode, and a raw CreateFile would lose its long
path handling. Put's rename is unchanged: no rename failed in about
63,000 renames during these measurements, idle or under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:23:24 +03:30
6 changed files with 178 additions and 175 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ func (s *localStorage) Get(ctx context.Context, name string) ([]byte, error) {
if err != nil {
return nil, err
}
data, err := os.ReadFile(full)
data, err := readFile(ctx, full)
if err != nil {
if os.IsNotExist(err) {
return nil, errNotFound
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package xdrive
import (
"context"
"os"
)
func readFile(_ context.Context, name string) ([]byte, error) {
return os.ReadFile(name)
}
@@ -0,0 +1,30 @@
package xdrive
import (
"context"
"errors"
"os"
"time"
"golang.org/x/sys/windows"
)
// readFile retries os.ReadFile for up to 2 seconds on sharing violations:
// os.Rename (MoveFileEx) in Put holds the file with DELETE access, which
// os.ReadFile does not share, until a moment after its new name appears in List
func readFile(ctx context.Context, name string) ([]byte, error) {
deadline := time.Now().Add(2 * time.Second)
delay := time.Millisecond
for {
data, err := os.ReadFile(name)
if !errors.Is(err, windows.ERROR_SHARING_VIOLATION) || time.Now().Add(delay).After(deadline) {
return data, err
}
select {
case <-ctx.Done():
return nil, err
case <-time.After(delay):
}
delay = min(2*delay, 100*time.Millisecond)
}
}
@@ -0,0 +1,57 @@
package xdrive
import (
"context"
"errors"
"sync"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestLocalSharingViolation(t *testing.T) {
storage, err := newLocalStorage(t.TempDir())
if err != nil {
t.Fatalf("newLocalStorage: %v", err)
}
const name = "streams/session/c2s/000000000.seg"
if err := storage.Put(context.Background(), name, []byte("segment")); err != nil {
t.Fatalf("Put: %v", err)
}
full, err := storage.resolve(name)
if err != nil {
t.Fatalf("resolve: %v", err)
}
path, err := windows.UTF16PtrFromString(full)
if err != nil {
t.Fatalf("UTF16PtrFromString: %v", err)
}
// os.Rename holds the file like this until a moment after its new name
// appears
handle, err := windows.CreateFile(path, windows.DELETE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
nil, windows.OPEN_EXISTING, 0, 0)
if err != nil {
t.Fatalf("CreateFile: %v", err)
}
var once sync.Once
release := func() { once.Do(func() { windows.CloseHandle(handle) }) }
defer release()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := storage.Get(ctx, name); !errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
t.Fatalf("Get returned %v while the file is held, want a sharing violation", err)
}
time.AfterFunc(100*time.Millisecond, release)
data, err := storage.Get(context.Background(), name)
if err != nil {
t.Fatalf("Get: %v", err)
}
if string(data) != "segment" {
t.Fatalf("Get returned %q, want %q", data, "segment")
}
}
+12 -105
View File
@@ -22,12 +22,12 @@ import (
// reuses a slot only once that says what was in it before was taken
const (
slotMagic = "XDRVSL02"
slotMagic = "XDRVSL01"
slotHeaderLen = len(slotMagic) + 8 + 1 + 4 + 4
// slotInlineLimit is how large a segment is at most to go in the description
// of its slot, which takes one request less than a body
slotInlineLimit = 14 << 10
slotDepthLimit = 16
slotDepthLimit = 8
// inlineRampSegments segments of one size in a row double it, from
// slotInlineLimit up to segmentBytes
inlineRampSegments = 8
@@ -42,23 +42,16 @@ var (
// the head is asked for between slotHuntFloor and slotHuntCeiling, a segment
// behind it at about the pace that segments come
slotHuntFloor = 50 * time.Millisecond
slotHuntCeiling = 100 * time.Millisecond
slotHuntCeiling = 200 * time.Millisecond
slotTailMin = 150 * time.Millisecond
slotTailMax = 800 * time.Millisecond
slotFetchAhead = 10
slotFetchAhead = 6
slotAckRetry = 30 * time.Millisecond
slotWatermarkTTL = 100 * time.Millisecond
slotAckRetry = 100 * time.Millisecond
slotWatermarkTTL = 250 * time.Millisecond
slotStallTimeout = 60 * time.Second
slotCloseGrace = 10 * time.Second
slotGoneTimeout = 60 * time.Second
// a failed write of a segment is tried slotWriteAttempts times
slotWriteAttempts = 3
// a read of the head that has not answered for slotHedgeTick is asked a
// second time, for slotHedgeBudget at most
slotHedgeTick = 250 * time.Millisecond
slotHedgeBudget = time.Second
)
// slotStore is a storage of objects that are written and read again by id,
@@ -278,11 +271,6 @@ func (w *slotWriter) readyToFlush() bool {
grew := len(w.buf) > w.lastSize
w.lastSize = len(w.buf)
// slots are busy and the buffer small, so a bigger segment is worth the wait
if len(w.slots) > 0 && w.held < maxCoalescedTicks && len(w.buf) < w.size/2 {
w.held++
return false
}
if grew && w.held < maxCoalescedTicks {
w.held++
return false
@@ -308,12 +296,6 @@ func (w *slotWriter) flushLocked() error {
return w.err
}
// a ring three quarters busy is saturated by small segments, so widen at once
if w.size < w.segmentBytes && len(w.buf) >= w.size && len(w.slots)*4 > cap(w.slots)*3 {
w.size = w.segmentBytes
w.full = 0
}
n := min(len(w.buf), w.size)
if len(w.buf) > 4*slotInlineLimit {
// a burst goes at the full size at once
@@ -354,31 +336,11 @@ func (w *slotWriter) upload(seq int64, chunk []byte) {
w.fail(errors.New("failed to store segment ", seq).Base(err))
return
}
var lastErr error
for attempt := range slotWriteAttempts {
if attempt > 0 {
select {
case <-w.ctx.Done():
return
case <-time.After(time.Duration(attempt) * 200 * time.Millisecond):
}
// the failed write may have gone through
if d, err := w.store.readDescription(w.ctx, w.slotID(seq)); err == nil {
if f, ok := decodeSlot(d); ok && f.seq >= seq {
return
}
}
}
if lastErr = w.write(seq, mode, chunk); lastErr == nil {
return
}
if w.ctx.Err() != nil {
return
}
if err := w.write(seq, mode, chunk); err != nil {
w.fail(errors.New("failed to store segment ", seq).Base(err))
// the slot is free, the reader learns there what happened
w.write(seq, slotErr, nil)
}
w.fail(errors.New("failed to store segment ", seq).Base(lastErr))
// the slot is free, the reader learns there what happened
w.write(seq, slotErr, nil)
}
func (w *slotWriter) write(seq int64, mode byte, data []byte) error {
@@ -431,7 +393,7 @@ func (w *slotWriter) await(seq int64) error {
w.ackMu.Lock()
still := time.Since(w.progress)
w.ackMu.Unlock()
if min(time.Since(start), still) > slotStallTimeout || closed && still > slotCloseGrace {
if time.Since(start) > slotStallTimeout || closed && still > slotCloseGrace {
return errors.New("the reader took nothing from slot ", w.slotID(seq), " for ", still).Base(err)
}
@@ -636,13 +598,7 @@ func (r *slotReader) fetch(ctx context.Context, seq int64, offset int, f *slotFe
backoff := slotHuntFloor
var goneSince, warnedAt time.Time
for {
var description string
var err error
if r.isHead(seq) {
description, err = r.readHedged(ctx, id)
} else {
description, err = r.store.readDescription(ctx, id)
}
description, err := r.store.readDescription(ctx, id)
frame, ok := decodeSlot(description)
switch {
case err == nil && ok && frame.seq == seq:
@@ -706,55 +662,6 @@ func (r *slotReader) fetch(ctx context.Context, seq int64, offset int, f *slotFe
}
}
// readHedged reads the description of the head, and asks a second time if the
// first request is slow to answer, as one that is stuck costs more than one
// too many. The first good answer is taken
func (r *slotReader) readHedged(ctx context.Context, id string) (string, error) {
if slotHedgeTick <= 0 {
return r.store.readDescription(ctx, id)
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type result struct {
description string
err error
}
results := make(chan result, 2)
launch := func() {
go func() {
d, err := r.store.readDescription(ctx, id)
results <- result{d, err}
}()
}
launch()
outstanding := 1
deadline := time.Now().Add(slotHedgeBudget)
ticker := time.NewTicker(slotHedgeTick)
defer ticker.Stop()
var last result
for {
select {
case res := <-results:
outstanding--
if res.err == nil {
return res.description, nil
}
last = res
if outstanding == 0 {
return last.description, last.err
}
case <-ticker.C:
if outstanding < 2 && time.Now().Before(deadline) {
launch()
outstanding++
}
case <-ctx.Done():
return "", ctx.Err()
}
}
}
// pace is about how long segments take to come one after the other
func (r *slotReader) pace() time.Duration {
r.mu.Lock()
+66 -69
View File
@@ -53,8 +53,6 @@ type walWriter struct {
buf []byte
seq int64
lastSize int
segBytes int
ramped int
held int
closed bool
err error
@@ -68,10 +66,6 @@ func newWALWriter(ctx context.Context, storage Storage, prefix string, p params)
params: p,
sem: make(chan struct{}, p.concurrency),
}
// A session being set up is latency bound: small segments fit in a
// single inline create, so start there and double up to the configured
// size once full segments keep coming.
w.segBytes = min(p.segmentBytes, driveInlineLimit)
go w.flushLoop()
return w
}
@@ -88,7 +82,7 @@ func (w *walWriter) Write(p []byte) (int, error) {
}
w.buf = append(w.buf, p...)
for len(w.buf) >= w.segBytes {
for len(w.buf) >= w.segmentBytes {
if err := w.flushLocked(); err != nil {
return 0, err
}
@@ -139,15 +133,8 @@ func (w *walWriter) flushLocked() error {
}
n := len(w.buf)
if n > w.segBytes {
n = w.segBytes
}
if n == w.segBytes && w.segBytes < w.segmentBytes {
w.ramped++
if w.ramped >= inlineRampSegments {
w.ramped = 0
w.segBytes = min(w.segBytes*2, w.segmentBytes)
}
if n > w.segmentBytes {
n = w.segmentBytes
}
chunk := make([]byte, n)
@@ -337,75 +324,85 @@ func (r *walReader) poll() (advanced, eof bool, err error) {
r.holeSince = time.Time{}
}
type result struct {
chunk []byte
err error
}
outstanding := make(map[int64]chan result)
for {
if entry, ok := pending[r.seq]; ok && strings.HasSuffix(entry.Name, errSuffix) {
r.discard(r.prefix + "/" + entry.Name)
return advanced, false, errors.New("the peer could not store segment ", r.seq)
}
if entry, ok := pending[r.seq]; ok && strings.HasSuffix(entry.Name, endSuffix) && len(outstanding) == 0 {
r.discard(r.prefix + "/" + entry.Name)
batch, done := r.nextBatch(pending)
if done {
return advanced, true, nil
}
// keep the window of fetches full, so the next segments download
// while the head one is handed over
for i := int64(0); len(outstanding) < r.concurrency; i++ {
seq := r.seq + i
entry, ok := pending[seq]
if !ok || !strings.HasSuffix(entry.Name, segSuffix) {
break
}
if _, ok := outstanding[seq]; ok {
continue
}
ch := make(chan result, 1)
outstanding[seq] = ch
if entry.Inline != nil {
ch <- result{chunk: entry.Inline}
continue
}
go func(name string) {
chunk, err := r.storage.Get(r.ctx, r.prefix+"/"+name)
ch <- result{chunk, err}
}(entry.Name)
}
head, ok := outstanding[r.seq]
if !ok {
if len(batch) == 0 {
return advanced, false, nil
}
var res result
select {
case res = <-head:
case <-r.ctx.Done():
return advanced, true, nil
}
delete(outstanding, r.seq)
if res.err != nil {
if res.err == errNotFound {
chunks, err := r.fetch(batch)
if err != nil {
if err == errNotFound {
return advanced, false, nil
}
return advanced, false, res.err
return advanced, false, err
}
select {
case r.ch <- res.chunk:
case <-r.ctx.Done():
return advanced, true, nil
for i, chunk := range chunks {
select {
case r.ch <- chunk:
case <-r.ctx.Done():
return advanced, true, nil
}
r.seq++
advanced = true
r.discard(r.prefix + "/" + batch[i].Name)
}
name := pending[r.seq].Name
delete(pending, r.seq)
r.seq++
advanced = true
r.discard(r.prefix + "/" + name)
}
}
func (r *walReader) nextBatch(pending map[int64]Entry) (batch []Entry, done bool) {
for i := 0; i < r.concurrency; i++ {
entry, ok := pending[r.seq+int64(i)]
if !ok {
break
}
if !strings.HasSuffix(entry.Name, segSuffix) {
if i == 0 && strings.HasSuffix(entry.Name, endSuffix) {
r.discard(r.prefix + "/" + entry.Name)
return nil, true
}
break
}
batch = append(batch, entry)
}
return batch, false
}
func (r *walReader) fetch(batch []Entry) ([][]byte, error) {
chunks := make([][]byte, len(batch))
failures := make([]error, len(batch))
var wg sync.WaitGroup
for i, entry := range batch {
if entry.Inline != nil {
chunks[i] = entry.Inline
continue
}
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
chunks[i], failures[i] = r.storage.Get(r.ctx, r.prefix+"/"+name)
}(i, entry.Name)
}
wg.Wait()
for i := range batch {
if failures[i] != nil {
return nil, failures[i]
}
}
return chunks, nil
}
func (r *walReader) discard(name string) {
select {
case r.discards <- name: