From b74c3d9f8bb1ad498f96d8c6d2b43df00149a482 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 13:11:08 +0900 Subject: [PATCH 01/35] filesystem: require abort evidence for attempt resolution Signed-off-by: bootjp --- internal/filesystem/operation_journal_exec.go | 5 +++- internal/filesystem/operation_journal_test.go | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index 60b743a96..63a583af7 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -98,7 +98,10 @@ func classifyAttemptOutcome(id OperationID, attempt [16]byte, record operationRe return AttemptUnknown, record, err } if record.AttemptID != attempt { - return AttemptAborted, record, nil + // A newer generation is not an abort certificate for an older + // transaction. In particular, its primary decision may have + // committed before the operation row became visible here. + return AttemptUnknown, record, errors.WithStack(kv.ErrTransactionOutcomeUnknown) } if record.State == OperationCommitted && err == nil { return AttemptCommitted, record, nil diff --git a/internal/filesystem/operation_journal_test.go b/internal/filesystem/operation_journal_test.go index ecc6bca86..41df3c7a3 100644 --- a/internal/filesystem/operation_journal_test.go +++ b/internal/filesystem/operation_journal_test.go @@ -589,6 +589,32 @@ func TestOperationJournalDoesNotAdvanceWithoutAbortDecision(t *testing.T) { require.Equal(t, []byte("old"), value) } +func TestOperationJournalNewGenerationDoesNotProveOldAttemptAborted(t *testing.T) { + id := OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + oldAttempt := [16]byte{3} + newAttempt := [16]byte{4} + for _, tc := range []struct { + name string + state OperationState + }{ + {name: "pending", state: OperationPending}, + {name: "committed", state: OperationCommitted}, + {name: "rejected", state: OperationRejected}, + {name: "cancelled", state: OperationCancelled}, + } { + t.Run(tc.name, func(t *testing.T) { + record := operationRecord{ID: id, State: tc.state, AttemptID: newAttempt, Generation: 2} + var resolutionErr error + if tc.state == OperationPending { + resolutionErr = ErrOperationPending + } + outcome, _, err := classifyAttemptOutcome(id, oldAttempt, record, resolutionErr) + require.Equal(t, AttemptUnknown, outcome) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + }) + } +} + func TestOperationJournalDelayedCommitCannotBeSupersededWithoutDecision(t *testing.T) { ctx := context.Background() journal, st, _ := testOperationJournal(t) From 5e0bb8e5b90fcb8ae365de6961c9f527b60b8f21 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 13:33:37 +0900 Subject: [PATCH 02/35] filesystem: fence old journal attempts at transaction primary Signed-off-by: bootjp --- .../operation_codec_property_test.go | 26 ++++ internal/filesystem/operation_journal.go | 15 ++- internal/filesystem/operation_journal_exec.go | 68 ++++++++-- internal/filesystem/operation_journal_test.go | 126 ++++++++++++++++-- internal/filesystem/operation_record.go | 71 +++++++--- internal/filesystem/service.go | 12 ++ internal/fskeys/keys.go | 7 + kv/coordinator.go | 17 ++- kv/coordinator_txn_test.go | 28 ++++ kv/sharded_coordinator.go | 30 ++++- kv/sharded_coordinator_txn_test.go | 50 +++++++ kv/sharded_integration_test.go | 2 +- kv/transcoder.go | 4 + 13 files changed, 410 insertions(+), 46 deletions(-) diff --git a/internal/filesystem/operation_codec_property_test.go b/internal/filesystem/operation_codec_property_test.go index 2a77c0d3f..10ec50872 100644 --- a/internal/filesystem/operation_codec_property_test.go +++ b/internal/filesystem/operation_codec_property_test.go @@ -26,6 +26,32 @@ func TestOperationRecordCodecProperty(t *testing.T) { }) } +func TestOperationRecordCodecPreservesGenerationFence(t *testing.T) { + id := OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + record := newOperationRecord(id, []byte("write"), 128, 1024) + record.AttemptID = [16]byte{4} + record.Generation = 2 + record.FencedAttempts = [][16]byte{{3}} + raw, err := encodeOperationRecord(record) + require.NoError(t, err) + decoded, err := decodeOperationRecord(raw) + require.NoError(t, err) + require.Equal(t, record, decoded) + + decoded.FencedAttempts = append(decoded.FencedAttempts, [16]byte{3}) + _, err = encodeOperationRecord(decoded) + require.ErrorIs(t, err, ErrOperationRecordCorrupt) + + legacy := newOperationRecord(id, []byte("write"), 128, 1024) + legacy.RecordIsPrimary = false + raw, err = encodeOperationRecord(legacy) + require.NoError(t, err) + raw[4] = operationRecordLegacyVersion + decoded, err = decodeOperationRecord(raw) + require.NoError(t, err) + require.False(t, decoded.RecordIsPrimary) +} + func TestOperationSessionCodecProperty(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { owner := rapid.StringMatching(`[a-z]{1,16}`).Draw(rt, "owner") diff --git a/internal/filesystem/operation_journal.go b/internal/filesystem/operation_journal.go index a09390d1a..6db9bdf3b 100644 --- a/internal/filesystem/operation_journal.go +++ b/internal/filesystem/operation_journal.go @@ -45,7 +45,20 @@ func (b serviceOperationBackend) ScanAt(ctx context.Context, start, end []byte, } func (b serviceOperationBackend) Commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte) (*kv.CoordinateResponse, error) { - return b.service.dispatchTxnWithPolicy(ctx, ts, elems, readKeys, true) + var primary []byte + for _, elem := range elems { + if elem == nil { + return nil, errors.WithStack(ErrInvalid) + } + if !fskeys.IsOperationKey(elem.Key) { + continue + } + if primary != nil { + return nil, errors.WithStack(ErrInvalid) + } + primary = elem.Key + } + return b.service.dispatchTxnWithPrimaryPolicy(ctx, ts, elems, readKeys, true, primary) } func (b serviceOperationBackend) CommittedVersionAt(ctx context.Context, key []byte, ts uint64) (bool, error) { diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index 63a583af7..fbccb4c7c 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -43,14 +43,58 @@ func (j *OperationJournal) BeginAttempt(ctx context.Context, id OperationID) (op return operationRecord{}, errors.WithStack(ErrOperationGeneration) } -// AdvanceGeneration cannot proceed until an authoritative transaction-decision -// resolver proves the previous attempt aborted. The journal does not yet -// persist the underlying transaction identity, so it must fail closed rather -// than infer abort from an absent result row or an OCC conflict. -func (j *OperationJournal) AdvanceGeneration(_ context.Context, _ OperationID, _ [16]byte) (operationRecord, error) { +// AdvanceGeneration fences an old attempt by committing a new version of its +// transaction primary. A delayed old publication either committed that primary +// first (making this CAS fail) or must fail its read-set validation against the +// new version. Legacy rows without the primary guarantee remain unresolved. +func (j *OperationJournal) AdvanceGeneration(ctx context.Context, id OperationID, old [16]byte) (operationRecord, error) { + if old == [16]byte{} { + return operationRecord{}, errors.WithStack(ErrInvalid) + } + for range operationCASRetries { + record, err := j.advanceGenerationOnce(ctx, id, old) + if !IsWriteConflict(err) { + return record, err + } + } return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) } +func (j *OperationJournal) advanceGenerationOnce(ctx context.Context, id OperationID, old [16]byte) (operationRecord, error) { + ts, record, session, err := j.pendingSnapshot(ctx, id) + if err != nil { + return operationRecord{}, err + } + if slices.Contains(record.FencedAttempts, old) { + return record, nil + } + record, err = prepareGenerationFence(record, old) + if err != nil { + return operationRecord{}, err + } + if err = j.storeRecordAndSession(ctx, ts, record, session, nil, nil); err != nil { + return operationRecord{}, err + } + return record, nil +} + +func prepareGenerationFence(record operationRecord, old [16]byte) (operationRecord, error) { + if !record.RecordIsPrimary || record.AttemptID != old { + return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if record.Generation == ^uint64(0) || len(record.FencedAttempts) >= maxOperationFencedAttempts { + return operationRecord{}, errors.WithStack(ErrOperationGeneration) + } + next, err := randomOperationToken() + if err != nil { + return operationRecord{}, err + } + record.FencedAttempts = append(record.FencedAttempts, old) + record.AttemptID = next + record.Generation++ + return record, nil +} + type operationAttemptPlan struct { Intent []byte Effects []*kv.Elem[kv.OP] @@ -98,10 +142,7 @@ func classifyAttemptOutcome(id OperationID, attempt [16]byte, record operationRe return AttemptUnknown, record, err } if record.AttemptID != attempt { - // A newer generation is not an abort certificate for an older - // transaction. In particular, its primary decision may have - // committed before the operation row became visible here. - return AttemptUnknown, record, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + return classifyPriorAttempt(attempt, record) } if record.State == OperationCommitted && err == nil { return AttemptCommitted, record, nil @@ -112,6 +153,15 @@ func classifyAttemptOutcome(id OperationID, attempt [16]byte, record operationRe return AttemptUnknown, record, err } +func classifyPriorAttempt(attempt [16]byte, record operationRecord) (AttemptOutcome, operationRecord, error) { + if record.RecordIsPrimary && slices.Contains(record.FencedAttempts, attempt) { + return AttemptAborted, record, nil + } + // A different attempt ID without a durable primary fence is not an abort + // certificate for an older transaction. + return AttemptUnknown, record, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + // Publish commits the result record and every effect under the recorded // generation. The session key is also a mutation, so closing the session and // an in-flight attempt cannot both succeed on the same snapshot. diff --git a/internal/filesystem/operation_journal_test.go b/internal/filesystem/operation_journal_test.go index 41df3c7a3..46e79f95f 100644 --- a/internal/filesystem/operation_journal_test.go +++ b/internal/filesystem/operation_journal_test.go @@ -33,6 +33,39 @@ func testOperationID(t *testing.T, journal *OperationJournal, sequence uint64) O return OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: sequence} } +type primaryCaptureDispatcher struct { + inner *testCoordinator + primary []byte +} + +func (d *primaryCaptureDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + if len(req.PrimaryKey) != 0 { + d.primary = append([]byte(nil), req.PrimaryKey...) + } + return d.inner.Dispatch(ctx, req) +} + +func TestOperationJournalSelectsRecordAsTransactionPrimary(t *testing.T) { + ctx := context.Background() + st := store.NewMVCCStore() + dispatch := &primaryCaptureDispatcher{inner: &testCoordinator{st: st}} + service, err := NewService(st, dispatch) + require.NoError(t, err) + journal, err := NewOperationJournal(service) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + _, err = journal.Publish(ctx, id, attempt.AttemptID, operationAttemptPlan{ + Intent: []byte("write"), + Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("!fs|chk|data"), Value: []byte("value")}}, + }) + require.NoError(t, err) + require.Equal(t, id.key(), dispatch.primary) +} + func TestOperationJournalPublishesResultWithEffectAndRetiresIdentity(t *testing.T) { ctx := context.Background() journal, st, coord := testOperationJournal(t) @@ -204,6 +237,29 @@ type delayedOperationCommitBackend struct { release chan struct{} } +type lostGenerationFenceBackend struct { + operationJournalBackend + lose bool +} + +func (b *lostGenerationFenceBackend) Commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte) (*kv.CoordinateResponse, error) { + response, err := b.operationJournalBackend.Commit(ctx, ts, elems, readKeys) + if err != nil { + return response, err + } + for _, elem := range elems { + if !fskeys.IsOperationKey(elem.Key) { + continue + } + record, decodeErr := decodeOperationRecord(elem.Value) + if decodeErr == nil && len(record.FencedAttempts) != 0 && b.lose { + b.lose = false + return nil, kv.ErrTransactionOutcomeUnknown + } + } + return response, nil +} + func (b *delayedOperationCommitBackend) Commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte) (*kv.CoordinateResponse, error) { for _, elem := range elems { if bytes.Equal(elem.Key, []byte("data")) { @@ -562,7 +618,7 @@ func TestOperationJournalAcknowledgementGapsRetireWithoutReuse(t *testing.T) { } } -func TestOperationJournalDoesNotAdvanceWithoutAbortDecision(t *testing.T) { +func TestOperationJournalDoesNotAdvanceLegacyAttempt(t *testing.T) { ctx := context.Background() journal, st, _ := testOperationJournal(t) id := testOperationID(t, journal, 1) @@ -570,6 +626,10 @@ func TestOperationJournalDoesNotAdvanceWithoutAbortDecision(t *testing.T) { require.NoError(t, err) old, err := journal.BeginAttempt(ctx, id) require.NoError(t, err) + ts, legacy, session, err := journal.pendingSnapshot(ctx, id) + require.NoError(t, err) + legacy.RecordIsPrimary = false + require.NoError(t, journal.storeRecordAndSession(ctx, ts, legacy, session, nil, nil)) _, err = journal.AdvanceGeneration(ctx, id, old.AttemptID) require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) outcome, _, err := journal.ResolveAttempt(ctx, id, old.AttemptID) @@ -615,7 +675,7 @@ func TestOperationJournalNewGenerationDoesNotProveOldAttemptAborted(t *testing.T } } -func TestOperationJournalDelayedCommitCannotBeSupersededWithoutDecision(t *testing.T) { +func TestOperationJournalGenerationFenceStopsDelayedOldCommit(t *testing.T) { ctx := context.Background() journal, st, _ := testOperationJournal(t) id := testOperationID(t, journal, 1) @@ -637,16 +697,66 @@ func TestOperationJournalDelayedCommitCannotBeSupersededWithoutDecision(t *testi result <- publishErr }() <-backend.entered - _, err = journal.AdvanceGeneration(ctx, id, old.AttemptID) - require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + next, err := journal.AdvanceGeneration(ctx, id, old.AttemptID) + require.NoError(t, err) + require.NotEqual(t, old.AttemptID, next.AttemptID) + require.Equal(t, old.Generation+1, next.Generation) close(backend.release) - require.NoError(t, <-result) + require.Error(t, <-result) current, err := journal.BeginAttempt(ctx, id) - require.ErrorIs(t, err, ErrOperationGeneration) - require.Equal(t, operationRecord{}, current) + require.NoError(t, err) + require.Equal(t, next.AttemptID, current.AttemptID) + _, err = st.GetAt(ctx, []byte("data"), st.LastCommitTS()) + require.ErrorIs(t, err, store.ErrKeyNotFound) + outcome, _, err := journal.ResolveAttempt(ctx, id, old.AttemptID) + require.NoError(t, err) + require.Equal(t, AttemptAborted, outcome) + _, err = journal.Publish(ctx, id, next.AttemptID, operationAttemptPlan{ + Intent: []byte("write"), Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("data"), Value: []byte("new")}}, + }) + require.NoError(t, err) value, err := st.GetAt(ctx, []byte("data"), st.LastCommitTS()) require.NoError(t, err) - require.Equal(t, []byte("old"), value) + require.Equal(t, []byte("new"), value) +} + +func TestOperationJournalCommittedAttemptWinsGenerationFence(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + _, err = journal.Publish(ctx, id, attempt.AttemptID, operationAttemptPlan{ + Intent: []byte("write"), Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("data"), Value: []byte("old")}}, + }) + require.NoError(t, err) + _, err = journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, ErrOperationGeneration) + outcome, _, err := journal.ResolveAttempt(ctx, id, attempt.AttemptID) + require.NoError(t, err) + require.Equal(t, AttemptCommitted, outcome) +} + +func TestOperationJournalLostGenerationFenceReplyRetainsAbortProof(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + journal = newOperationJournalWithBackend(&lostGenerationFenceBackend{operationJournalBackend: journal.backend, lose: true}) + _, err = journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt.AttemptID) + require.NoError(t, err) + require.Equal(t, AttemptAborted, outcome) + require.NotEqual(t, attempt.AttemptID, record.AttemptID) + current, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.Equal(t, record.AttemptID, current.AttemptID) } func TestOperationJournalSnapshotRetainsTerminalAndPendingRecords(t *testing.T) { diff --git a/internal/filesystem/operation_record.go b/internal/filesystem/operation_record.go index 2ec9b3ddf..1a5a76e64 100644 --- a/internal/filesystem/operation_record.go +++ b/internal/filesystem/operation_record.go @@ -12,12 +12,14 @@ import ( ) const ( - operationRecordVersion = 2 + operationRecordVersion = 3 + operationRecordLegacyVersion = 2 operationHeaderBytes = 16 operationCommitTSOffset = 8 maxOperationRecordBytes = 8 << 20 operationReservationOverhead = 4096 maxOperationProofGroups = 16 + maxOperationFencedAttempts = 256 ) var ( @@ -54,25 +56,31 @@ type operationRecord struct { Intent []byte `json:"intent"` Generation uint64 `json:"generation"` AttemptID [16]byte `json:"attempt_id"` - Result []byte `json:"result,omitempty"` - EffectKeys [][]byte `json:"effect_keys,omitempty"` - ProofGroups []uint64 `json:"proof_groups,omitempty"` - Reservation uint64 `json:"reservation"` - ResultLimit uint32 `json:"result_limit"` - EffectLimit uint32 `json:"effect_limit"` - CommitTS uint64 `json:"-"` + // RecordIsPrimary certifies that each publication of this operation + // selects this row as its transaction primary. Legacy rows omit it and + // cannot use generation fencing. + RecordIsPrimary bool `json:"record_is_primary,omitempty"` + FencedAttempts [][16]byte `json:"fenced_attempts,omitempty"` + Result []byte `json:"result,omitempty"` + EffectKeys [][]byte `json:"effect_keys,omitempty"` + ProofGroups []uint64 `json:"proof_groups,omitempty"` + Reservation uint64 `json:"reservation"` + ResultLimit uint32 `json:"result_limit"` + EffectLimit uint32 `json:"effect_limit"` + CommitTS uint64 `json:"-"` } func newOperationRecord(id OperationID, intent []byte, resultLimit, effectLimit uint32) operationRecord { owned := append([]byte(nil), intent...) return operationRecord{ - ID: id, - State: OperationPending, - Fingerprint: sha256.Sum256(owned), - Intent: owned, - Reservation: 2*(uint64(len(owned))+uint64(resultLimit)+uint64(effectLimit)) + operationHeaderBytes + operationReservationOverhead, - ResultLimit: resultLimit, - EffectLimit: effectLimit, + ID: id, + State: OperationPending, + Fingerprint: sha256.Sum256(owned), + RecordIsPrimary: true, + Intent: owned, + Reservation: 2*(uint64(len(owned))+uint64(resultLimit)+uint64(effectLimit)) + operationHeaderBytes + operationReservationOverhead, + ResultLimit: resultLimit, + EffectLimit: effectLimit, } } @@ -110,28 +118,49 @@ func decodeOperationRecord(raw []byte) (operationRecord, error) { return operationRecord{}, errors.WithStack(ErrOperationRecordCorrupt) } record.CommitTS = binary.BigEndian.Uint64(raw[operationCommitTSOffset:]) - if record.State != OperationPending && record.CommitTS == 0 { - return operationRecord{}, errors.WithStack(ErrOperationRecordCorrupt) - } - if record.State != OperationState(raw[5]) || validateOperationRecord(record) != nil { + if !validDecodedOperationRecord(raw, record) { return operationRecord{}, errors.WithStack(ErrOperationRecordCorrupt) } return record, nil } +func validDecodedOperationRecord(raw []byte, record operationRecord) bool { + return (raw[4] != operationRecordLegacyVersion || (!record.RecordIsPrimary && len(record.FencedAttempts) == 0)) && + (record.State == OperationPending || record.CommitTS != 0) && + record.State == OperationState(raw[5]) && validateOperationRecord(record) == nil +} + func validOperationHeader(raw []byte) bool { return len(raw) >= operationHeaderBytes && len(raw) <= maxOperationRecordBytes && - bytes.Equal(raw[:4], []byte("FSOP")) && raw[4] == operationRecordVersion && + bytes.Equal(raw[:4], []byte("FSOP")) && + (raw[4] == operationRecordVersion || raw[4] == operationRecordLegacyVersion) && raw[6] == 0 && raw[7] == 0 } func validateOperationRecord(record operationRecord) error { - if !validOperationCore(record) || !validOperationEffects(record) || !validOperationState(record) { + if !validOperationCore(record) || !validOperationEffects(record) || !validOperationState(record) || !validFencedAttempts(record) { return errors.WithStack(ErrOperationRecordCorrupt) } return nil } +func validFencedAttempts(record operationRecord) bool { + if len(record.FencedAttempts) > maxOperationFencedAttempts || (len(record.FencedAttempts) != 0 && !record.RecordIsPrimary) { + return false + } + seen := make(map[[16]byte]struct{}, len(record.FencedAttempts)) + for _, attempt := range record.FencedAttempts { + if attempt == [16]byte{} || attempt == record.AttemptID { + return false + } + if _, duplicate := seen[attempt]; duplicate { + return false + } + seen[attempt] = struct{}{} + } + return true +} + func validOperationCore(record operationRecord) bool { return record.ID.Sequence != 0 && record.ID.Filesystem != [16]byte{} && record.ID.Session != [16]byte{} && record.Fingerprint == sha256.Sum256(record.Intent) && record.Reservation != 0 && diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index 9853094f9..a8314abba 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -2802,11 +2802,23 @@ func (s *Service) dispatchTxnWithPolicy( elems []*kv.Elem[kv.OP], readKeys [][]byte, requireAllParticipants bool, +) (*kv.CoordinateResponse, error) { + return s.dispatchTxnWithPrimaryPolicy(ctx, startTS, elems, readKeys, requireAllParticipants, nil) +} + +func (s *Service) dispatchTxnWithPrimaryPolicy( + ctx context.Context, + startTS uint64, + elems []*kv.Elem[kv.OP], + readKeys [][]byte, + requireAllParticipants bool, + primary []byte, ) (*kv.CoordinateResponse, error) { startTS = normalizeEmptyStoreReadTS(startTS) reqs := &kv.OperationGroup[kv.OP]{ Elems: elems, IsTxn: true, + PrimaryKey: primary, RequireAllParticipants: requireAllParticipants, StartTS: startTS, ReadKeys: readKeys, diff --git a/internal/fskeys/keys.go b/internal/fskeys/keys.go index aaf8a6883..738dc013c 100644 --- a/internal/fskeys/keys.go +++ b/internal/fskeys/keys.go @@ -73,6 +73,13 @@ func OperationKey(filesystemID, sessionID [16]byte, sequence uint64) []byte { return appendU64(out, sequence) } +// IsOperationKey identifies an exact operation row, excluding other journal +// metadata families and malformed prefixes. +func IsOperationKey(key []byte) bool { + return bytes.HasPrefix(key, []byte(operationPrefix)) && + len(key) == len(operationPrefix)+operationIdentityBytes+u64Bytes +} + // OperationSessionKey names one durable mount incarnation. func OperationSessionKey(filesystemID, sessionID [16]byte) []byte { out := make([]byte, 0, len(operationSessionPrefix)+operationIdentityBytes) diff --git a/kv/coordinator.go b/kv/coordinator.go index 1fa2d5cca..d72f5694a 100644 --- a/kv/coordinator.go +++ b/kv/coordinator.go @@ -796,7 +796,7 @@ func (c *Coordinate) dispatchOnce(ctx context.Context, reqs *OperationGroup[OP]) var resp *CoordinateResponse var err error if reqs.IsTxn { - resp, err = c.dispatchTxn(ctx, reqs.Elems, reqs.ReadKeys, reqs.StartTS, reqs.CommitTS, reqs.PrevCommitTS, reqs.ObservedRouteVersion, reqs.KeyVizLabel) + resp, err = c.dispatchTxn(ctx, reqs.Elems, reqs.ReadKeys, reqs.StartTS, reqs.CommitTS, reqs.PrevCommitTS, reqs.ObservedRouteVersion, reqs.KeyVizLabel, reqs.PrimaryKey) } else { resp, err = c.dispatchRaw(ctx, reqs.Elems, reqs.KeyVizLabel) } @@ -1315,11 +1315,14 @@ func (c *Coordinate) resolveDispatchCommitTS(ctx context.Context, commitTS, star return retry, nil } -func (c *Coordinate) dispatchTxn(ctx context.Context, reqs []*Elem[OP], readKeys [][]byte, startTS uint64, commitTS uint64, prevCommitTS uint64, observedRouteVersion uint64, label keyviz.Label) (*CoordinateResponse, error) { +func (c *Coordinate) dispatchTxn(ctx context.Context, reqs []*Elem[OP], readKeys [][]byte, startTS uint64, commitTS uint64, prevCommitTS uint64, observedRouteVersion uint64, label keyviz.Label, selectedPrimary ...[]byte) (*CoordinateResponse, error) { if len(readKeys) > maxReadKeys { return nil, errors.WithStack(ErrInvalidRequest) } primary := primaryKeyForElems(reqs) + if len(selectedPrimary) != 0 && len(selectedPrimary[0]) != 0 { + primary = selectedPrimary[0] + } if len(primary) == 0 { return nil, errors.WithStack(ErrTxnPrimaryKeyRequired) } @@ -1592,6 +1595,9 @@ func (c *Coordinate) buildRedirectRequests(reqs *OperationGroup[OP]) ([]*pb.Requ return nil, errors.WithStack(ErrInvalidRequest) } primary := primaryKeyForElems(reqs.Elems) + if len(reqs.PrimaryKey) != 0 { + primary = reqs.PrimaryKey + } if len(primary) == 0 { return nil, errors.WithStack(ErrTxnPrimaryKeyRequired) } @@ -1725,3 +1731,10 @@ func primaryKeyForElems(reqs []*Elem[OP]) []byte { } return primary } + +func transactionPrimaryKey(reqs []*Elem[OP], selected []byte) []byte { + if len(selected) != 0 { + return selected + } + return primaryKeyForElems(reqs) +} diff --git a/kv/coordinator_txn_test.go b/kv/coordinator_txn_test.go index 86db08090..1aa42bd3a 100644 --- a/kv/coordinator_txn_test.go +++ b/kv/coordinator_txn_test.go @@ -97,6 +97,34 @@ func TestCoordinateDispatchTxn_UsesOnePhaseRequest(t *testing.T) { require.Greater(t, meta.CommitTS, startTS) } +func TestCoordinateDispatchTxn_UsesSelectedPrimary(t *testing.T) { + t.Parallel() + tx := &stubTransactional{} + c := &Coordinate{transactionManager: tx, clock: NewHLC()} + _, err := c.dispatchTxn(context.Background(), []*Elem[OP]{ + {Op: Put, Key: []byte("a")}, + {Op: Put, Key: []byte("z")}, + }, nil, 10, 0, 0, 0, keyviz.LabelLegacy, []byte("z")) + require.NoError(t, err) + meta, err := DecodeTxnMeta(tx.reqs[0][0].Mutations[0].Value) + require.NoError(t, err) + require.Equal(t, []byte("z"), meta.PrimaryKey) +} + +func TestCoordinateForwardTxnPreservesSelectedPrimary(t *testing.T) { + t.Parallel() + c := &Coordinate{} + requests, err := c.buildRedirectRequests(&OperationGroup[OP]{ + IsTxn: true, StartTS: 10, PrimaryKey: []byte("z"), + Elems: []*Elem[OP]{{Op: Put, Key: []byte("a")}, {Op: Put, Key: []byte("z")}}, + }) + require.NoError(t, err) + require.Len(t, requests, 1) + meta, err := DecodeTxnMeta(requests[0].Mutations[0].Value) + require.NoError(t, err) + require.Equal(t, []byte("z"), meta.PrimaryKey) +} + func TestCoordinateDispatchTxn_UsesProvidedCommitTS(t *testing.T) { t.Parallel() diff --git a/kv/sharded_coordinator.go b/kv/sharded_coordinator.go index 2369f2383..52569ef14 100644 --- a/kv/sharded_coordinator.go +++ b/kv/sharded_coordinator.go @@ -1202,6 +1202,7 @@ func (c *ShardedCoordinator) dispatchTxnWithComposed1Retry(ctx context.Context, reqs.KeyVizLabel, callerSuppliedStartTS, reqs.RequireAllParticipants, + reqs.PrimaryKey, ) if err == nil { return resp, nil @@ -1760,17 +1761,18 @@ func (c *ShardedCoordinator) dispatchTxn( observedRouteVersion uint64, label keyviz.Label, callerSuppliedStartTS bool, - requireAllParticipants ...bool, + requireAllParticipants bool, + selectedPrimary []byte, ) (*CoordinateResponse, error) { if len(readKeys) > maxReadKeys { return nil, errors.WithStack(ErrInvalidRequest) } - grouped, gids, err := c.groupTxnMutations(elems, label, observedRouteVersion, requireAllParticipantsFlag(requireAllParticipants)) + grouped, gids, err := c.groupTxnMutations(elems, label, observedRouteVersion, requireAllParticipants) if err != nil { return nil, err } bypassKeysByGroup := c.writeFenceBypassKeysByGroup(elems) - primaryKey := primaryKeyForElems(elems) + primaryKey := transactionPrimaryKey(elems, selectedPrimary) if len(primaryKey) == 0 { return nil, errors.WithStack(ErrTxnPrimaryKeyRequired) } @@ -1798,7 +1800,7 @@ func (c *ShardedCoordinator) dispatchTxn( if err := StampGroupedMutationCommitTS(grouped, commitTS); err != nil { return nil, err } - return c.dispatchMultiShardTxn(ctx, startTS, commitTS, prevCommitTS, primaryKey, grouped, gids, readKeys, observedRouteVersion, bypassKeysByGroup, requireAllParticipantsFlag(requireAllParticipants)) + return c.dispatchMultiShardTxn(ctx, startTS, commitTS, prevCommitTS, primaryKey, grouped, gids, readKeys, observedRouteVersion, bypassKeysByGroup, requireAllParticipants) } func (c *ShardedCoordinator) groupTxnMutations(elems []*Elem[OP], label keyviz.Label, observedRouteVersion uint64, strict bool) (map[uint64][]*pb.Mutation, []uint64, error) { @@ -3287,6 +3289,21 @@ func validateOperationGroup(reqs *OperationGroup[OP]) error { return ErrInvalidRequest } } + if len(reqs.PrimaryKey) != 0 { + if !reqs.IsTxn { + return ErrInvalidRequest + } + found := false + for _, e := range reqs.Elems { + if bytes.Equal(e.Key, reqs.PrimaryKey) { + found = true + break + } + } + if !found { + return ErrInvalidRequest + } + } return nil } @@ -3385,6 +3402,11 @@ func (c *ShardedCoordinator) stampRawRequestTimestamps(ctx context.Context, reqs func (c *ShardedCoordinator) txnLogs(ctx context.Context, reqs *OperationGroup[OP]) ([]*pb.Request, error) { // NOTE: ShardedCoordinator implements distributed transactions directly in // Dispatch. txnLogs is retained for compatibility and single-shard helpers. + if len(reqs.PrimaryKey) != 0 { + // This legacy builder selects a primary independently per group. + // Never silently discard an explicit transaction primary. + return nil, errors.WithStack(ErrInvalidRequest) + } grouped, gids, err := c.groupMutations(reqs.Elems, reqs.KeyVizLabel) if err != nil { return nil, err diff --git a/kv/sharded_coordinator_txn_test.go b/kv/sharded_coordinator_txn_test.go index 8d674a7d0..8b085ed7a 100644 --- a/kv/sharded_coordinator_txn_test.go +++ b/kv/sharded_coordinator_txn_test.go @@ -621,6 +621,56 @@ func TestShardedCoordinatorDispatchTxn_CrossShardPhasesAndCommitIndex(t *testing require.Zero(t, binary.BigEndian.Uint64(value2[4:12])) } +func TestShardedCoordinatorDispatchTxn_SelectedPrimaryCommitsFirst(t *testing.T) { + t.Parallel() + engine := distribution.NewEngine() + applyComposed1Snapshot(t, engine, 1, []distribution.RouteDescriptor{ + {RouteID: 1, Start: []byte("a"), End: []byte("m"), GroupID: 1}, + {RouteID: 2, Start: []byte("m"), GroupID: 2}, + }) + g1Txn := &recordingTransactional{responses: []*TransactionResponse{{CommitIndex: 1}, {CommitIndex: 2}}} + g2Txn := &recordingTransactional{responses: []*TransactionResponse{{CommitIndex: 3}, {CommitIndex: 4}}} + var orderMu sync.Mutex + var commitOrder []string + for group, txn := range map[string]*recordingTransactional{"a": g1Txn, "z": g2Txn} { + txn.onCommit = func(_ int, request *pb.Request) { + if request.Phase == pb.Phase_COMMIT { + orderMu.Lock() + commitOrder = append(commitOrder, group) + orderMu.Unlock() + } + } + } + coord := NewShardedCoordinator(engine, map[uint64]*ShardGroup{ + 1: {Txn: g1Txn}, + 2: {Txn: g2Txn}, + }, 1, NewHLC(), nil) + _, err := coord.Dispatch(context.Background(), &OperationGroup[OP]{ + IsTxn: true, StartTS: 10, PrimaryKey: []byte("z"), RequireAllParticipants: true, + ObservedRouteVersion: EncodeObservedRouteVersion(engine.Version()), + Elems: []*Elem[OP]{{Op: Put, Key: []byte("a")}, {Op: Put, Key: []byte("z")}}, + }) + require.NoError(t, err) + require.Equal(t, []string{"z", "a"}, commitOrder) + require.Equal(t, pb.Phase_COMMIT, g2Txn.requests[1].Phase) + require.Equal(t, pb.Phase_COMMIT, g1Txn.requests[1].Phase) + for _, requests := range [][]*pb.Request{g1Txn.requests, g2Txn.requests} { + for _, request := range requests { + require.Equal(t, []byte("z"), requestTxnMeta(t, request).PrimaryKey) + } + } +} + +func TestShardedCoordinatorRejectsUnwrittenSelectedPrimary(t *testing.T) { + t.Parallel() + coord := NewShardedCoordinator(nil, nil, 1, NewHLC(), nil) + _, err := coord.Dispatch(context.Background(), &OperationGroup[OP]{ + IsTxn: true, PrimaryKey: []byte("missing"), + Elems: []*Elem[OP]{{Op: Put, Key: []byte("a")}}, + }) + require.ErrorIs(t, err, ErrInvalidRequest) +} + func TestShardedCoordinatorDispatchTxn_PhaseDRejectsInvalidCallerStartTSBeforeProposal(t *testing.T) { t.Parallel() coord, g1Txn, g2Txn, alloc := newPhaseDCrossShardCoordinator(t, ErrTSOTimestampInvalid) diff --git a/kv/sharded_integration_test.go b/kv/sharded_integration_test.go index 91ed87ee6..c27692551 100644 --- a/kv/sharded_integration_test.go +++ b/kv/sharded_integration_test.go @@ -188,7 +188,7 @@ func TestShardedCoordinatorStrictCrossShardTxnConfirmsEveryEffect(t *testing.T) shardStore := NewShardStore(router, groups) coord := NewShardedCoordinator(router, groups, 1, NewHLC(), shardStore) resp, err := coord.Dispatch(ctx, &OperationGroup[OP]{ - IsTxn: true, RequireAllParticipants: true, + IsTxn: true, RequireAllParticipants: true, PrimaryKey: []byte("x"), Elems: []*Elem[OP]{ {Op: Put, Key: []byte("b"), Value: []byte("record")}, {Op: Put, Key: []byte("x"), Value: []byte("effect")}, diff --git a/kv/transcoder.go b/kv/transcoder.go index e94d0c43c..df58f1925 100644 --- a/kv/transcoder.go +++ b/kv/transcoder.go @@ -74,6 +74,10 @@ type Elem[T OP] struct { type OperationGroup[T OP] struct { Elems []*Elem[T] IsTxn bool + // PrimaryKey optionally selects a written key as the transaction's + // primary. Empty preserves the lexicographically smallest-key default. + // Callers that use it must keep the same key across every attempt. + PrimaryKey []byte // RequireAllParticipants makes a committed primary insufficient for a // successful response: every secondary must acknowledge apply. Used by // operation-result publication, whose success includes visible effects. From 5ca863a93bd28617cc82398ed85f1dd6b92404f1 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 13:38:55 +0900 Subject: [PATCH 03/35] filesystem: pin route epoch when fencing journal attempts Signed-off-by: bootjp --- internal/filesystem/operation_journal.go | 30 +++++++++++- internal/filesystem/operation_journal_exec.go | 10 ++-- internal/filesystem/operation_journal_test.go | 49 ++++++++++++++++++- internal/filesystem/service.go | 13 +++++ kv/sharded_coordinator.go | 10 ++++ 5 files changed, 105 insertions(+), 7 deletions(-) diff --git a/internal/filesystem/operation_journal.go b/internal/filesystem/operation_journal.go index 6db9bdf3b..716e00a1a 100644 --- a/internal/filesystem/operation_journal.go +++ b/internal/filesystem/operation_journal.go @@ -23,6 +23,8 @@ type operationJournalBackend interface { GetAt(context.Context, []byte, uint64) ([]byte, error) ScanAt(context.Context, []byte, []byte, int, uint64) ([]*store.KVPair, error) Commit(context.Context, uint64, []*kv.Elem[kv.OP], [][]byte) (*kv.CoordinateResponse, error) + JournalRouteVersion() (uint64, bool) + CommitAtRouteVersion(context.Context, uint64, []*kv.Elem[kv.OP], [][]byte, uint64) (*kv.CoordinateResponse, error) CommittedVersionAt(context.Context, []byte, uint64) (bool, error) CommittedVersionAtGroup(context.Context, []byte, uint64, uint64) (bool, error) GroupIDForKey([]byte) (uint64, error) @@ -45,6 +47,22 @@ func (b serviceOperationBackend) ScanAt(ctx context.Context, start, end []byte, } func (b serviceOperationBackend) Commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte) (*kv.CoordinateResponse, error) { + return b.commit(ctx, ts, elems, readKeys, 0) +} + +func (b serviceOperationBackend) JournalRouteVersion() (uint64, bool) { + if source, ok := b.service.dispatch.(interface{ JournalRouteVersion() (uint64, bool) }); ok { + return source.JournalRouteVersion() + } + _, sharded := b.service.store.(*kv.ShardStore) + return 0, !sharded +} + +func (b serviceOperationBackend) CommitAtRouteVersion(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte, observed uint64) (*kv.CoordinateResponse, error) { + return b.commit(ctx, ts, elems, readKeys, observed) +} + +func (b serviceOperationBackend) commit(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte, observed uint64) (*kv.CoordinateResponse, error) { var primary []byte for _, elem := range elems { if elem == nil { @@ -58,7 +76,7 @@ func (b serviceOperationBackend) Commit(ctx context.Context, ts uint64, elems [] } primary = elem.Key } - return b.service.dispatchTxnWithPrimaryPolicy(ctx, ts, elems, readKeys, true, primary) + return b.service.dispatchTxnWithPrimaryAtRoute(ctx, ts, elems, readKeys, true, primary, observed) } func (b serviceOperationBackend) CommittedVersionAt(ctx context.Context, key []byte, ts uint64) (bool, error) { @@ -386,6 +404,10 @@ func (j *OperationJournal) loadRecordAt(ctx context.Context, id OperationID, ts } func (j *OperationJournal) storeRecordAndSession(ctx context.Context, ts uint64, record operationRecord, session operationSession, extra []*kv.Elem[kv.OP], extraReadKeys [][]byte) error { + return j.storeRecordAndSessionAtRoute(ctx, ts, record, session, extra, extraReadKeys, 0) +} + +func (j *OperationJournal) storeRecordAndSessionAtRoute(ctx context.Context, ts uint64, record operationRecord, session operationSession, extra []*kv.Elem[kv.OP], extraReadKeys [][]byte, observed uint64) error { recordRaw, err := encodeOperationRecord(record) if err != nil { return err @@ -407,7 +429,11 @@ func (j *OperationJournal) storeRecordAndSession(ctx context.Context, ts uint64, readKeys := make([][]byte, 0, len(extraReadKeys)+operationJournalRows) readKeys = append(readKeys, record.ID.key(), session.ID.key()) readKeys = append(readKeys, extraReadKeys...) - _, err = j.backend.Commit(ctx, ts, elems, readKeys) + if observed != 0 { + _, err = j.backend.CommitAtRouteVersion(ctx, ts, elems, readKeys, observed) + } else { + _, err = j.backend.Commit(ctx, ts, elems, readKeys) + } return errors.Wrap(err, "publish operation journal") } diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index fbccb4c7c..e81801667 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -52,7 +52,11 @@ func (j *OperationJournal) AdvanceGeneration(ctx context.Context, id OperationID return operationRecord{}, errors.WithStack(ErrInvalid) } for range operationCASRetries { - record, err := j.advanceGenerationOnce(ctx, id, old) + observed, ready := j.backend.JournalRouteVersion() + if !ready { + return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + record, err := j.advanceGenerationOnce(ctx, id, old, observed) if !IsWriteConflict(err) { return record, err } @@ -60,7 +64,7 @@ func (j *OperationJournal) AdvanceGeneration(ctx context.Context, id OperationID return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) } -func (j *OperationJournal) advanceGenerationOnce(ctx context.Context, id OperationID, old [16]byte) (operationRecord, error) { +func (j *OperationJournal) advanceGenerationOnce(ctx context.Context, id OperationID, old [16]byte, observed uint64) (operationRecord, error) { ts, record, session, err := j.pendingSnapshot(ctx, id) if err != nil { return operationRecord{}, err @@ -72,7 +76,7 @@ func (j *OperationJournal) advanceGenerationOnce(ctx context.Context, id Operati if err != nil { return operationRecord{}, err } - if err = j.storeRecordAndSession(ctx, ts, record, session, nil, nil); err != nil { + if err = j.storeRecordAndSessionAtRoute(ctx, ts, record, session, nil, nil, observed); err != nil { return operationRecord{}, err } return record, nil diff --git a/internal/filesystem/operation_journal_test.go b/internal/filesystem/operation_journal_test.go index 46e79f95f..a08fb8c77 100644 --- a/internal/filesystem/operation_journal_test.go +++ b/internal/filesystem/operation_journal_test.go @@ -34,17 +34,35 @@ func testOperationID(t *testing.T, journal *OperationJournal, sequence uint64) O } type primaryCaptureDispatcher struct { - inner *testCoordinator - primary []byte + inner *testCoordinator + primary []byte + routeVersion uint64 } func (d *primaryCaptureDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { if len(req.PrimaryKey) != 0 { d.primary = append([]byte(nil), req.PrimaryKey...) } + if req.ObservedRouteVersion != 0 && req.ObservedRouteVersion != d.routeVersion { + return nil, kv.ErrTransactionOutcomeUnknown + } return d.inner.Dispatch(ctx, req) } +func (d *primaryCaptureDispatcher) JournalRouteVersion() (uint64, bool) { + return d.routeVersion, d.routeVersion != 0 +} + +type routeSwitchOperationBackend struct { + operationJournalBackend + switchRoute func() +} + +func (b *routeSwitchOperationBackend) CommitAtRouteVersion(ctx context.Context, ts uint64, elems []*kv.Elem[kv.OP], reads [][]byte, observed uint64) (*kv.CoordinateResponse, error) { + b.switchRoute() + return b.operationJournalBackend.CommitAtRouteVersion(ctx, ts, elems, reads, observed) +} + func TestOperationJournalSelectsRecordAsTransactionPrimary(t *testing.T) { ctx := context.Background() st := store.NewMVCCStore() @@ -66,6 +84,33 @@ func TestOperationJournalSelectsRecordAsTransactionPrimary(t *testing.T) { require.Equal(t, id.key(), dispatch.primary) } +func TestOperationJournalGenerationFenceRejectsChangedRouteVersion(t *testing.T) { + ctx := context.Background() + st := store.NewMVCCStore() + dispatch := &primaryCaptureDispatcher{inner: &testCoordinator{st: st}, routeVersion: 7} + service, err := NewService(st, dispatch) + require.NoError(t, err) + journal, err := NewOperationJournal(service) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + journal = newOperationJournalWithBackend(&routeSwitchOperationBackend{ + operationJournalBackend: journal.backend, + switchRoute: func() { dispatch.routeVersion = 8 }, + }) + _, err = journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + outcome, _, err := journal.ResolveAttempt(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, ErrOperationPending) + require.Equal(t, AttemptUnknown, outcome) + current, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.Equal(t, attempt.AttemptID, current.AttemptID) +} + func TestOperationJournalPublishesResultWithEffectAndRetiresIdentity(t *testing.T) { ctx := context.Background() journal, st, coord := testOperationJournal(t) diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index a8314abba..d9df8fca1 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -2813,12 +2813,25 @@ func (s *Service) dispatchTxnWithPrimaryPolicy( readKeys [][]byte, requireAllParticipants bool, primary []byte, +) (*kv.CoordinateResponse, error) { + return s.dispatchTxnWithPrimaryAtRoute(ctx, startTS, elems, readKeys, requireAllParticipants, primary, 0) +} + +func (s *Service) dispatchTxnWithPrimaryAtRoute( + ctx context.Context, + startTS uint64, + elems []*kv.Elem[kv.OP], + readKeys [][]byte, + requireAllParticipants bool, + primary []byte, + observedRouteVersion uint64, ) (*kv.CoordinateResponse, error) { startTS = normalizeEmptyStoreReadTS(startTS) reqs := &kv.OperationGroup[kv.OP]{ Elems: elems, IsTxn: true, PrimaryKey: primary, + ObservedRouteVersion: observedRouteVersion, RequireAllParticipants: requireAllParticipants, StartTS: startTS, ReadKeys: readKeys, diff --git a/kv/sharded_coordinator.go b/kv/sharded_coordinator.go index 52569ef14..37a5ab2ea 100644 --- a/kv/sharded_coordinator.go +++ b/kv/sharded_coordinator.go @@ -1320,6 +1320,16 @@ func (c *ShardedCoordinator) pinStrictRouteVersion(reqs *OperationGroup[OP]) { } } +// JournalRouteVersion returns a pin for journal read/modify/write operations. +// Version zero is not a usable pin until its wire encoding is enabled. +func (c *ShardedCoordinator) JournalRouteVersion() (uint64, bool) { + if c == nil || c.engine == nil { + return 0, false + } + observed := EncodeObservedRouteVersion(c.engine.Version()) + return observed, observed != 0 +} + func (c *ShardedCoordinator) ensureStrictRouteVersion(observed uint64, strict bool) error { if !strict || c.engine == nil { return nil From a1df973f27ee796fdaaae6705d5feab67ecdb70b Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 13:43:49 +0900 Subject: [PATCH 04/35] filesystem: pin journal planning routes and bound fences Signed-off-by: bootjp --- .../operation_codec_property_test.go | 1 + internal/filesystem/operation_journal.go | 9 +- internal/filesystem/operation_journal_exec.go | 74 +++++++++++++--- internal/filesystem/operation_journal_test.go | 88 +++++++++++++++++++ internal/filesystem/operation_record.go | 4 +- 5 files changed, 160 insertions(+), 16 deletions(-) diff --git a/internal/filesystem/operation_codec_property_test.go b/internal/filesystem/operation_codec_property_test.go index 10ec50872..6b605acdc 100644 --- a/internal/filesystem/operation_codec_property_test.go +++ b/internal/filesystem/operation_codec_property_test.go @@ -32,6 +32,7 @@ func TestOperationRecordCodecPreservesGenerationFence(t *testing.T) { record.AttemptID = [16]byte{4} record.Generation = 2 record.FencedAttempts = [][16]byte{{3}} + record.Reservation += operationFenceReservation raw, err := encodeOperationRecord(record) require.NoError(t, err) decoded, err := decodeOperationRecord(raw) diff --git a/internal/filesystem/operation_journal.go b/internal/filesystem/operation_journal.go index 716e00a1a..f52518d8d 100644 --- a/internal/filesystem/operation_journal.go +++ b/internal/filesystem/operation_journal.go @@ -52,7 +52,14 @@ func (b serviceOperationBackend) Commit(ctx context.Context, ts uint64, elems [] func (b serviceOperationBackend) JournalRouteVersion() (uint64, bool) { if source, ok := b.service.dispatch.(interface{ JournalRouteVersion() (uint64, bool) }); ok { - return source.JournalRouteVersion() + version, ready := source.JournalRouteVersion() + if ready { + return version, true + } + if _, sharded := b.service.store.(*kv.ShardStore); sharded { + return 0, false + } + return 0, true } _, sharded := b.service.store.(*kv.ShardStore) return 0, !sharded diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index e81801667..07d394f08 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -72,7 +72,7 @@ func (j *OperationJournal) advanceGenerationOnce(ctx context.Context, id Operati if slices.Contains(record.FencedAttempts, old) { return record, nil } - record, err = prepareGenerationFence(record, old) + record, session, err = prepareGenerationFence(record, session, old) if err != nil { return operationRecord{}, err } @@ -82,29 +82,55 @@ func (j *OperationJournal) advanceGenerationOnce(ctx context.Context, id Operati return record, nil } -func prepareGenerationFence(record operationRecord, old [16]byte) (operationRecord, error) { +func prepareGenerationFence(record operationRecord, session operationSession, old [16]byte) (operationRecord, operationSession, error) { if !record.RecordIsPrimary || record.AttemptID != old { - return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + return operationRecord{}, operationSession{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) } if record.Generation == ^uint64(0) || len(record.FencedAttempts) >= maxOperationFencedAttempts { - return operationRecord{}, errors.WithStack(ErrOperationGeneration) + return operationRecord{}, operationSession{}, errors.WithStack(ErrOperationGeneration) + } + if operationFenceReservation > session.MaxBytes-session.RetainedBytes { + return operationRecord{}, operationSession{}, errors.WithStack(ErrOperationQuota) } next, err := randomOperationToken() if err != nil { - return operationRecord{}, err + return operationRecord{}, operationSession{}, err } record.FencedAttempts = append(record.FencedAttempts, old) record.AttemptID = next record.Generation++ - return record, nil + record.Reservation += operationFenceReservation + session.RetainedBytes += operationFenceReservation + return record, session, nil } type operationAttemptPlan struct { - Intent []byte - Effects []*kv.Elem[kv.OP] - ReadTS uint64 - ReadKeys [][]byte - Result []byte + Intent []byte + Effects []*kv.Elem[kv.OP] + ReadTS uint64 + // RouteVersion is captured before the planner reads any routed key. + RouteVersion uint64 + ReadKeys [][]byte + Result []byte +} + +type operationPlanningSnapshot struct { + ReadTS uint64 + RouteVersion uint64 +} + +// PlanSnapshotAtRoute pins the placement used by the planner. A sharded +// planner must carry RouteVersion into publication or semantic rejection. +func (j *OperationJournal) PlanSnapshotAtRoute(ctx context.Context, id OperationID, attempt [16]byte) (operationPlanningSnapshot, error) { + version, ready := j.backend.JournalRouteVersion() + if !ready { + return operationPlanningSnapshot{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + ts, err := j.PlanSnapshot(ctx, id, attempt) + if err != nil { + return operationPlanningSnapshot{}, err + } + return operationPlanningSnapshot{ReadTS: ts, RouteVersion: version}, nil } // PlanSnapshot establishes the OCC timestamp before a planner reads data. @@ -143,7 +169,7 @@ func classifyAttemptOutcome(id OperationID, attempt [16]byte, record operationRe return AttemptUnknown, record, err } if record.ID != id { - return AttemptUnknown, record, err + return AttemptUnknown, record, errors.WithStack(kv.ErrTransactionOutcomeUnknown) } if record.AttemptID != attempt { return classifyPriorAttempt(attempt, record) @@ -173,6 +199,9 @@ func (j *OperationJournal) Publish(ctx context.Context, id OperationID, attempt if len(plan.ReadKeys) != 0 && plan.ReadTS == 0 { return operationRecord{}, errors.WithStack(ErrInvalid) } + if err := j.requirePlanningRoute(plan.RouteVersion); err != nil { + return operationRecord{}, err + } ts, record, session, err := j.publicationSnapshot(ctx, id, plan.ReadTS) if err != nil { return j.resolveDuplicatePublication(ctx, id, attempt, plan.Intent, err) @@ -194,12 +223,22 @@ func (j *OperationJournal) Publish(ctx context.Context, id OperationID, attempt record.ProofGroups = groups session.Pending-- effects := slices.Concat(routedEffects, proofs) - if err = j.storeRecordAndSession(ctx, ts, record, session, effects, plan.ReadKeys); err != nil { + if err = j.storeRecordAndSessionAtRoute(ctx, ts, record, session, effects, plan.ReadKeys, plan.RouteVersion); err != nil { return j.resolveDuplicatePublication(ctx, id, attempt, plan.Intent, err) } return j.Resolve(ctx, id) } +func (j *OperationJournal) requirePlanningRoute(observed uint64) error { + current, ready := j.backend.JournalRouteVersion() + // A changed catalog invalidates the planner's reads even if the commit + // coordinator could still find a route for every write key. + if !ready || (current != 0 && current != observed) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + return nil +} + func (j *OperationJournal) publicationSnapshot(ctx context.Context, id OperationID, readTS uint64) (uint64, operationRecord, operationSession, error) { if readTS == 0 { return j.pendingSnapshot(ctx, id) @@ -278,9 +317,16 @@ func validateOperationPlan(record operationRecord, session operationSession, pla // validated its predicates on the same snapshot. OCC conflicts remain pending // attempts and must not be stored as semantic rejection. func (j *OperationJournal) Reject(ctx context.Context, id OperationID, attempt [16]byte, readTS uint64, intent []byte, result []byte, readKeys [][]byte) (operationRecord, error) { + return j.RejectAtRoute(ctx, id, attempt, readTS, 0, intent, result, readKeys) +} + +func (j *OperationJournal) RejectAtRoute(ctx context.Context, id OperationID, attempt [16]byte, readTS, routeVersion uint64, intent []byte, result []byte, readKeys [][]byte) (operationRecord, error) { if readTS == 0 { return operationRecord{}, errors.WithStack(ErrInvalid) } + if err := j.requirePlanningRoute(routeVersion); err != nil { + return operationRecord{}, err + } ts, record, session, err := j.publicationSnapshot(ctx, id, readTS) if err != nil { return j.resolveDuplicateRejection(ctx, id, attempt, intent, err) @@ -297,7 +343,7 @@ func (j *OperationJournal) Reject(ctx context.Context, id OperationID, attempt [ record.State = OperationRejected record.Result = append([]byte(nil), result...) session.Pending-- - if err = j.storeRecordAndSession(ctx, ts, record, session, nil, readKeys); err != nil { + if err = j.storeRecordAndSessionAtRoute(ctx, ts, record, session, nil, readKeys, routeVersion); err != nil { return j.resolveDuplicateRejection(ctx, id, attempt, intent, err) } return j.Resolve(ctx, id) diff --git a/internal/filesystem/operation_journal_test.go b/internal/filesystem/operation_journal_test.go index a08fb8c77..2abdd1ac4 100644 --- a/internal/filesystem/operation_journal_test.go +++ b/internal/filesystem/operation_journal_test.go @@ -111,6 +111,37 @@ func TestOperationJournalGenerationFenceRejectsChangedRouteVersion(t *testing.T) require.Equal(t, attempt.AttemptID, current.AttemptID) } +func TestOperationJournalPublicationRejectsChangedPlanningRoute(t *testing.T) { + ctx := context.Background() + st := store.NewMVCCStore() + dispatch := &primaryCaptureDispatcher{inner: &testCoordinator{st: st}, routeVersion: 7} + service, err := NewService(st, dispatch) + require.NoError(t, err) + journal, err := NewOperationJournal(service) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt.AttemptID) + require.NoError(t, err) + require.Equal(t, uint64(7), snapshot.RouteVersion) + dispatch.routeVersion = 8 + _, err = journal.Publish(ctx, id, attempt.AttemptID, operationAttemptPlan{ + Intent: []byte("write"), ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("data"), Value: []byte("value")}}, + }) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + _, err = journal.RejectAtRoute(ctx, id, attempt.AttemptID, snapshot.ReadTS, snapshot.RouteVersion, []byte("write"), []byte("rejected"), nil) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + _, err = st.GetAt(ctx, []byte("data"), st.LastCommitTS()) + require.ErrorIs(t, err, store.ErrKeyNotFound) + outcome, _, err := journal.ResolveAttempt(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, ErrOperationPending) + require.Equal(t, AttemptUnknown, outcome) +} + func TestOperationJournalPublishesResultWithEffectAndRetiresIdentity(t *testing.T) { ctx := context.Background() journal, st, coord := testOperationJournal(t) @@ -720,6 +751,17 @@ func TestOperationJournalNewGenerationDoesNotProveOldAttemptAborted(t *testing.T } } +func TestOperationJournalForeignRecordNeverProvesAttemptOutcome(t *testing.T) { + id := OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + foreign := OperationID{Filesystem: id.Filesystem, Session: id.Session, Sequence: 2} + attempt := [16]byte{3} + outcome, _, err := classifyAttemptOutcome(id, attempt, operationRecord{ + ID: foreign, State: OperationCommitted, AttemptID: attempt, + }, nil) + require.Equal(t, AttemptUnknown, outcome) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) +} + func TestOperationJournalGenerationFenceStopsDelayedOldCommit(t *testing.T) { ctx := context.Background() journal, st, _ := testOperationJournal(t) @@ -804,6 +846,52 @@ func TestOperationJournalLostGenerationFenceReplyRetainsAbortProof(t *testing.T) require.Equal(t, record.AttemptID, current.AttemptID) } +func TestOperationJournalGenerationFenceRespectsSessionQuota(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + ts, record, session, err := journal.pendingSnapshot(ctx, id) + require.NoError(t, err) + session.MaxBytes = session.RetainedBytes + require.NoError(t, journal.storeRecordAndSession(ctx, ts, record, session, nil, nil)) + _, err = journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.ErrorIs(t, err, ErrOperationQuota) + current, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.Equal(t, attempt.AttemptID, current.AttemptID) +} + +func TestOperationJournalGenerationFenceReleasesReservedBytesOnAcknowledgement(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + beforeTS, _, before, err := journal.pendingSnapshot(ctx, id) + require.NoError(t, err) + require.NotZero(t, beforeTS) + next, err := journal.AdvanceGeneration(ctx, id, attempt.AttemptID) + require.NoError(t, err) + _, _, during, err := journal.pendingSnapshot(ctx, id) + require.NoError(t, err) + require.Equal(t, before.RetainedBytes+operationFenceReservation, during.RetainedBytes) + _, err = journal.Publish(ctx, id, next.AttemptID, operationAttemptPlan{Intent: []byte("write")}) + require.NoError(t, err) + require.NoError(t, journal.Acknowledge(ctx, id)) + ts, err := journal.backend.Snapshot(ctx) + require.NoError(t, err) + after, exists, err := journal.loadSessionAt(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session}, ts) + require.NoError(t, err) + require.True(t, exists) + require.Zero(t, after.RetainedBytes) +} + func TestOperationJournalSnapshotRetainsTerminalAndPendingRecords(t *testing.T) { ctx := context.Background() journal, st, _ := testOperationJournal(t) diff --git a/internal/filesystem/operation_record.go b/internal/filesystem/operation_record.go index 1a5a76e64..5a08aa245 100644 --- a/internal/filesystem/operation_record.go +++ b/internal/filesystem/operation_record.go @@ -20,6 +20,7 @@ const ( operationReservationOverhead = 4096 maxOperationProofGroups = 16 maxOperationFencedAttempts = 256 + operationFenceReservation = 128 ) var ( @@ -166,7 +167,8 @@ func validOperationCore(record operationRecord) bool { record.Fingerprint == sha256.Sum256(record.Intent) && record.Reservation != 0 && record.ResultLimit != 0 && uint64(record.ResultLimit) <= maxOperationRecordBytes && uint64(len(record.Result)) <= uint64(record.ResultLimit) && - record.Reservation == 2*(uint64(len(record.Intent))+uint64(record.ResultLimit)+uint64(record.EffectLimit))+operationHeaderBytes+operationReservationOverhead + record.Reservation == 2*(uint64(len(record.Intent))+uint64(record.ResultLimit)+uint64(record.EffectLimit))+ + operationHeaderBytes+operationReservationOverhead+uint64(len(record.FencedAttempts))*operationFenceReservation } func validOperationEffects(record operationRecord) bool { From d610a45e30f7e70d7f73865bf7e851beab3be7d7 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 13:47:49 +0900 Subject: [PATCH 05/35] filesystem: preserve duplicate results across route changes Signed-off-by: bootjp --- internal/filesystem/operation_journal_exec.go | 6 +++++ internal/filesystem/operation_journal_test.go | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index 07d394f08..44a7775e0 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -200,6 +200,9 @@ func (j *OperationJournal) Publish(ctx context.Context, id OperationID, attempt return operationRecord{}, errors.WithStack(ErrInvalid) } if err := j.requirePlanningRoute(plan.RouteVersion); err != nil { + if duplicate, resolveErr := j.resolveDuplicatePublication(ctx, id, attempt, plan.Intent, ErrOperationGeneration); resolveErr == nil { + return duplicate, nil + } return operationRecord{}, err } ts, record, session, err := j.publicationSnapshot(ctx, id, plan.ReadTS) @@ -325,6 +328,9 @@ func (j *OperationJournal) RejectAtRoute(ctx context.Context, id OperationID, at return operationRecord{}, errors.WithStack(ErrInvalid) } if err := j.requirePlanningRoute(routeVersion); err != nil { + if duplicate, resolveErr := j.resolveDuplicateRejection(ctx, id, attempt, intent, ErrOperationGeneration); resolveErr == nil { + return duplicate, nil + } return operationRecord{}, err } ts, record, session, err := j.publicationSnapshot(ctx, id, readTS) diff --git a/internal/filesystem/operation_journal_test.go b/internal/filesystem/operation_journal_test.go index 2abdd1ac4..0cec23f5f 100644 --- a/internal/filesystem/operation_journal_test.go +++ b/internal/filesystem/operation_journal_test.go @@ -142,6 +142,31 @@ func TestOperationJournalPublicationRejectsChangedPlanningRoute(t *testing.T) { require.Equal(t, AttemptUnknown, outcome) } +func TestOperationJournalDuplicateResultSurvivesPlanningRouteChange(t *testing.T) { + ctx := context.Background() + st := store.NewMVCCStore() + dispatch := &primaryCaptureDispatcher{inner: &testCoordinator{st: st}, routeVersion: 7} + service, err := NewService(st, dispatch) + require.NoError(t, err) + journal, err := NewOperationJournal(service) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt.AttemptID) + require.NoError(t, err) + plan := operationAttemptPlan{Intent: []byte("write"), ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + Effects: []*kv.Elem[kv.OP]{{Op: kv.Put, Key: []byte("data"), Value: []byte("value")}}, Result: []byte("5")} + first, err := journal.Publish(ctx, id, attempt.AttemptID, plan) + require.NoError(t, err) + dispatch.routeVersion = 8 + duplicate, err := journal.Publish(ctx, id, attempt.AttemptID, plan) + require.NoError(t, err) + require.Equal(t, first.Result, duplicate.Result) +} + func TestOperationJournalPublishesResultWithEffectAndRetiresIdentity(t *testing.T) { ctx := context.Background() journal, st, coord := testOperationJournal(t) From 1a0bbeab954d485ed2a09b598966e2c8e400c3e5 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 13:49:18 +0900 Subject: [PATCH 06/35] filesystem: isolate journal rejection validation Signed-off-by: bootjp --- internal/filesystem/operation_journal_exec.go | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index 44a7775e0..fdac733f9 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -337,14 +337,8 @@ func (j *OperationJournal) RejectAtRoute(ctx context.Context, id OperationID, at if err != nil { return j.resolveDuplicateRejection(ctx, id, attempt, intent, err) } - if attempt == [16]byte{} || record.AttemptID != attempt { - return operationRecord{}, errors.WithStack(ErrOperationGeneration) - } - if record.Fingerprint != sha256.Sum256(intent) || !bytes.Equal(record.Intent, intent) { - return operationRecord{}, errors.WithStack(ErrOperationIDReused) - } - if uint64(len(result)) > uint64(record.ResultLimit) { - return operationRecord{}, errors.WithStack(ErrOperationQuota) + if err := validateOperationRejection(record, attempt, intent, result); err != nil { + return operationRecord{}, err } record.State = OperationRejected record.Result = append([]byte(nil), result...) @@ -355,6 +349,19 @@ func (j *OperationJournal) RejectAtRoute(ctx context.Context, id OperationID, at return j.Resolve(ctx, id) } +func validateOperationRejection(record operationRecord, attempt [16]byte, intent, result []byte) error { + if attempt == [16]byte{} || record.AttemptID != attempt { + return errors.WithStack(ErrOperationGeneration) + } + if record.Fingerprint != sha256.Sum256(intent) || !bytes.Equal(record.Intent, intent) { + return errors.WithStack(ErrOperationIDReused) + } + if uint64(len(result)) > uint64(record.ResultLimit) { + return errors.WithStack(ErrOperationQuota) + } + return nil +} + func (j *OperationJournal) resolveDuplicateRejection(ctx context.Context, id OperationID, attempt [16]byte, intent []byte, originalErr error) (operationRecord, error) { if !errors.Is(originalErr, ErrOperationGeneration) && !IsWriteConflict(originalErr) { return operationRecord{}, originalErr From 742f6646a87b5c7877d611979aadb2793f96357f Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 13:51:04 +0900 Subject: [PATCH 07/35] kv: forward journal route versions through coordinator wrappers Signed-off-by: bootjp --- kv/coordinator_forwarder.go | 10 ++++++++++ main_coordinator_capabilities_test.go | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/kv/coordinator_forwarder.go b/kv/coordinator_forwarder.go index 6d2e67e17..6555dd848 100644 --- a/kv/coordinator_forwarder.go +++ b/kv/coordinator_forwarder.go @@ -270,6 +270,16 @@ func (f CoordinatorForwarder) ResolveOperationGroupForKey(key []byte) (uint64, e return group, errors.WithStack(err) } +// JournalRouteVersion carries the route pin through coordinator wrappers. +// An unavailable pin must remain unavailable to the operation journal. +func (f CoordinatorForwarder) JournalRouteVersion() (uint64, bool) { + source, ok := f.Inner.(interface{ JournalRouteVersion() (uint64, bool) }) + if !ok { + return 0, false + } + return source.JournalRouteVersion() +} + // LocalLeaderGroupIDs lists the data groups this process leads. The Redis // delta compactor's TTL-inline migration plans one scan per group from it. // An Inner without the capability stays unknown rather than leading no group, diff --git a/main_coordinator_capabilities_test.go b/main_coordinator_capabilities_test.go index 70b89c5ed..2b3a0264e 100644 --- a/main_coordinator_capabilities_test.go +++ b/main_coordinator_capabilities_test.go @@ -145,6 +145,11 @@ func (s *shardedCapabilitySpy) ResolveOperationGroupForKey([]byte) (uint64, erro return spyIndex, errSpyForwarded } +func (s *shardedCapabilitySpy) JournalRouteVersion() (uint64, bool) { + s.record("JournalRouteVersion") + return spyIndex, true +} + func (s *shardedCapabilitySpy) LocalLeaderGroupIDs() ([]uint64, bool) { s.record("LocalLeaderGroupIDs") return []uint64{spyIndex}, true From 8a8476e955babed45f7d3357526da87e3555e7c9 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 14:41:35 +0900 Subject: [PATCH 08/35] ci: extend full gRPC sequence test timeout Signed-off-by: bootjp --- .github/workflows/go-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index eff57f32a..2876d90a2 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -38,4 +38,4 @@ jobs: # These unchanged 10,000-iteration tests need a fresh package budget # after the serial migration/snapshot acceptance tests have finished. - name: Run full-length gRPC consistency sequences - run: go test -race ./adapter -run "$GRPC_SEQUENCE_TESTS" + run: go test -race -timeout=30m ./adapter -run "$GRPC_SEQUENCE_TESTS" From 2e632cea291b097755b2829fac4e31e24d4709c0 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 14:49:02 +0900 Subject: [PATCH 09/35] filesystem: publish fixed-offset writes through operation journal Signed-off-by: bootjp --- internal/filesystem/operation_write.go | 250 ++++++++++++++++++++ internal/filesystem/operation_write_test.go | 196 +++++++++++++++ internal/filesystem/service.go | 44 ++-- 3 files changed, 475 insertions(+), 15 deletions(-) create mode 100644 internal/filesystem/operation_write.go create mode 100644 internal/filesystem/operation_write_test.go diff --git a/internal/filesystem/operation_write.go b/internal/filesystem/operation_write.go new file mode 100644 index 000000000..ea8023b58 --- /dev/null +++ b/internal/filesystem/operation_write.go @@ -0,0 +1,250 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/binary" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + writeOperationIntentVersion = 1 + writeOperationIntentHeader = 25 + writeOperationResultBytes = 9 + writeOperationEffectLimit = 1 << 20 + writeOperationMaxData = 4 << 20 + writeResultSuccess = 0 + writeResultIsDir = 1 + writeResultNotFound = 2 +) + +var errWriteCleanupChanged = errors.New("filesystem: write cleanup snapshot changed") + +// WriteWithOperation publishes one fixed-offset write and its original byte +// count under id. A pending duplicate is resolved without issuing a new write. +// The caller owns acknowledgement after it has attempted the FUSE reply. +func (s *Service) WriteWithOperation(ctx context.Context, id OperationID, inode, offset uint64, data []byte) (int, error) { + if len(data) == 0 { + return 0, nil + } + if len(data) > writeOperationMaxData { + return 0, errors.WithStack(ErrOperationQuota) + } + end, overflow := addUint64(offset, uint64(len(data))) + if overflow { + return 0, errors.WithStack(ErrInvalid) + } + data = bytes.Clone(data) + intent := encodeWriteOperationIntent(inode, offset, data) + journal, err := NewOperationJournal(s) + if err != nil { + return 0, err + } + admitted, err := journal.Admit(ctx, id, intent, writeOperationResultBytes, writeOperationEffectLimit) + if err != nil { + return 0, err + } + if admitted.State != OperationPending { + return resolveWriteOperation(ctx, journal, id) + } + if admitted.AttemptID != [16]byte{} { + return resolveWriteOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return 0, err + } + return s.executeWriteOperation(ctx, journal, id, attempt, inode, offset, end, data, intent) +} + +func (s *Service) executeWriteOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode, offset, end uint64, data, intent []byte) (int, error) { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + if err := deadlineCtx.Err(); err != nil { + return 0, errors.WithStack(err) + } + plan, semanticErr, err := s.planJournalWrite(deadlineCtx, journal, id, attempt.AttemptID, inode, offset, end, data, intent) + if err != nil { + return 0, err + } + if semanticErr != nil { + return publishWriteRejection(deadlineCtx, journal, id, attempt.AttemptID, plan, semanticErr) + } + record, err := journal.Publish(deadlineCtx, id, attempt.AttemptID, plan) + if err == nil { + return decodeWriteOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveWriteAttempt(deadlineCtx, journal, id, attempt.AttemptID) + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return 0, err + } + if retry == writeConflictRetryLimit-1 { + return 0, errors.WithStack(ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return 0, err + } + } + return 0, errors.WithStack(ErrContentionLimit) +} + +func encodeWriteOperationIntent(inode, offset uint64, data []byte) []byte { + intent := make([]byte, writeOperationIntentHeader+len(data)) + intent[0] = writeOperationIntentVersion + binary.BigEndian.PutUint64(intent[1:9], inode) + binary.BigEndian.PutUint64(intent[9:17], offset) + binary.BigEndian.PutUint64(intent[17:25], uint64(len(data))) + copy(intent[writeOperationIntentHeader:], data) + return intent +} + +func encodeWriteOperationResult(count uint64, status byte) []byte { + result := make([]byte, writeOperationResultBytes) + result[0] = status + binary.BigEndian.PutUint64(result[1:], count) + return result +} + +func decodeWriteOperationResult(record operationRecord) (int, error) { + if len(record.Result) != writeOperationResultBytes { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + switch record.Result[0] { + case writeResultSuccess: + if record.State != OperationCommitted { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return checkedInt(binary.BigEndian.Uint64(record.Result[1:])) + case writeResultIsDir: + if record.State != OperationRejected { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return 0, errors.WithStack(ErrIsDir) + case writeResultNotFound: + if record.State != OperationRejected { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return 0, errors.WithStack(ErrNotFound) + default: + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } +} + +func resolveWriteOperation(ctx context.Context, journal *OperationJournal, id OperationID) (int, error) { + record, err := journal.Resolve(ctx, id) + if err != nil { + if errors.Is(err, ErrOperationPending) { + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + return 0, err + } + return decodeWriteOperationResult(record) +} + +func resolveWriteAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) (int, error) { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeWriteOperationResult(record) + } + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func publishWriteRejection(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (int, error) { + var status byte + switch { + case errors.Is(semanticErr, ErrIsDir): + status = writeResultIsDir + case errors.Is(semanticErr, ErrNotFound): + status = writeResultNotFound + default: + return 0, semanticErr + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, + encodeWriteOperationResult(0, status), plan.ReadKeys) + if err != nil { + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + return 0, err + } + return resolveWriteAttempt(ctx, journal, id, attempt) + } + return decodeWriteOperationResult(record) +} + +func (s *Service) planJournalWrite(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, offset, end uint64, data, intent []byte) (operationAttemptPlan, error, error) { + for retry := range writeConflictRetryLimit { + cleaned, err := s.prepareWriteCleanup(ctx, inode, end) + if err != nil { + return operationAttemptPlan{}, nil, err + } + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan, semanticErr, err := s.planJournalWriteAt(ctx, snapshot, cleaned, inode, offset, end, data, intent) + if errors.Is(err, errWriteCleanupChanged) { + if err = waitWriteConflictRetry(ctx, retry); err != nil { + return operationAttemptPlan{}, nil, err + } + continue + } + return plan, semanticErr, err + } + return operationAttemptPlan{}, nil, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) planJournalWriteAt(ctx context.Context, snapshot operationPlanningSnapshot, cleaned *InodeMeta, inode, offset, end uint64, data, intent []byte) (operationAttemptPlan, error, error) { + plan := operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion} + meta, err := s.inodeAt(ctx, inode, snapshot.ReadTS) + if errors.Is(err, ErrNotFound) { + plan.ReadKeys = [][]byte{fskeys.InodeKey(inode)} + return plan, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, err + } + if meta.Type == TypeDirectory { + plan.ReadKeys = [][]byte{fskeys.InodeKey(inode)} + return plan, ErrIsDir, nil + } + if err = s.ensureActiveHomeAt(ctx, meta, snapshot.ReadTS); err != nil { + return operationAttemptPlan{}, nil, err + } + if end > meta.Size && (meta.Size != cleaned.Size || meta.HomeSlot != cleaned.HomeSlot) { + return operationAttemptPlan{}, nil, errWriteCleanupChanged + } + plan.Effects, plan.ReadKeys, err = s.planWriteTxn(ctx, meta, snapshot.ReadTS, offset, end, data) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.Result = encodeWriteOperationResult(uint64(len(data)), writeResultSuccess) + return plan, nil, nil +} + +func (s *Service) prepareWriteCleanup(ctx context.Context, inode, end uint64) (*InodeMeta, error) { + ts, err := s.readTS(ctx) + if err != nil { + return nil, err + } + meta, err := s.inodeAt(ctx, inode, ts) + if errors.Is(err, ErrNotFound) { + return &InodeMeta{}, nil + } + if err != nil { + return nil, err + } + if meta.Type == TypeDirectory || end <= meta.Size { + return meta, nil + } + if err = s.ensureActiveHomeAt(ctx, meta, ts); err != nil { + return nil, err + } + _, cleaned, _, err := s.cleanupStaleChunksBeforeSizeChange(ctx, inode, meta) + return cleaned, err +} diff --git a/internal/filesystem/operation_write_test.go b/internal/filesystem/operation_write_test.go new file mode 100644 index 000000000..bb6942ec9 --- /dev/null +++ b/internal/filesystem/operation_write_test.go @@ -0,0 +1,196 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +type writeOperationFaultDispatcher struct { + inner *testCoordinator + key []byte + once bool + before func(context.Context) error + loseAck bool + loseRejection bool +} + +func (d *writeOperationFaultDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + if !d.once && d.loseRejection && isWriteOperationRejection(req) { + return d.rejectWithLostReply(ctx, req) + } + if !d.once && writeOperationTouchesKey(req, d.key) { + d.once = true + if d.before != nil { + if err := d.before(ctx); err != nil { + return nil, err + } + } + resp, err := d.inner.Dispatch(ctx, req) + if err == nil && d.loseAck { + return nil, kv.ErrTransactionOutcomeUnknown + } + return resp, err + } + return d.inner.Dispatch(ctx, req) +} + +func (d *writeOperationFaultDispatcher) rejectWithLostReply(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + d.once = true + if _, err := d.inner.Dispatch(ctx, req); err != nil { + return nil, err + } + return nil, kv.ErrTransactionOutcomeUnknown +} + +func isWriteOperationRejection(req *kv.OperationGroup[kv.OP]) bool { + for _, elem := range req.Elems { + if elem == nil || !fskeys.IsOperationKey(elem.Key) { + continue + } + record, err := decodeOperationRecord(elem.Value) + return err == nil && record.State == OperationRejected + } + return false +} + +func writeOperationTouchesKey(req *kv.OperationGroup[kv.OP], key []byte) bool { + for _, elem := range req.Elems { + if elem != nil && string(elem.Key) == string(key) { + return true + } + } + return false +} + +func testWriteOperationID(t *testing.T, svc *Service, sequence uint64) OperationID { + t.Helper() + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "write-test") + require.NoError(t, err) + return OperationID{Filesystem: filesystemID, Session: session.Session, Sequence: sequence} +} + +func TestWriteWithOperationReturnsOriginalResultWithoutReplaying(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + n, err := svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("abcd")) + require.NoError(t, err) + require.Equal(t, 4, n) + _, err = svc.Write(ctx, file.Inode, 0, 0, []byte("NEW!")) + require.NoError(t, err) + n, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("abcd")) + require.NoError(t, err) + require.Equal(t, 4, n) + data, err := svc.Read(ctx, file.Inode, 0, 0, 4) + require.NoError(t, err) + require.Equal(t, []byte("NEW!"), data) + _, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("different")) + require.ErrorIs(t, err, ErrOperationIDReused) +} + +func TestWriteWithOperationRetainsSemanticFailure(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + _, err := svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) + require.ErrorIs(t, err, ErrIsDir) + _, err = svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) + require.ErrorIs(t, err, ErrIsDir) + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + record, err := journal.Resolve(ctx, id) + require.NoError(t, err) + require.Equal(t, OperationRejected, record.State) +} + +func TestWriteWithOperationDoesNotReplayPendingAttempt(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + _, err = journal.Admit(ctx, id, encodeWriteOperationIntent(file.Inode, 0, []byte("x")), writeOperationResultBytes, writeOperationEffectLimit) + require.NoError(t, err) + _, err = journal.BeginAttempt(ctx, id) + require.NoError(t, err) + _, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("x")) + require.ErrorIs(t, err, kv.ErrTransactionOutcomeUnknown) + data, err := svc.Read(ctx, file.Inode, 0, 0, 1) + require.NoError(t, err) + require.Empty(t, data) +} + +func TestWriteWithOperationReplansAfterDefinitiveConflict(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + fault := &writeOperationFaultDispatcher{inner: inner, key: fskeys.ChunkKey(file.Inode, file.Inode, 0)} + fault.before = func(ctx context.Context) error { + _, err := svc.Write(ctx, file.Inode, 0, 1, []byte("Z")) + if err != nil { + return err + } + return store.ErrWriteConflict + } + svc.dispatch = fault + n, err := svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("a")) + require.NoError(t, err) + require.Equal(t, 1, n) + data, err := svc.Read(ctx, file.Inode, 0, 0, 2) + require.NoError(t, err) + require.Equal(t, []byte("aZ"), data) +} + +func TestWriteWithOperationResolvesLostCommitReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, + key: fskeys.ChunkKey(file.Inode, file.Inode, 0), loseAck: true} + n, err := svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("x")) + require.NoError(t, err) + require.Equal(t, 1, n) + data, err := svc.Read(ctx, file.Inode, 0, 0, 1) + require.NoError(t, err) + require.Equal(t, []byte("x"), data) +} + +func TestWriteWithOperationResolvesLostRejectionReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, loseRejection: true} + _, err := svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) + require.ErrorIs(t, err, ErrIsDir) + _, err = svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) + require.ErrorIs(t, err, ErrIsDir) +} diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index d9df8fca1..7d490c4d4 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -799,16 +799,30 @@ func (s *Service) writeOnce(ctx context.Context, inode uint64, offset uint64, da return 0, err } } + elems, readKeys, err := s.planWriteTxn(ctx, meta, ts, offset, end, data) + if err != nil { + return 0, err + } + if err := s.dispatchTxn(ctx, ts, elems, readKeys); err != nil { + return 0, s.translateHomeConflict(ctx, meta, err) + } + return len(data), nil +} + +// planWriteTxn reads every chunk and metadata predicate at ts. The caller +// must dispatch the returned effects with that same timestamp. +func (s *Service) planWriteTxn(ctx context.Context, meta *InodeMeta, ts, offset, end uint64, data []byte) ([]*kv.Elem[kv.OP], [][]byte, error) { + inode := meta.Inode chunkSize := meta.effectiveChunkSize(s.chunkSize) first := offset / chunkSize last := (end - 1) / chunkSize elemCap, err := checkedInt(last - first + uint64(directoryInitialNlink)) if err != nil { - return 0, err + return nil, nil, err } chunkLen, err := checkedInt(chunkSize) if err != nil { - return 0, err + return nil, nil, err } elems := make([]*kv.Elem[kv.OP], 0, elemCap) readKeys := [][]byte{fskeys.InodeKey(inode), fskeys.HomeKey(inode)} @@ -821,18 +835,18 @@ func (s *Service) writeOnce(ctx context.Context, inode uint64, offset uint64, da writeEnd := min(end, chunkEnd) srcStart, err := checkedInt(writeStart - offset) if err != nil { - return 0, err + return nil, nil, err } srcEnd, err := checkedInt(writeEnd - offset) if err != nil { - return 0, err + return nil, nil, err } fullChunkWrite := writeStart == chunkStart && writeEnd == chunkEnd oldLen, value, readChunk, err := s.writeChunkValue( ctx, ts, chunkKey, chunkLen, fullChunkWrite, data, srcStart, srcEnd, writeStart, chunkStart, ) if err != nil { - return 0, err + return nil, nil, err } if readChunk { readKeys = append(readKeys, chunkKey) @@ -844,25 +858,25 @@ func (s *Service) writeOnce(ctx context.Context, inode uint64, offset uint64, da elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: chunkKey, Value: value}) } } - now := s.now().UnixNano() + return s.finishWritePlan(ctx, meta, ts, end, elems, readKeys, delta) +} + +func (s *Service) finishWritePlan(ctx context.Context, meta *InodeMeta, ts, end uint64, elems []*kv.Elem[kv.OP], readKeys [][]byte, delta usageDelta) ([]*kv.Elem[kv.OP], [][]byte, error) { if end > meta.Size { meta.Size = end } - meta.MtimeNsec = now - meta.CtimeNsec = now - elem, err := putElem(fskeys.InodeKey(inode), meta) + meta.MtimeNsec = s.now().UnixNano() + meta.CtimeNsec = meta.MtimeNsec + elem, err := putElem(fskeys.InodeKey(meta.Inode), meta) if err != nil { - return 0, err + return nil, nil, err } elems = append(elems, elem) elems, readKeys, err = s.appendUsageUpdate(ctx, ts, elems, readKeys, delta) if err != nil { - return 0, err - } - if err := s.dispatchTxn(ctx, ts, elems, readKeys); err != nil { - return 0, s.translateHomeConflict(ctx, meta, err) + return nil, nil, err } - return len(data), nil + return elems, readKeys, nil } func (s *Service) writeChunkValue( From a10732815bd53cd166ad865d7856989ac99c67bf Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 14:54:08 +0900 Subject: [PATCH 10/35] filesystem: exercise journaled writes through FUSE Signed-off-by: bootjp --- internal/filesystem/fstest/memory.go | 12 ++++++- internal/filesystem/fuseadapter/adapter.go | 28 ++++++++++++++++- .../fuseadapter/adapter_ops_test.go | 27 ++++++++++++++++ .../filesystem/fuseadapter/adapter_test.go | 1 + .../filesystem/fuseadapter/mount_e2e_test.go | 31 ++++++++++++++++++- 5 files changed, 96 insertions(+), 3 deletions(-) diff --git a/internal/filesystem/fstest/memory.go b/internal/filesystem/fstest/memory.go index 51bb7e23c..7afbb7690 100644 --- a/internal/filesystem/fstest/memory.go +++ b/internal/filesystem/fstest/memory.go @@ -4,6 +4,7 @@ package fstest import ( "context" + "encoding/binary" "sync" "github.com/bootjp/elastickv/internal/filesystem" @@ -20,6 +21,8 @@ type MemoryDispatcher struct { store store.MVCCStore } +const commitTimestampBytes = 8 + // NewMemoryDispatcher wraps st in a dispatcher that commits every group at // the next commit timestamp. func NewMemoryDispatcher(st store.MVCCStore) *MemoryDispatcher { @@ -48,12 +51,19 @@ func (d *MemoryDispatcher) Dispatch( } mutations := make([]*store.KVPairMutation, 0, len(req.Elems)) for _, elem := range req.Elems { + value := append([]byte(nil), elem.Value...) + if elem.CommitTSValueOffset != 0 { + if len(value) < commitTimestampBytes || elem.CommitTSValueOffset > uint64(len(value)-commitTimestampBytes) { + return nil, errors.WithStack(filesystem.ErrInvalid) + } + binary.BigEndian.PutUint64(value[elem.CommitTSValueOffset:], commitTS) + } switch elem.Op { case kv.Put: mutations = append(mutations, &store.KVPairMutation{ Op: store.OpTypePut, Key: append([]byte(nil), elem.Key...), - Value: append([]byte(nil), elem.Value...), + Value: value, }) case kv.Del: mutations = append(mutations, &store.KVPairMutation{ diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 8277fd1c8..87d001b14 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "sync" + "sync/atomic" "syscall" "time" @@ -45,6 +46,9 @@ type Adapter struct { core Core mutations mutationTracker clientID []byte + operationSession filesystem.OperationSessionID + journaledWrites bool + operationSequence atomic.Uint64 readdirLimit int keepaliveInterval time.Duration keepaliveTimeout time.Duration @@ -78,6 +82,7 @@ var errnoMappings = []struct { {filesystem.ErrUnsupported, syscall.EOPNOTSUPP}, {filesystem.ErrStaleHome, syscall.EAGAIN}, {filesystem.ErrContentionLimit, syscall.EBUSY}, + {filesystem.ErrOperationQuota, syscall.ENOSPC}, {store.ErrWriteConflict, syscall.EAGAIN}, } @@ -103,6 +108,15 @@ func WithHandleKeepaliveTimeout(timeout time.Duration) Option { } } +// WithJournaledWrites assigns one durable operation identity to each FUSE +// write request. The caller must register the session before mounting. +func WithJournaledWrites(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.operationSession = session + a.journaledWrites = true + } +} + func New(core Core, clientID []byte, opts ...Option) *Adapter { a := &Adapter{ core: core, @@ -206,7 +220,19 @@ func (a *Adapter) Write(ctx context.Context, inode uint64, fh uint64, offset uin if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { return 0, errno } - n, writeErr = a.core.Write(ctx, inode, fh, offset, data) + if a.journaledWrites { + writer, ok := a.core.(interface { + WriteWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) (int, error) + }) + if !ok || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return 0, syscall.EOPNOTSUPP + } + id := filesystem.OperationID{Filesystem: a.operationSession.Filesystem, Session: a.operationSession.Session, + Sequence: a.operationSequence.Add(1)} + n, writeErr = writer.WriteWithOperation(ctx, id, inode, offset, data) + } else { + n, writeErr = a.core.Write(ctx, inode, fh, offset, data) + } if writeErr != nil && Errno(writeErr) == syscall.EIO { slog.WarnContext(ctx, "filesystem FUSE write failed", "inode", inode, "offset", offset, "length", len(data), "error", writeErr.Error()) } diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index 23cbda08c..941208a17 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -54,6 +54,33 @@ func TestAdapterWriteRefreshesLeaseThenForwards(t *testing.T) { require.Equal(t, []byte("data"), core.writeData) } +type journalWriteCore struct { + *fakeCore + ids []filesystem.OperationID +} + +func (c *journalWriteCore) WriteWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ uint64, data []byte) (int, error) { + c.ids = append(c.ids, id) + return len(data), nil +} + +func TestAdapterJournaledWritesAllocateStableSessionSequence(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalWriteCore{fakeCore: &fakeCore{}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledWrites(session)) + t.Cleanup(adapter.Close) + for range 2 { + n, errno := adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Zero(t, errno) + require.Equal(t, 1, n) + } + require.False(t, core.writeCalled) + require.Equal(t, []filesystem.OperationID{ + {Filesystem: session.Filesystem, Session: session.Session, Sequence: 1}, + {Filesystem: session.Filesystem, Session: session.Session, Sequence: 2}, + }, core.ids) +} + func TestAdapterWriteStopsWhenLeaseRefreshFails(t *testing.T) { ctx := context.Background() core := &fakeCore{refreshErr: filesystem.ErrStaleHome} diff --git a/internal/filesystem/fuseadapter/adapter_test.go b/internal/filesystem/fuseadapter/adapter_test.go index a132f8c2d..3e1a94f00 100644 --- a/internal/filesystem/fuseadapter/adapter_test.go +++ b/internal/filesystem/fuseadapter/adapter_test.go @@ -33,6 +33,7 @@ func TestErrnoMapsFilesystemErrors(t *testing.T) { {name: "unsupported", err: filesystem.ErrUnsupported, want: syscall.EOPNOTSUPP}, {name: "stale home", err: filesystem.ErrStaleHome, want: syscall.EAGAIN}, {name: "contention limit", err: filesystem.ErrContentionLimit, want: syscall.EBUSY}, + {name: "operation journal quota", err: filesystem.ErrOperationQuota, want: syscall.ENOSPC}, {name: "write conflict", err: cerrors.Wrap(store.ErrWriteConflict, "txn"), want: syscall.EAGAIN}, { name: "legacy textual forwarded conflict is uncertain", diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index e250df58c..f08591dd5 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -90,8 +90,12 @@ func mountMemoryService(t *testing.T, svc fuseadapter.Core, clientID string) str } func mountMemoryServiceAt(t *testing.T, svc fuseadapter.Core, clientID, mountPoint string) *e2eMount { + return mountMemoryServiceAtWithOptions(t, svc, clientID, mountPoint) +} + +func mountMemoryServiceAtWithOptions(t *testing.T, svc fuseadapter.Core, clientID, mountPoint string, adapterOptions ...fuseadapter.Option) *e2eMount { t.Helper() - adapter := fuseadapter.New(svc, []byte(clientID)) + adapter := fuseadapter.New(svc, []byte(clientID), adapterOptions...) server, err := fuseadapter.Mount(mountPoint, adapter, &fuse.MountOptions{DirectMount: os.Geteuid() == 0}) if err != nil { adapter.Close() @@ -384,3 +388,28 @@ func TestKernelMountStatfsReportsNameLimit(t *testing.T) { require.NotZero(t, stat.Bsize) require.EqualValues(t, 255, stat.Namelen) } + +func TestKernelMountJournaledFixedOffsetWrite(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-write") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-write", mountPoint, fuseadapter.WithJournaledWrites(session)) + path := filepath.Join(mountPoint, "file") + require.NoError(t, os.WriteFile(path, []byte("journaled"), 0o600)) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("journaled"), data) + record, err := journal.Resolve(t.Context(), filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, + }) + require.NoError(t, err) + require.Equal(t, filesystem.OperationCommitted, record.State) +} From 7e2b7b84cd8b47abcf5953d7ae0d28e578134349 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 14:55:18 +0900 Subject: [PATCH 11/35] docs: update fuse journal implementation status Signed-off-by: bootjp --- ...026_10_25_partial_fuse_conflict_semantics.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index d04b8eaec..b301b46d6 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -438,13 +438,16 @@ regression tests. ### Remaining work after the bounded legacy-path changes -The current branch adds typed forwarding of definitive write conflicts, bounded -retries for those conflicts in several filesystem service operations, a local -FUSE predecessor barrier, and mounted SQLite PERSIST/DELETE test coverage. These -changes improve the legacy path but do not route FUSE mutations through the -operation journal. In particular, `AdvanceGeneration` still fails closed, and -`Service.Fsync` still uses an attribute read rather than a durable receipt. -The tests for the legacy path cannot satisfy the strict-mode gates below. +The merged baseline forwards definitive write conflicts, retries several +filesystem service operations within bounds, tracks local FUSE predecessors, +and tests mounted SQLite PERSIST/DELETE workloads. +This branch adds a transaction-primary generation fence and an optional +journal-backed path for fixed-offset FUSE writes. +The default mount path still uses the legacy mutations. +Other mutation categories lack journal-backed planners, and `Service.Fsync` +still uses an attribute read rather than a durable receipt. +Neither the baseline tests nor the optional write-path test satisfies the +strict-mode gates below. Complete the remaining work in dependency order: From 5b8527ea9138a717c239e691bdff780b883bfd98 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:05:51 +0900 Subject: [PATCH 12/35] filesystem: bound journaled write reservations Signed-off-by: bootjp --- internal/filesystem/fuseadapter/adapter.go | 3 ++ .../fuseadapter/adapter_ops_test.go | 5 +- internal/filesystem/operation_write.go | 50 +++++++++++++++---- internal/filesystem/operation_write_test.go | 29 ++++++++++- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 87d001b14..6cf431026 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -220,6 +220,9 @@ func (a *Adapter) Write(ctx context.Context, inode uint64, fh uint64, offset uin if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { return 0, errno } + if len(data) == 0 { + return 0, 0 + } if a.journaledWrites { writer, ok := a.core.(interface { WriteWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) (int, error) diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index 941208a17..5e2dd583e 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -69,8 +69,11 @@ func TestAdapterJournaledWritesAllocateStableSessionSequence(t *testing.T) { core := &journalWriteCore{fakeCore: &fakeCore{}} adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledWrites(session)) t.Cleanup(adapter.Close) + n, errno := adapter.Write(t.Context(), 7, 9, 0, nil) + require.Zero(t, errno) + require.Zero(t, n) for range 2 { - n, errno := adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + n, errno = adapter.Write(t.Context(), 7, 9, 0, []byte("x")) require.Zero(t, errno) require.Equal(t, 1, n) } diff --git a/internal/filesystem/operation_write.go b/internal/filesystem/operation_write.go index ea8023b58..34ea75bad 100644 --- a/internal/filesystem/operation_write.go +++ b/internal/filesystem/operation_write.go @@ -11,14 +11,15 @@ import ( ) const ( - writeOperationIntentVersion = 1 - writeOperationIntentHeader = 25 - writeOperationResultBytes = 9 - writeOperationEffectLimit = 1 << 20 - writeOperationMaxData = 4 << 20 - writeResultSuccess = 0 - writeResultIsDir = 1 - writeResultNotFound = 2 + writeOperationIntentVersion = 1 + writeOperationIntentHeader = 25 + writeOperationResultBytes = 9 + writeOperationMaxEffectLimit = 1 << 20 + writeOperationEffectSlack = 256 + writeOperationMaxData = 4 << 20 + writeResultSuccess = 0 + writeResultIsDir = 1 + writeResultNotFound = 2 ) var errWriteCleanupChanged = errors.New("filesystem: write cleanup snapshot changed") @@ -39,11 +40,15 @@ func (s *Service) WriteWithOperation(ctx context.Context, id OperationID, inode, } data = bytes.Clone(data) intent := encodeWriteOperationIntent(inode, offset, data) + effectLimit, err := s.writeOperationEffectLimit(ctx, inode, offset, end) + if err != nil { + return 0, err + } journal, err := NewOperationJournal(s) if err != nil { return 0, err } - admitted, err := journal.Admit(ctx, id, intent, writeOperationResultBytes, writeOperationEffectLimit) + admitted, err := journal.Admit(ctx, id, intent, writeOperationResultBytes, effectLimit) if err != nil { return 0, err } @@ -60,6 +65,33 @@ func (s *Service) WriteWithOperation(ctx context.Context, id OperationID, inode, return s.executeWriteOperation(ctx, journal, id, attempt, inode, offset, end, data, intent) } +func (s *Service) writeOperationEffectLimit(ctx context.Context, inode, offset, end uint64) (uint32, error) { + ts, err := s.readTS(ctx) + if err != nil { + return 0, err + } + chunkSize := s.chunkSize + homeSlot := inode + meta, err := s.inodeAt(ctx, inode, ts) + if err == nil { + chunkSize = meta.effectiveChunkSize(s.chunkSize) + homeSlot = meta.HomeSlot + } else if !errors.Is(err, ErrNotFound) { + return 0, err + } + chunks := (end-1)/chunkSize - offset/chunkSize + 1 + if chunks > maxOperationEffectKeys-2 { + return 0, errors.WithStack(ErrOperationQuota) + } + chunkKey := fskeys.ChunkKey(homeSlot, inode, 0) + usageKey := fskeys.UsageRouteKey(kv.RouteKey(chunkKey)) + limit := chunks*uint64(len(chunkKey)) + uint64(len(fskeys.InodeKey(inode))+len(usageKey)+writeOperationEffectSlack) + if limit > writeOperationMaxEffectLimit { + return 0, errors.WithStack(ErrOperationQuota) + } + return uint32(limit), nil +} + func (s *Service) executeWriteOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode, offset, end uint64, data, intent []byte) (int, error) { deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) defer cancel() diff --git a/internal/filesystem/operation_write_test.go b/internal/filesystem/operation_write_test.go index bb6942ec9..be21c6853 100644 --- a/internal/filesystem/operation_write_test.go +++ b/internal/filesystem/operation_write_test.go @@ -116,6 +116,31 @@ func TestWriteWithOperationRetainsSemanticFailure(t *testing.T) { require.Equal(t, OperationRejected, record.State) } +func TestWriteWithOperationRetainsMissingInodeRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + _, err := svc.WriteWithOperation(ctx, id, 99, 0, []byte("x")) + require.ErrorIs(t, err, ErrNotFound) + _, err = svc.WriteWithOperation(ctx, id, 99, 0, []byte("x")) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestWriteOperationEffectLimitTracksChunkCount(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + one, err := svc.writeOperationEffectLimit(ctx, file.Inode, 0, 1) + require.NoError(t, err) + two, err := svc.writeOperationEffectLimit(ctx, file.Inode, 0, testChunkSize+1) + require.NoError(t, err) + require.Greater(t, two, one) + require.Less(t, two, uint32(writeOperationMaxEffectLimit)) +} + func TestWriteWithOperationDoesNotReplayPendingAttempt(t *testing.T) { ctx := context.Background() svc := newTestServiceWithOptions(t, []uint64{2}) @@ -125,7 +150,9 @@ func TestWriteWithOperationDoesNotReplayPendingAttempt(t *testing.T) { id := testWriteOperationID(t, svc, 1) journal, err := NewOperationJournal(svc) require.NoError(t, err) - _, err = journal.Admit(ctx, id, encodeWriteOperationIntent(file.Inode, 0, []byte("x")), writeOperationResultBytes, writeOperationEffectLimit) + effectLimit, err := svc.writeOperationEffectLimit(ctx, file.Inode, 0, 1) + require.NoError(t, err) + _, err = journal.Admit(ctx, id, encodeWriteOperationIntent(file.Inode, 0, []byte("x")), writeOperationResultBytes, effectLimit) require.NoError(t, err) _, err = journal.BeginAttempt(ctx, id) require.NoError(t, err) From 2a4c111836458bfb9d110bde12a17be516d6da76 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:06:04 +0900 Subject: [PATCH 13/35] ci: extend Go and Jepsen test runs Signed-off-by: bootjp --- .github/workflows/go-test.yml | 2 +- .github/workflows/jepsen-test-scheduled.yml | 36 ++++++++++----------- .github/workflows/jepsen-test.yml | 14 ++++---- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 2876d90a2..07cc5243d 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -30,7 +30,7 @@ jobs: - name: Reject production imports of test support run: bash scripts/check-production-imports.sh - name: Run race tests outside full-length gRPC sequences - run: go test -race ./... -skip "$GRPC_SEQUENCE_TESTS" + run: go test -race -timeout=30m ./... -skip "$GRPC_SEQUENCE_TESTS" env: # Fail, rather than skip, the kernel-mount tests when FUSE is unusable. ELASTICKV_REQUIRE_FUSE: "1" diff --git a/.github/workflows/jepsen-test-scheduled.yml b/.github/workflows/jepsen-test-scheduled.yml index b4ea08587..4ea870681 100644 --- a/.github/workflows/jepsen-test-scheduled.yml +++ b/.github/workflows/jepsen-test-scheduled.yml @@ -135,10 +135,10 @@ jobs: exit 1 - name: Run Redis Jepsen workload against elastickv working-directory: jepsen - timeout-minutes: 10 + timeout-minutes: 15 run: | - timeout 480 ~/lein run -m elastickv.redis-workload \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.redis-workload \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --key-count ${{ inputs.key-count || '16' }} \ @@ -148,20 +148,20 @@ jobs: --host 127.0.0.1 - name: Run Redis ZSet safety Jepsen workload against elastickv working-directory: jepsen - timeout-minutes: 10 + timeout-minutes: 15 run: | - timeout 480 ~/lein run -m elastickv.redis-zset-safety-workload \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.redis-zset-safety-workload \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --ports 63791,63792,63793 \ --host 127.0.0.1 - name: Run DynamoDB Jepsen workload against elastickv working-directory: jepsen - timeout-minutes: 10 + timeout-minutes: 15 run: | - timeout 480 ~/lein run -m elastickv.dynamodb-workload --local \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.dynamodb-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --key-count ${{ inputs.key-count || '16' }} \ @@ -228,10 +228,10 @@ jobs: fi - name: Run S3 Jepsen workload against elastickv working-directory: jepsen - timeout-minutes: 10 + timeout-minutes: 15 run: | - timeout 480 ~/lein run -m elastickv.s3-workload --local \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.s3-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --key-count ${{ inputs.key-count || '16' }} \ @@ -332,7 +332,7 @@ jobs: # rename (namespace txn and remove-while-open). Both modes run even if # one fails so the summary shows which mode broke. # See jepsen/src/elastickv/filesystem_workload.clj. - timeout-minutes: 20 + timeout-minutes: 30 env: FS_REQUESTED_KEYS: ${{ inputs.key-count || '16' }} FS_REQUESTED_WRITES: ${{ inputs.max-writes-per-key || '250' }} @@ -360,8 +360,8 @@ jobs: filesystem_budget "$mode" "$FS_REQUESTED_KEYS" "$FS_REQUESTED_WRITES" echo "Filesystem register budget: ${FS_KEY_COUNT} keys, ${FS_MAX_WRITES} writes per key" set +e - timeout 480 ~/lein run -m elastickv.filesystem-workload --local \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 720 ~/lein run -m elastickv.filesystem-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency ${{ inputs.concurrency || '8' }} \ --key-count "$FS_KEY_COUNT" \ @@ -389,7 +389,7 @@ jobs: fi - name: Run SQLite Jepsen workload on FUSE working-directory: jepsen - timeout-minutes: 12 + timeout-minutes: 20 run: | # Keep the 1024-write budget while bounding the ambiguous writes # Knossos must explore for any one SQLite database. @@ -397,8 +397,8 @@ jobs: source ./sqlite_budget.sh sqlite_budget 16 64 echo "SQLite register budget: ${SQLITE_KEY_COUNT} keys, ${SQLITE_MAX_WRITES} writes per key" - timeout 600 ~/lein run -m elastickv.sqlite-workload --local \ - --time-limit ${{ inputs.time-limit || '150' }} \ + timeout 900 ~/lein run -m elastickv.sqlite-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ --rate ${{ inputs.rate || '10' }} \ --concurrency 8 --key-count "$SQLITE_KEY_COUNT" --max-writes-per-key "$SQLITE_MAX_WRITES" \ --mount-dirs /tmp/elastickv-fs/fs-n1,/tmp/elastickv-fs/fs-n2,/tmp/elastickv-fs/fs-n3 diff --git a/.github/workflows/jepsen-test.yml b/.github/workflows/jepsen-test.yml index 1244e1d40..71888e019 100644 --- a/.github/workflows/jepsen-test.yml +++ b/.github/workflows/jepsen-test.yml @@ -287,15 +287,15 @@ jobs: # temp file + rename (namespace txn and remove-while-open). Both run # even if one fails so the log shows which mode broke. # See jepsen/src/elastickv/filesystem_workload.clj. - timeout-minutes: 6 + timeout-minutes: 10 run: | declare -A RESULT FAILED=() for mode in overwrite rename; do echo "::group::write-mode=${mode}" set +e - timeout 150 ~/lein run -m elastickv.filesystem-workload --local \ - --time-limit 5 --rate 5 --concurrency 6 \ + timeout 240 ~/lein run -m elastickv.filesystem-workload --local \ + --time-limit 30 --rate 5 --concurrency 6 \ --write-mode "${mode}" \ --mount-dirs /tmp/elastickv-ci/fs-n1,/tmp/elastickv-ci/fs-n2,/tmp/elastickv-ci/fs-n3 rc=$? @@ -319,7 +319,7 @@ jobs: fi - name: Run SQLite Jepsen workload on FUSE working-directory: jepsen - timeout-minutes: 3 + timeout-minutes: 6 run: | probe=/tmp/elastickv-ci/fs-n1/sqlite-probe.db if ! strace -f -o /tmp/elastickv-sqlite.strace sqlite3 "$probe" \ @@ -345,9 +345,9 @@ jobs: exit 1 fi done - timeout 150 ~/lein run -m elastickv.sqlite-workload --local \ - --time-limit 5 --rate 5 --concurrency 6 \ - --key-count 6 --max-writes-per-key 16 \ + timeout 240 ~/lein run -m elastickv.sqlite-workload --local \ + --time-limit 30 --rate 5 --concurrency 6 \ + --key-count 12 --max-writes-per-key 16 \ --mount-dirs /tmp/elastickv-ci/fs-n1,/tmp/elastickv-ci/fs-n2,/tmp/elastickv-ci/fs-n3 - name: Archive Jepsen store on filesystem failure if: failure() From 5ceb36e2a67d96fab0e9ff42551042eb69dae46c Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:15:02 +0900 Subject: [PATCH 14/35] ci: sustain longer SQLite Jepsen history Signed-off-by: bootjp --- .github/workflows/jepsen-test-scheduled.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/jepsen-test-scheduled.yml b/.github/workflows/jepsen-test-scheduled.yml index 4ea870681..bbbb740bb 100644 --- a/.github/workflows/jepsen-test-scheduled.yml +++ b/.github/workflows/jepsen-test-scheduled.yml @@ -391,11 +391,11 @@ jobs: working-directory: jepsen timeout-minutes: 20 run: | - # Keep the 1024-write budget while bounding the ambiguous writes - # Knossos must explore for any one SQLite database. + # Spread the longer run across databases while bounding the + # ambiguous writes Knossos must explore for any one database. bash test/sqlite_budget_test.sh source ./sqlite_budget.sh - sqlite_budget 16 64 + sqlite_budget 24 64 echo "SQLite register budget: ${SQLITE_KEY_COUNT} keys, ${SQLITE_MAX_WRITES} writes per key" timeout 900 ~/lein run -m elastickv.sqlite-workload --local \ --time-limit ${{ inputs.time-limit || '300' }} \ From eeb0019b0c739b0e9e3ac25057e560ac1ad2b92c Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:26:00 +0900 Subject: [PATCH 15/35] filesystem: journal metadata changes and verify FUSE receipts Signed-off-by: bootjp --- internal/filesystem/fuseadapter/adapter.go | 65 +++++- .../fuseadapter/adapter_ops_test.go | 86 ++++++++ .../filesystem/fuseadapter/mount_e2e_test.go | 30 +++ internal/filesystem/fuseadapter/receipts.go | 57 ++++++ internal/filesystem/operation_receipt.go | 24 +++ internal/filesystem/operation_setattr.go | 189 ++++++++++++++++++ internal/filesystem/operation_setattr_test.go | 51 +++++ 7 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 internal/filesystem/fuseadapter/receipts.go create mode 100644 internal/filesystem/operation_receipt.go create mode 100644 internal/filesystem/operation_setattr.go create mode 100644 internal/filesystem/operation_setattr_test.go diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 6cf431026..eaed24742 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -47,8 +47,12 @@ type Adapter struct { mutations mutationTracker clientID []byte operationSession filesystem.OperationSessionID + sessionConflict bool journaledWrites bool + journaledMetadata bool operationSequence atomic.Uint64 + receiptMu sync.Mutex + receipts map[uint64]map[filesystem.OperationID]struct{} readdirLimit int keepaliveInterval time.Duration keepaliveTimeout time.Duration @@ -112,11 +116,28 @@ func WithHandleKeepaliveTimeout(timeout time.Duration) Option { // write request. The caller must register the session before mounting. func WithJournaledWrites(session filesystem.OperationSessionID) Option { return func(a *Adapter) { - a.operationSession = session + a.setOperationSession(session) a.journaledWrites = true } } +// WithJournaledMetadata journals setattr requests that do not change size. +// Size changes need a separate chunk-deletion planner. +func WithJournaledMetadata(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledMetadata = true + } +} + +func (a *Adapter) setOperationSession(session filesystem.OperationSessionID) { + if a.operationSession != (filesystem.OperationSessionID{}) && a.operationSession != session { + a.sessionConflict = true + return + } + a.operationSession = session +} + func New(core Core, clientID []byte, opts ...Option) *Adapter { a := &Adapter{ core: core, @@ -188,10 +209,29 @@ func (a *Adapter) SetAttr( } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - stat, err = a.core.SetAttr(ctx, inode, mask, attrs) + if a.journaledMetadata && !mask.Size { + setter, ok := a.core.(interface { + SetAttrWithOperation(context.Context, filesystem.OperationID, uint64, filesystem.SetAttrMask, filesystem.SetAttr) (filesystem.Stat, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.Stat{}, syscall.EOPNOTSUPP + } + id := a.nextOperationID() + stat, err = setter.SetAttrWithOperation(ctx, id, inode, mask, attrs) + if err == nil { + a.recordReceipt(inode, id) + } + } else { + stat, err = a.core.SetAttr(ctx, inode, mask, attrs) + } return stat, Errno(err) } +func (a *Adapter) nextOperationID() filesystem.OperationID { + return filesystem.OperationID{Filesystem: a.operationSession.Filesystem, Session: a.operationSession.Session, + Sequence: a.operationSequence.Add(1)} +} + func (a *Adapter) Open(ctx context.Context, inode uint64) (uint64, syscall.Errno) { fh, err := a.core.Open(ctx, inode, a.clientID) if err == nil { @@ -227,12 +267,14 @@ func (a *Adapter) Write(ctx context.Context, inode uint64, fh uint64, offset uin writer, ok := a.core.(interface { WriteWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) (int, error) }) - if !ok || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return 0, syscall.EOPNOTSUPP } - id := filesystem.OperationID{Filesystem: a.operationSession.Filesystem, Session: a.operationSession.Session, - Sequence: a.operationSequence.Add(1)} + id := a.nextOperationID() n, writeErr = writer.WriteWithOperation(ctx, id, inode, offset, data) + if writeErr == nil { + a.recordReceipt(inode, id) + } } else { n, writeErr = a.core.Write(ctx, inode, fh, offset, data) } @@ -249,6 +291,9 @@ func (a *Adapter) Flush(ctx context.Context, inode uint64, fh uint64) syscall.Er if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { return errno } + if _, errno := a.verifyReceipts(ctx, inode); errno != 0 { + return errno + } return Errno(a.core.Flush(ctx, inode, fh)) } @@ -259,7 +304,15 @@ func (a *Adapter) Fsync(ctx context.Context, inode uint64, fh uint64, datasync b if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { return errno } - return Errno(a.core.Fsync(ctx, inode, fh, datasync)) + receipts, errno := a.verifyReceipts(ctx, inode) + if errno != 0 { + return errno + } + if errno := Errno(a.core.Fsync(ctx, inode, fh, datasync)); errno != 0 { + return errno + } + a.clearReceipts(inode, receipts) + return 0 } func (a *Adapter) FsyncDirectory(ctx context.Context, inode uint64) syscall.Errno { diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index 5e2dd583e..1e8135978 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -64,6 +64,92 @@ func (c *journalWriteCore) WriteWithOperation(_ context.Context, id filesystem.O return len(data), nil } +type journalMetadataCore struct { + *journalWriteCore + metadataIDs []filesystem.OperationID +} + +type receiptCore struct { + *journalMetadataCore + verified []filesystem.OperationID + verifyErr error +} + +func (c *receiptCore) VerifyOperationReceipt(_ context.Context, id filesystem.OperationID) error { + c.verified = append(c.verified, id) + return c.verifyErr +} + +func TestAdapterFsyncVerifiesJournalReceipts(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), + WithJournaledWrites(session), WithJournaledMetadata(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Zero(t, errno) + _, errno = adapter.SetAttr(t.Context(), 7, filesystem.SetAttrMask{Mode: true}, filesystem.SetAttr{Mode: 0o600}) + require.Zero(t, errno) + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.ElementsMatch(t, []filesystem.OperationID{core.ids[0], core.metadataIDs[0]}, core.verified) + require.True(t, core.fsyncCalled) + core.verified = nil + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.Empty(t, core.verified) +} + +func TestAdapterFsyncFailsClosedOnUnverifiedReceipt(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }, verifyErr: filesystem.ErrOperationPending} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledWrites(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Zero(t, errno) + require.Equal(t, syscall.EIO, adapter.Fsync(t.Context(), 7, 9, false)) + require.False(t, core.fsyncCalled) + require.Equal(t, syscall.EIO, adapter.Flush(t.Context(), 7, 9)) + require.False(t, core.flushCalled) +} + +func (c *journalMetadataCore) SetAttrWithOperation(_ context.Context, id filesystem.OperationID, inode uint64, _ filesystem.SetAttrMask, _ filesystem.SetAttr) (filesystem.Stat, error) { + c.metadataIDs = append(c.metadataIDs, id) + return filesystem.Stat{Inode: inode}, nil +} + +func TestAdapterJournaledMetadataSharesWriteSequence(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalMetadataCore{journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), + WithJournaledWrites(session), WithJournaledMetadata(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.SetAttr(t.Context(), 7, filesystem.SetAttrMask{Mode: true}, filesystem.SetAttr{Mode: 0o600}) + require.Zero(t, errno) + _, errno = adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Zero(t, errno) + require.EqualValues(t, 1, core.metadataIDs[0].Sequence) + require.EqualValues(t, 2, core.ids[0].Sequence) + require.Zero(t, core.setAttrInode) +} + +func TestAdapterRejectsDifferentJournalSessions(t *testing.T) { + first := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + second := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{3}} + core := &journalMetadataCore{journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), + WithJournaledWrites(first), WithJournaledMetadata(second)) + t.Cleanup(adapter.Close) + _, errno := adapter.Write(t.Context(), 7, 9, 0, []byte("x")) + require.Equal(t, syscall.EOPNOTSUPP, errno) + _, errno = adapter.SetAttr(t.Context(), 7, filesystem.SetAttrMask{Mode: true}, filesystem.SetAttr{Mode: 0o600}) + require.Equal(t, syscall.EOPNOTSUPP, errno) + require.Empty(t, core.ids) + require.Empty(t, core.metadataIDs) +} + func TestAdapterJournaledWritesAllocateStableSessionSequence(t *testing.T) { session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} core := &journalWriteCore{fakeCore: &fakeCore{}} diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index f08591dd5..8cee58065 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -404,6 +404,10 @@ func TestKernelMountJournaledFixedOffsetWrite(t *testing.T) { mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-write", mountPoint, fuseadapter.WithJournaledWrites(session)) path := filepath.Join(mountPoint, "file") require.NoError(t, os.WriteFile(path, []byte("journaled"), 0o600)) + f, err := os.Open(path) + require.NoError(t, err) + require.NoError(t, f.Sync()) + require.NoError(t, f.Close()) data, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, []byte("journaled"), data) @@ -413,3 +417,29 @@ func TestKernelMountJournaledFixedOffsetWrite(t *testing.T) { require.NoError(t, err) require.Equal(t, filesystem.OperationCommitted, record.State) } + +func TestKernelMountJournaledMetadataChange(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-metadata") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-metadata", mountPoint, + fuseadapter.WithJournaledMetadata(session)) + path := filepath.Join(mountPoint, "file") + require.NoError(t, os.WriteFile(path, []byte("data"), 0o600)) + require.NoError(t, os.Chmod(path, 0o640)) + f, err := os.Open(path) + require.NoError(t, err) + require.NoError(t, f.Sync()) + require.NoError(t, f.Close()) + stat, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o640), stat.Mode().Perm()) +} diff --git a/internal/filesystem/fuseadapter/receipts.go b/internal/filesystem/fuseadapter/receipts.go new file mode 100644 index 000000000..e82002836 --- /dev/null +++ b/internal/filesystem/fuseadapter/receipts.go @@ -0,0 +1,57 @@ +package fuseadapter + +import ( + "context" + "syscall" + + "github.com/bootjp/elastickv/internal/filesystem" +) + +type operationReceiptVerifier interface { + VerifyOperationReceipt(context.Context, filesystem.OperationID) error +} + +func (a *Adapter) recordReceipt(inode uint64, id filesystem.OperationID) { + a.receiptMu.Lock() + defer a.receiptMu.Unlock() + if a.receipts == nil { + a.receipts = make(map[uint64]map[filesystem.OperationID]struct{}) + } + if a.receipts[inode] == nil { + a.receipts[inode] = make(map[filesystem.OperationID]struct{}) + } + a.receipts[inode][id] = struct{}{} +} + +func (a *Adapter) verifyReceipts(ctx context.Context, inode uint64) ([]filesystem.OperationID, syscall.Errno) { + a.receiptMu.Lock() + receipts := make([]filesystem.OperationID, 0, len(a.receipts[inode])) + for id := range a.receipts[inode] { + receipts = append(receipts, id) + } + a.receiptMu.Unlock() + if len(receipts) == 0 { + return receipts, 0 + } + verifier, ok := a.core.(operationReceiptVerifier) + if !ok { + return nil, syscall.EOPNOTSUPP + } + for _, id := range receipts { + if err := verifier.VerifyOperationReceipt(ctx, id); err != nil { + return nil, Errno(err) + } + } + return receipts, 0 +} + +func (a *Adapter) clearReceipts(inode uint64, receipts []filesystem.OperationID) { + a.receiptMu.Lock() + defer a.receiptMu.Unlock() + for _, id := range receipts { + delete(a.receipts[inode], id) + } + if len(a.receipts[inode]) == 0 { + delete(a.receipts, inode) + } +} diff --git a/internal/filesystem/operation_receipt.go b/internal/filesystem/operation_receipt.go new file mode 100644 index 000000000..72f03b725 --- /dev/null +++ b/internal/filesystem/operation_receipt.go @@ -0,0 +1,24 @@ +package filesystem + +import ( + "context" + + "github.com/cockroachdb/errors" +) + +// VerifyOperationReceipt reads the authoritative terminal record and checks +// every participant proof before a local FUSE barrier can discharge it. +func (s *Service) VerifyOperationReceipt(ctx context.Context, id OperationID) error { + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + record, err := journal.Resolve(ctx, id) + if err != nil { + return err + } + if record.State != OperationCommitted { + return errors.WithStack(ErrOperationRecordCorrupt) + } + return nil +} diff --git a/internal/filesystem/operation_setattr.go b/internal/filesystem/operation_setattr.go new file mode 100644 index 000000000..e9b1c2873 --- /dev/null +++ b/internal/filesystem/operation_setattr.go @@ -0,0 +1,189 @@ +package filesystem + +import ( + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + setAttrOperationVersion = 1 + setAttrOperationResultLimit = 512 + setAttrOperationEffectLimit = 256 +) + +type setAttrOperationIntent struct { + Version uint8 `json:"version"` + Inode uint64 `json:"inode"` + Mask SetAttrMask `json:"mask"` + Attrs SetAttr `json:"attrs"` +} + +type setAttrOperationResult struct { + Version uint8 `json:"version"` + Stat Stat `json:"stat"` + Error uint8 `json:"error"` +} + +// SetAttrWithOperation journals metadata-only attribute changes. Size changes +// require a separate planner because chunk deletion may span transactions. +func (s *Service) SetAttrWithOperation(ctx context.Context, id OperationID, inode uint64, mask SetAttrMask, attrs SetAttr) (Stat, error) { + if mask.Size { + return Stat{}, errors.WithStack(ErrUnsupported) + } + intent, err := json.Marshal(setAttrOperationIntent{Version: setAttrOperationVersion, Inode: inode, Mask: mask, Attrs: attrs}) + if err != nil { + return Stat{}, errors.Wrap(err, "encode setattr intent") + } + journal, err := NewOperationJournal(s) + if err != nil { + return Stat{}, err + } + admitted, err := journal.Admit(ctx, id, intent, setAttrOperationResultLimit, setAttrOperationEffectLimit) + if err != nil { + return Stat{}, err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveSetAttrOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return Stat{}, err + } + return s.executeSetAttrOperation(ctx, journal, id, attempt, inode, mask, attrs, intent) +} + +func (s *Service) executeSetAttrOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (Stat, error) { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + stat, err := s.publishSetAttrOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, mask, attrs, intent) + if !isDefinitiveWriteConflict(err) { + return stat, err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return Stat{}, err + } + if retry == writeConflictRetryLimit-1 { + return Stat{}, errors.WithStack(ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return Stat{}, err + } + } + return Stat{}, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishSetAttrOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (Stat, error) { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return Stat{}, err + } + plan, semanticErr, err := s.planSetAttrOperation(ctx, snapshot, inode, mask, attrs, intent) + if err != nil { + return Stat{}, err + } + if semanticErr != nil { + return rejectSetAttrOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeSetAttrOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveSetAttrAttempt(ctx, journal, id, attempt) + } + return Stat{}, err +} + +func (s *Service) planSetAttrOperation(ctx context.Context, snapshot operationPlanningSnapshot, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (operationAttemptPlan, error, error) { + plan := operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(inode), fskeys.HomeKey(inode)}} + meta, err := s.inodeAt(ctx, inode, snapshot.ReadTS) + if errors.Is(err, ErrNotFound) { + return plan, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, err + } + now := s.now().UnixNano() + if mask.Mode { + meta.Mode = attrs.Mode + } + if mask.UID { + meta.UID = attrs.UID + } + if mask.GID { + meta.GID = attrs.GID + } + if mask.Atime { + meta.AtimeNsec = attrs.AtimeNsec + } + if mask.Mtime { + meta.MtimeNsec = attrs.MtimeNsec + } + meta.CtimeNsec = now + elem, err := putElem(fskeys.InodeKey(inode), meta) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.Effects = []*kv.Elem[kv.OP]{elem} + plan.Result, err = json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Stat: meta.Stat()}) + return plan, nil, errors.Wrap(err, "encode setattr result") +} + +func decodeSetAttrOperationResult(record operationRecord) (Stat, error) { + var result setAttrOperationResult + if len(record.Result) == 0 || len(record.Result) > setAttrOperationResultLimit || + json.Unmarshal(record.Result, &result) != nil || result.Version != setAttrOperationVersion { + return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationRejected && result.Error == 1 { + return Stat{}, errors.WithStack(ErrNotFound) + } + if record.State != OperationCommitted || result.Error != 0 || result.Stat.Inode == 0 { + return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) + } + return result.Stat, nil +} + +func resolveSetAttrOperation(ctx context.Context, journal *OperationJournal, id OperationID) (Stat, error) { + record, err := journal.Resolve(ctx, id) + if err != nil { + if errors.Is(err, ErrOperationPending) { + return Stat{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + return Stat{}, err + } + return decodeSetAttrOperationResult(record) +} + +func resolveSetAttrAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) (Stat, error) { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeSetAttrOperationResult(record) + } + return Stat{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectSetAttrOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (Stat, error) { + if !errors.Is(semanticErr, ErrNotFound) { + return Stat{}, semanticErr + } + result, err := json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Error: 1}) + if err != nil { + return Stat{}, errors.Wrap(err, "encode setattr rejection") + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) + if err != nil { + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + return Stat{}, err + } + return resolveSetAttrAttempt(ctx, journal, id, attempt) + } + return decodeSetAttrOperationResult(record) +} diff --git a/internal/filesystem/operation_setattr_test.go b/internal/filesystem/operation_setattr_test.go new file mode 100644 index 000000000..5215f3b9a --- /dev/null +++ b/internal/filesystem/operation_setattr_test.go @@ -0,0 +1,51 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetAttrWithOperationRetainsOriginalStat(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + mask := SetAttrMask{Mode: true, UID: true} + attrs := SetAttr{Mode: 0o600, UID: 42} + original, err := svc.SetAttrWithOperation(ctx, id, file.Inode, mask, attrs) + require.NoError(t, err) + require.EqualValues(t, 0o600, original.Mode) + require.EqualValues(t, 42, original.UID) + _, err = svc.SetAttr(ctx, file.Inode, SetAttrMask{Mode: true}, SetAttr{Mode: 0o644}) + require.NoError(t, err) + replayed, err := svc.SetAttrWithOperation(ctx, id, file.Inode, mask, attrs) + require.NoError(t, err) + require.Equal(t, original, replayed) + current, err := svc.GetAttr(ctx, file.Inode) + require.NoError(t, err) + require.EqualValues(t, 0o644, current.Mode) + _, err = svc.SetAttrWithOperation(ctx, id, file.Inode, mask, SetAttr{Mode: 0o640, UID: 42}) + require.ErrorIs(t, err, ErrOperationIDReused) +} + +func TestSetAttrWithOperationRetainsMissingInode(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + for range 2 { + _, err := svc.SetAttrWithOperation(ctx, id, 999, SetAttrMask{Mode: true}, SetAttr{Mode: 0o600}) + require.ErrorIs(t, err, ErrNotFound) + } +} + +func TestSetAttrWithOperationRequiresSeparateSizePlanner(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + _, err := svc.SetAttrWithOperation(ctx, OperationID{}, RootInode, SetAttrMask{Size: true}, SetAttr{Size: 1}) + require.ErrorIs(t, err, ErrUnsupported) +} From 2b8631b23a58984a2769021a271bf69381bcaf4d Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:29:16 +0900 Subject: [PATCH 16/35] docs: record journal receipt durability audit Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index b301b46d6..da07c44cf 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -441,13 +441,26 @@ regression tests. The merged baseline forwards definitive write conflicts, retries several filesystem service operations within bounds, tracks local FUSE predecessors, and tests mounted SQLite PERSIST/DELETE workloads. -This branch adds a transaction-primary generation fence and an optional -journal-backed path for fixed-offset FUSE writes. +This branch adds a transaction-primary generation fence and optional +journal-backed paths for fixed-offset FUSE writes and metadata-only setattr. +The adapter retains their successful operation IDs per inode. +Its flush and fsync paths resolve those IDs and check participant proofs. The default mount path still uses the legacy mutations. -Other mutation categories lack journal-backed planners, and `Service.Fsync` -still uses an attribute read rather than a durable receipt. -Neither the baseline tests nor the optional write-path test satisfies the -strict-mode gates below. +Other mutation categories lack journal-backed planners. +`Service.Fsync` still uses an attribute read after the adapter checks the +receipts for its supported journaled operations. +Directory fsync still has no durable namespace receipt. +The baseline and optional-path tests do not satisfy the strict-mode gates +below. + +The current Raft path persists Ready entries through `persistReadyToWAL` +before applying them. +The pinned etcd WAL calls sync for a Ready containing entries. +The Pebble FSM apply path defaults to `pebble.Sync`; its optional no-sync mode +depends on replay from the synced Raft WAL. +This code audit supports the receipt approach but does not close the M4 gate. +Crash tests must cover failure between WAL persistence, FSM apply, proof +visibility, FUSE reply, and the next fsync for every participant group. Complete the remaining work in dependency order: From 55771a2a455a6585535dacb99d60896815c327e1 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:33:08 +0900 Subject: [PATCH 17/35] filesystem: recover committed receipt after local reopen Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 2 + internal/filesystem/operation_receipt_test.go | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 internal/filesystem/operation_receipt_test.go diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index da07c44cf..e8049b545 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -458,6 +458,8 @@ before applying them. The pinned etcd WAL calls sync for a Ready containing entries. The Pebble FSM apply path defaults to `pebble.Sync`; its optional no-sync mode depends on replay from the synced Raft WAL. +The local Pebble close/reopen test recovers a committed write and its receipt. +It does not exercise a Raft process or node crash. This code audit supports the receipt approach but does not close the M4 gate. Crash tests must cover failure between WAL persistence, FSM apply, proof visibility, FUSE reply, and the next fsync for every participant group. diff --git a/internal/filesystem/operation_receipt_test.go b/internal/filesystem/operation_receipt_test.go new file mode 100644 index 000000000..fed3eccaa --- /dev/null +++ b/internal/filesystem/operation_receipt_test.go @@ -0,0 +1,37 @@ +package filesystem + +import ( + "context" + "path/filepath" + "testing" + + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +func TestVerifyOperationReceiptAfterPebbleReopen(t *testing.T) { + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "pebble") + st, err := store.NewPebbleStore(dir) + require.NoError(t, err) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + _, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("durable")) + require.NoError(t, err) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + require.NoError(t, st.Close()) + + reopened, err := store.NewPebbleStore(dir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + restarted, err := NewService(reopened, &testCoordinator{st: reopened}) + require.NoError(t, err) + require.NoError(t, restarted.VerifyOperationReceipt(ctx, id)) + data, err := restarted.Read(ctx, file.Inode, 0, 0, 7) + require.NoError(t, err) + require.Equal(t, []byte("durable"), data) +} From fa2ca78297a17442c12f7278fcea9a61b4d131f1 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:37:49 +0900 Subject: [PATCH 18/35] filesystem: journal file unlink and verify directory receipts Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 6 +- internal/filesystem/fuseadapter/adapter.go | 35 ++- .../fuseadapter/adapter_ops_test.go | 24 ++ .../filesystem/fuseadapter/mount_e2e_test.go | 29 ++ internal/filesystem/operation_unlink.go | 255 ++++++++++++++++++ internal/filesystem/operation_unlink_test.go | 52 ++++ 6 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 internal/filesystem/operation_unlink.go create mode 100644 internal/filesystem/operation_unlink_test.go diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index e8049b545..bf5c0ecc8 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -442,14 +442,16 @@ The merged baseline forwards definitive write conflicts, retries several filesystem service operations within bounds, tracks local FUSE predecessors, and tests mounted SQLite PERSIST/DELETE workloads. This branch adds a transaction-primary generation fence and optional -journal-backed paths for fixed-offset FUSE writes and metadata-only setattr. +journal-backed paths for fixed-offset FUSE writes, metadata-only setattr, +and file unlink. The adapter retains their successful operation IDs per inode. Its flush and fsync paths resolve those IDs and check participant proofs. +Directory fsync checks the file-unlink receipts recorded for that parent. The default mount path still uses the legacy mutations. Other mutation categories lack journal-backed planners. `Service.Fsync` still uses an attribute read after the adapter checks the receipts for its supported journaled operations. -Directory fsync still has no durable namespace receipt. +Other namespace mutations still have no durable directory receipt. The baseline and optional-path tests do not satisfy the strict-mode gates below. diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index eaed24742..9e1ba2588 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -50,6 +50,7 @@ type Adapter struct { sessionConflict bool journaledWrites bool journaledMetadata bool + journaledUnlink bool operationSequence atomic.Uint64 receiptMu sync.Mutex receipts map[uint64]map[filesystem.OperationID]struct{} @@ -130,6 +131,14 @@ func WithJournaledMetadata(session filesystem.OperationSessionID) Option { } } +// WithJournaledUnlink journals file unlink requests under the same session. +func WithJournaledUnlink(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledUnlink = true + } +} + func (a *Adapter) setOperationSession(session filesystem.OperationSessionID) { if a.operationSession != (filesystem.OperationSessionID{}) && a.operationSession != session { a.sessionConflict = true @@ -319,7 +328,15 @@ func (a *Adapter) FsyncDirectory(ctx context.Context, inode uint64) syscall.Errn if errno := a.mutations.wait(ctx, inode); errno != 0 { return errno } - return Errno(a.core.Fsync(ctx, inode, 0, false)) + receipts, errno := a.verifyReceipts(ctx, inode) + if errno != 0 { + return errno + } + if errno := Errno(a.core.Fsync(ctx, inode, 0, false)); errno != 0 { + return errno + } + a.clearReceipts(inode, receipts) + return 0 } func (a *Adapter) Release(ctx context.Context, inode uint64, fh uint64) syscall.Errno { @@ -391,7 +408,21 @@ func (a *Adapter) Unlink(ctx context.Context, parent uint64, name []byte) (errno } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - err = a.core.Unlink(ctx, parent, name) + if a.journaledUnlink { + unlinker, ok := a.core.(interface { + UnlinkWithOperation(context.Context, filesystem.OperationID, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return syscall.EOPNOTSUPP + } + id := a.nextOperationID() + err = unlinker.UnlinkWithOperation(ctx, id, parent, name) + if err == nil { + a.recordReceipt(parent, id) + } + } else { + err = a.core.Unlink(ctx, parent, name) + } return Errno(err) } diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index 1e8135978..dcbea2f79 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -75,6 +75,30 @@ type receiptCore struct { verifyErr error } +type journalUnlinkCore struct { + *receiptCore + unlinkIDs []filesystem.OperationID +} + +func (c *journalUnlinkCore) UnlinkWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte) error { + c.unlinkIDs = append(c.unlinkIDs, id) + return nil +} + +func TestAdapterDirectoryFsyncVerifiesUnlinkReceipt(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalUnlinkCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledUnlink(session)) + t.Cleanup(adapter.Close) + require.Zero(t, adapter.Unlink(t.Context(), filesystem.RootInode, []byte("file"))) + require.Zero(t, adapter.FsyncDirectory(t.Context(), filesystem.RootInode)) + require.Equal(t, core.unlinkIDs, core.verified) + require.True(t, core.fsyncCalled) + require.Zero(t, core.unlinkParent) +} + func (c *receiptCore) VerifyOperationReceipt(_ context.Context, id filesystem.OperationID) error { c.verified = append(c.verified, id) return c.verifyErr diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index 8cee58065..f76a58e56 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -443,3 +443,32 @@ func TestKernelMountJournaledMetadataChange(t *testing.T) { require.NoError(t, err) require.Equal(t, os.FileMode(0o640), stat.Mode().Perm()) } + +func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-unlink") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-unlink", mountPoint, + fuseadapter.WithJournaledUnlink(session)) + path := filepath.Join(mountPoint, "file") + require.NoError(t, os.WriteFile(path, []byte("old"), 0o600)) + require.NoError(t, os.Remove(path)) + dir, err := os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, dir.Sync()) + require.NoError(t, dir.Close()) + require.NoError(t, os.WriteFile(path, []byte("new"), 0o600)) + id := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 1} + require.NoError(t, svc.UnlinkWithOperation(t.Context(), id, filesystem.RootInode, []byte("file"))) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("new"), data) +} diff --git a/internal/filesystem/operation_unlink.go b/internal/filesystem/operation_unlink.go new file mode 100644 index 000000000..7ff951a00 --- /dev/null +++ b/internal/filesystem/operation_unlink.go @@ -0,0 +1,255 @@ +package filesystem + +import ( + "context" + "encoding/binary" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + unlinkIntentVersion = 1 + unlinkResultSuccess = 0 + unlinkResultMissing = 1 + unlinkResultNotDir = 2 + unlinkResultIsDir = 3 + unlinkEffectLimit = 64 << 10 + unlinkIntentHeader = 9 +) + +// UnlinkWithOperation records one file removal and its result under id. +// Directory removal has a separate emptiness predicate and planner. +func (s *Service) UnlinkWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte) error { + if err := validateName(name); err != nil { + return err + } + intent := make([]byte, unlinkIntentHeader+len(name)) + intent[0] = unlinkIntentVersion + binary.BigEndian.PutUint64(intent[1:9], parent) + copy(intent[unlinkIntentHeader:], name) + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + admitted, err := journal.Admit(ctx, id, intent, 1, unlinkEffectLimit) + if err != nil { + return err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveUnlinkOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return err + } + return s.executeUnlinkOperation(ctx, journal, id, attempt, parent, name, intent) +} + +func (s *Service) executeUnlinkOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, name, intent []byte) error { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + err := s.publishUnlinkOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, parent, name, intent) + if !isDefinitiveWriteConflict(err) { + return err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return err + } + if retry == writeConflictRetryLimit-1 { + return errors.WithStack(ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishUnlinkOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, parent uint64, name, intent []byte) error { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return err + } + plan, cleanup, semanticErr, err := s.planUnlinkOperation(ctx, snapshot, parent, name, intent) + if err != nil { + return err + } + if semanticErr != nil { + return rejectUnlinkOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + err = decodeUnlinkOperationResult(record) + } else if !isDefinitiveWriteConflict(err) { + err = resolveUnlinkAttempt(ctx, journal, id, attempt) + } + if err == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return err +} + +func (s *Service) planUnlinkOperation(ctx context.Context, snapshot operationPlanningSnapshot, parent uint64, name, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + ts := snapshot.ReadTS + entryKey := fskeys.DirEntryKey(parent, name) + plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(parent), entryKey}} + parentMeta, semanticErr, err := s.unlinkParentAt(ctx, parent, ts) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + if semanticErr != nil { + return plan, nil, semanticErr, nil + } + meta, targetKey, semanticErr, err := s.unlinkFileAt(ctx, parent, name, ts) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + if len(targetKey) != 0 { + plan.ReadKeys = append(plan.ReadKeys, targetKey) + } + if semanticErr != nil { + return plan, nil, semanticErr, nil + } + if err = s.ensureActiveHomeAt(ctx, meta, ts); err != nil { + return operationAttemptPlan{}, nil, nil, err + } + now := s.now().UnixNano() + parentMeta.MtimeNsec, parentMeta.CtimeNsec = now, now + fileElems, fileReads, delta, cleanup, err := s.unlinkFileTxnParts(ctx, ts, meta, now) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + parentEffects, err := unlinkParentEffects(parent, parentMeta, now) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = append([]*kv.Elem[kv.OP]{{Op: kv.Del, Key: entryKey}}, fileElems...) + plan.Effects = append(plan.Effects, parentEffects...) + plan.ReadKeys = append(plan.ReadKeys, fileReads...) + plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, ts, plan.Effects, plan.ReadKeys, delta) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Result = []byte{unlinkResultSuccess} + return plan, cleanup, nil, nil +} + +func unlinkParentEffects(parent uint64, meta *InodeMeta, now int64) ([]*kv.Elem[kv.OP], error) { + parentElem, err := putElem(fskeys.InodeKey(parent), meta) + if err != nil { + return nil, err + } + versionElem, err := putElem(fskeys.DirVersionKey(parent), unixNsecVersion(now)) + if err != nil { + return nil, err + } + return []*kv.Elem[kv.OP]{parentElem, versionElem}, nil +} + +func (s *Service) unlinkParentAt(ctx context.Context, parent, ts uint64) (*InodeMeta, error, error) { + meta, err := s.inodeAt(ctx, parent, ts) + if errors.Is(err, ErrNotFound) { + return nil, ErrNotFound, nil + } + if err != nil { + return nil, nil, err + } + if meta.Type != TypeDirectory { + return nil, ErrNotDir, nil + } + return meta, nil, nil +} + +func (s *Service) unlinkFileAt(ctx context.Context, parent uint64, name []byte, ts uint64) (*InodeMeta, []byte, error, error) { + entry, err := s.dirEntryAt(ctx, parent, name, ts) + if errors.Is(err, ErrNotFound) { + return nil, nil, ErrNotFound, nil + } + if err != nil { + return nil, nil, nil, err + } + targetKey := fskeys.InodeKey(entry.Inode) + meta, err := s.inodeAt(ctx, entry.Inode, ts) + if errors.Is(err, ErrNotFound) { + return nil, targetKey, ErrNotFound, nil + } + if err != nil { + return nil, nil, nil, err + } + if meta.Type == TypeDirectory { + return nil, targetKey, ErrIsDir, nil + } + return meta, targetKey, nil, nil +} + +func decodeUnlinkOperationResult(record operationRecord) error { + if len(record.Result) != 1 { + return errors.WithStack(ErrOperationRecordCorrupt) + } + switch record.Result[0] { + case unlinkResultSuccess: + if record.State == OperationCommitted { + return nil + } + case unlinkResultMissing: + if record.State == OperationRejected { + return errors.WithStack(ErrNotFound) + } + case unlinkResultNotDir: + if record.State == OperationRejected { + return errors.WithStack(ErrNotDir) + } + case unlinkResultIsDir: + if record.State == OperationRejected { + return errors.WithStack(ErrIsDir) + } + } + return errors.WithStack(ErrOperationRecordCorrupt) +} + +func resolveUnlinkOperation(ctx context.Context, journal *OperationJournal, id OperationID) error { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return err + } + return decodeUnlinkOperationResult(record) +} + +func resolveUnlinkAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) error { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeUnlinkOperationResult(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectUnlinkOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) error { + var status byte + switch { + case errors.Is(semanticErr, ErrNotFound): + status = unlinkResultMissing + case errors.Is(semanticErr, ErrNotDir): + status = unlinkResultNotDir + case errors.Is(semanticErr, ErrIsDir): + status = unlinkResultIsDir + default: + return semanticErr + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, + []byte{status}, plan.ReadKeys) + if err != nil { + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + return err + } + return resolveUnlinkAttempt(ctx, journal, id, attempt) + } + return decodeUnlinkOperationResult(record) +} diff --git a/internal/filesystem/operation_unlink_test.go b/internal/filesystem/operation_unlink_test.go new file mode 100644 index 000000000..bb80c15d9 --- /dev/null +++ b/internal/filesystem/operation_unlink_test.go @@ -0,0 +1,52 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnlinkWithOperationDoesNotRemoveRecreatedName(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + old, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("file"))) + newFile, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + require.NotEqual(t, old.Inode, newFile.Inode) + require.NoError(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("file"))) + resolved, err := svc.Resolve(ctx, RootInode, []byte("file")) + require.NoError(t, err) + require.Equal(t, newFile.Inode, resolved) + require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("other")), ErrOperationIDReused) +} + +func TestUnlinkWithOperationRetainsMissingName(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("file")), ErrNotFound) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("file")), ErrNotFound) + resolved, err := svc.Resolve(ctx, RootInode, []byte("file")) + require.NoError(t, err) + require.Equal(t, file.Inode, resolved) +} + +func TestUnlinkWithOperationRetainsDirectoryRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + _, err := svc.Mkdir(ctx, RootInode, []byte("dir"), CreateOptions{Mode: testDirMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + for range 2 { + require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("dir")), ErrIsDir) + } +} From 6a8aac0280432adafa5406ab82554b735eb416e8 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:41:19 +0900 Subject: [PATCH 19/35] filesystem: journal empty directory removal Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 4 +- internal/filesystem/fuseadapter/adapter.go | 25 +++- .../filesystem/fuseadapter/mount_e2e_test.go | 15 +- internal/filesystem/operation_unlink.go | 128 +++++++++++++----- internal/filesystem/operation_unlink_test.go | 33 +++++ 5 files changed, 165 insertions(+), 40 deletions(-) diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index bf5c0ecc8..79dec47a5 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -443,10 +443,10 @@ filesystem service operations within bounds, tracks local FUSE predecessors, and tests mounted SQLite PERSIST/DELETE workloads. This branch adds a transaction-primary generation fence and optional journal-backed paths for fixed-offset FUSE writes, metadata-only setattr, -and file unlink. +file unlink, and empty-directory removal. The adapter retains their successful operation IDs per inode. Its flush and fsync paths resolve those IDs and check participant proofs. -Directory fsync checks the file-unlink receipts recorded for that parent. +Directory fsync checks the removal receipts recorded for that parent. The default mount path still uses the legacy mutations. Other mutation categories lack journal-backed planners. `Service.Fsync` still uses an attribute read after the adapter checks the diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 9e1ba2588..ef489a146 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -51,6 +51,7 @@ type Adapter struct { journaledWrites bool journaledMetadata bool journaledUnlink bool + journaledRmdir bool operationSequence atomic.Uint64 receiptMu sync.Mutex receipts map[uint64]map[filesystem.OperationID]struct{} @@ -139,6 +140,14 @@ func WithJournaledUnlink(session filesystem.OperationSessionID) Option { } } +// WithJournaledRmdir journals empty-directory removal requests. +func WithJournaledRmdir(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledRmdir = true + } +} + func (a *Adapter) setOperationSession(session filesystem.OperationSessionID) { if a.operationSession != (filesystem.OperationSessionID{}) && a.operationSession != session { a.sessionConflict = true @@ -433,7 +442,21 @@ func (a *Adapter) Rmdir(ctx context.Context, parent uint64, name []byte) (errno } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - err = a.core.Rmdir(ctx, parent, name) + if a.journaledRmdir { + remover, ok := a.core.(interface { + RmdirWithOperation(context.Context, filesystem.OperationID, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return syscall.EOPNOTSUPP + } + id := a.nextOperationID() + err = remover.RmdirWithOperation(ctx, id, parent, name) + if err == nil { + a.recordReceipt(parent, id) + } + } else { + err = a.core.Rmdir(ctx, parent, name) + } return Errno(err) } diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index f76a58e56..a388284b9 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -457,7 +457,7 @@ func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { require.NoError(t, err) mountPoint := t.TempDir() mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-unlink", mountPoint, - fuseadapter.WithJournaledUnlink(session)) + fuseadapter.WithJournaledUnlink(session), fuseadapter.WithJournaledRmdir(session)) path := filepath.Join(mountPoint, "file") require.NoError(t, os.WriteFile(path, []byte("old"), 0o600)) require.NoError(t, os.Remove(path)) @@ -471,4 +471,17 @@ func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { data, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, []byte("new"), data) + dirPath := filepath.Join(mountPoint, "dir") + require.NoError(t, os.Mkdir(dirPath, 0o700)) + require.NoError(t, os.Remove(dirPath)) + dir, err = os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, dir.Sync()) + require.NoError(t, dir.Close()) + require.NoError(t, os.Mkdir(dirPath, 0o700)) + directoryID := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 2} + require.NoError(t, svc.RmdirWithOperation(t.Context(), directoryID, filesystem.RootInode, []byte("dir"))) + stat, err := os.Stat(dirPath) + require.NoError(t, err) + require.True(t, stat.IsDir()) } diff --git a/internal/filesystem/operation_unlink.go b/internal/filesystem/operation_unlink.go index 7ff951a00..933fc6764 100644 --- a/internal/filesystem/operation_unlink.go +++ b/internal/filesystem/operation_unlink.go @@ -10,24 +10,37 @@ import ( ) const ( - unlinkIntentVersion = 1 - unlinkResultSuccess = 0 - unlinkResultMissing = 1 - unlinkResultNotDir = 2 - unlinkResultIsDir = 3 - unlinkEffectLimit = 64 << 10 - unlinkIntentHeader = 9 + unlinkIntentVersion = 1 + unlinkResultSuccess = 0 + unlinkResultMissing = 1 + unlinkResultNotDir = 2 + unlinkResultIsDir = 3 + unlinkResultNotEmpty = 4 + unlinkEffectLimit = 64 << 10 + unlinkIntentHeader = 10 ) // UnlinkWithOperation records one file removal and its result under id. // Directory removal has a separate emptiness predicate and planner. func (s *Service) UnlinkWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte) error { + return s.unlinkNodeWithOperation(ctx, id, parent, name, false) +} + +// RmdirWithOperation journals an empty-directory removal and its result. +func (s *Service) RmdirWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte) error { + return s.unlinkNodeWithOperation(ctx, id, parent, name, true) +} + +func (s *Service) unlinkNodeWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte, directory bool) error { if err := validateName(name); err != nil { return err } intent := make([]byte, unlinkIntentHeader+len(name)) intent[0] = unlinkIntentVersion binary.BigEndian.PutUint64(intent[1:9], parent) + if directory { + intent[9] = 1 + } copy(intent[unlinkIntentHeader:], name) journal, err := NewOperationJournal(s) if err != nil { @@ -44,14 +57,14 @@ func (s *Service) UnlinkWithOperation(ctx context.Context, id OperationID, paren if err != nil { return err } - return s.executeUnlinkOperation(ctx, journal, id, attempt, parent, name, intent) + return s.executeUnlinkOperation(ctx, journal, id, attempt, parent, name, directory, intent) } -func (s *Service) executeUnlinkOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, name, intent []byte) error { +func (s *Service) executeUnlinkOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, name []byte, directory bool, intent []byte) error { deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) defer cancel() for retry := range writeConflictRetryLimit { - err := s.publishUnlinkOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, parent, name, intent) + err := s.publishUnlinkOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, parent, name, directory, intent) if !isDefinitiveWriteConflict(err) { return err } @@ -69,12 +82,12 @@ func (s *Service) executeUnlinkOperation(ctx context.Context, journal *Operation return errors.WithStack(ErrContentionLimit) } -func (s *Service) publishUnlinkOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, parent uint64, name, intent []byte) error { +func (s *Service) publishUnlinkOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, parent uint64, name []byte, directory bool, intent []byte) error { snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) if err != nil { return err } - plan, cleanup, semanticErr, err := s.planUnlinkOperation(ctx, snapshot, parent, name, intent) + plan, cleanup, semanticErr, err := s.planUnlinkOperation(ctx, snapshot, parent, name, directory, intent) if err != nil { return err } @@ -93,7 +106,7 @@ func (s *Service) publishUnlinkOperationAttempt(ctx context.Context, journal *Op return err } -func (s *Service) planUnlinkOperation(ctx context.Context, snapshot operationPlanningSnapshot, parent uint64, name, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { +func (s *Service) planUnlinkOperation(ctx context.Context, snapshot operationPlanningSnapshot, parent uint64, name []byte, directory bool, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { ts := snapshot.ReadTS entryKey := fskeys.DirEntryKey(parent, name) plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, @@ -105,22 +118,20 @@ func (s *Service) planUnlinkOperation(ctx context.Context, snapshot operationPla if semanticErr != nil { return plan, nil, semanticErr, nil } - meta, targetKey, semanticErr, err := s.unlinkFileAt(ctx, parent, name, ts) + meta, targetKey, semanticErr, err := s.unlinkTargetAt(ctx, parent, name, ts, directory) if err != nil { return operationAttemptPlan{}, nil, nil, err } if len(targetKey) != 0 { plan.ReadKeys = append(plan.ReadKeys, targetKey) } + appendUnlinkDirectoryPredicate(&plan, meta, directory, semanticErr) if semanticErr != nil { return plan, nil, semanticErr, nil } - if err = s.ensureActiveHomeAt(ctx, meta, ts); err != nil { - return operationAttemptPlan{}, nil, nil, err - } now := s.now().UnixNano() parentMeta.MtimeNsec, parentMeta.CtimeNsec = now, now - fileElems, fileReads, delta, cleanup, err := s.unlinkFileTxnParts(ctx, ts, meta, now) + nodeElems, nodeReads, delta, cleanup, err := s.unlinkNodeTxnParts(ctx, ts, parentMeta, meta, directory, now) if err != nil { return operationAttemptPlan{}, nil, nil, err } @@ -128,9 +139,9 @@ func (s *Service) planUnlinkOperation(ctx context.Context, snapshot operationPla if err != nil { return operationAttemptPlan{}, nil, nil, err } - plan.Effects = append([]*kv.Elem[kv.OP]{{Op: kv.Del, Key: entryKey}}, fileElems...) + plan.Effects = append([]*kv.Elem[kv.OP]{{Op: kv.Del, Key: entryKey}}, nodeElems...) plan.Effects = append(plan.Effects, parentEffects...) - plan.ReadKeys = append(plan.ReadKeys, fileReads...) + plan.ReadKeys = append(plan.ReadKeys, nodeReads...) plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, ts, plan.Effects, plan.ReadKeys, delta) if err != nil { return operationAttemptPlan{}, nil, nil, err @@ -139,6 +150,27 @@ func (s *Service) planUnlinkOperation(ctx context.Context, snapshot operationPla return plan, cleanup, nil, nil } +func appendUnlinkDirectoryPredicate(plan *operationAttemptPlan, meta *InodeMeta, directory bool, semanticErr error) { + if directory && meta != nil && semanticErr != nil { + plan.ReadKeys = append(plan.ReadKeys, fskeys.DirVersionKey(meta.Inode)) + } +} + +func (s *Service) unlinkNodeTxnParts(ctx context.Context, ts uint64, parent, target *InodeMeta, directory bool, now int64) ([]*kv.Elem[kv.OP], [][]byte, usageDelta, *chunkDeletePlan, error) { + if !directory { + return s.unlinkFileTxnParts(ctx, ts, target, now) + } + if parent.Nlink > fileInitialNlink { + parent.Nlink-- + } + keys := [][]byte{fskeys.InodeKey(target.Inode), fskeys.HomeKey(target.Inode), fskeys.DirVersionKey(target.Inode)} + effects := make([]*kv.Elem[kv.OP], 0, len(keys)) + for _, key := range keys { + effects = append(effects, &kv.Elem[kv.OP]{Op: kv.Del, Key: key}) + } + return effects, keys[1:], usageDelta{filesSub: 1}, nil, nil +} + func unlinkParentEffects(parent uint64, meta *InodeMeta, now int64) ([]*kv.Elem[kv.OP], error) { parentElem, err := putElem(fskeys.InodeKey(parent), meta) if err != nil { @@ -165,7 +197,7 @@ func (s *Service) unlinkParentAt(ctx context.Context, parent, ts uint64) (*Inode return meta, nil, nil } -func (s *Service) unlinkFileAt(ctx context.Context, parent uint64, name []byte, ts uint64) (*InodeMeta, []byte, error, error) { +func (s *Service) unlinkTargetAt(ctx context.Context, parent uint64, name []byte, ts uint64, directory bool) (*InodeMeta, []byte, error, error) { entry, err := s.dirEntryAt(ctx, parent, name, ts) if errors.Is(err, ErrNotFound) { return nil, nil, ErrNotFound, nil @@ -181,33 +213,55 @@ func (s *Service) unlinkFileAt(ctx context.Context, parent uint64, name []byte, if err != nil { return nil, nil, nil, err } - if meta.Type == TypeDirectory { - return nil, targetKey, ErrIsDir, nil + semanticErr, err := s.validateUnlinkTarget(ctx, meta, ts, directory) + if err != nil { + return nil, nil, nil, err + } + if semanticErr != nil { + return meta, targetKey, semanticErr, nil } return meta, targetKey, nil, nil } +func (s *Service) validateUnlinkTarget(ctx context.Context, meta *InodeMeta, ts uint64, directory bool) (error, error) { + if directory && meta.Type != TypeDirectory { + return ErrNotDir, nil + } + if !directory && meta.Type == TypeDirectory { + return ErrIsDir, nil + } + if directory { + if err := s.ensureDirectoryEmpty(ctx, meta.Inode, ts); err != nil { + if errors.Is(err, ErrNotEmpty) { + return ErrNotEmpty, nil + } + return nil, err + } + } else if err := s.ensureActiveHomeAt(ctx, meta, ts); err != nil { + return nil, err + } + return nil, nil +} + func decodeUnlinkOperationResult(record operationRecord) error { if len(record.Result) != 1 { return errors.WithStack(ErrOperationRecordCorrupt) } + if record.State == OperationCommitted && record.Result[0] == unlinkResultSuccess { + return nil + } + if record.State != OperationRejected { + return errors.WithStack(ErrOperationRecordCorrupt) + } switch record.Result[0] { - case unlinkResultSuccess: - if record.State == OperationCommitted { - return nil - } case unlinkResultMissing: - if record.State == OperationRejected { - return errors.WithStack(ErrNotFound) - } + return errors.WithStack(ErrNotFound) case unlinkResultNotDir: - if record.State == OperationRejected { - return errors.WithStack(ErrNotDir) - } + return errors.WithStack(ErrNotDir) case unlinkResultIsDir: - if record.State == OperationRejected { - return errors.WithStack(ErrIsDir) - } + return errors.WithStack(ErrIsDir) + case unlinkResultNotEmpty: + return errors.WithStack(ErrNotEmpty) } return errors.WithStack(ErrOperationRecordCorrupt) } @@ -240,6 +294,8 @@ func rejectUnlinkOperation(ctx context.Context, journal *OperationJournal, id Op status = unlinkResultNotDir case errors.Is(semanticErr, ErrIsDir): status = unlinkResultIsDir + case errors.Is(semanticErr, ErrNotEmpty): + status = unlinkResultNotEmpty default: return semanticErr } diff --git a/internal/filesystem/operation_unlink_test.go b/internal/filesystem/operation_unlink_test.go index bb80c15d9..3a9e5379e 100644 --- a/internal/filesystem/operation_unlink_test.go +++ b/internal/filesystem/operation_unlink_test.go @@ -50,3 +50,36 @@ func TestUnlinkWithOperationRetainsDirectoryRejection(t *testing.T) { require.ErrorIs(t, svc.UnlinkWithOperation(ctx, id, RootInode, []byte("dir")), ErrIsDir) } } + +func TestRmdirWithOperationDoesNotRemoveRecreatedDirectory(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + old, err := svc.Mkdir(ctx, RootInode, []byte("dir"), CreateOptions{Mode: testDirMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.RmdirWithOperation(ctx, id, RootInode, []byte("dir"))) + newDir, err := svc.Mkdir(ctx, RootInode, []byte("dir"), CreateOptions{Mode: testDirMode}) + require.NoError(t, err) + require.NotEqual(t, old.Inode, newDir.Inode) + require.NoError(t, svc.RmdirWithOperation(ctx, id, RootInode, []byte("dir"))) + resolved, err := svc.Resolve(ctx, RootInode, []byte("dir")) + require.NoError(t, err) + require.Equal(t, newDir.Inode, resolved) +} + +func TestRmdirWithOperationRetainsNotEmptyRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + dir, err := svc.Mkdir(ctx, RootInode, []byte("dir"), CreateOptions{Mode: testDirMode}) + require.NoError(t, err) + _, err = svc.Create(ctx, dir.Inode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.ErrorIs(t, svc.RmdirWithOperation(ctx, id, RootInode, []byte("dir")), ErrNotEmpty) + require.NoError(t, svc.Unlink(ctx, dir.Inode, []byte("file"))) + require.ErrorIs(t, svc.RmdirWithOperation(ctx, id, RootInode, []byte("dir")), ErrNotEmpty) + _, err = svc.Resolve(ctx, RootInode, []byte("dir")) + require.NoError(t, err) +} From abfea3c0313b4db2364b02ea6c56339ea1abd6ff Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:45:01 +0900 Subject: [PATCH 20/35] filesystem: journal same-directory rename Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 4 +- internal/filesystem/fuseadapter/adapter.go | 25 +- .../filesystem/fuseadapter/mount_e2e_test.go | 20 +- internal/filesystem/operation_rename.go | 246 ++++++++++++++++++ internal/filesystem/operation_rename_test.go | 52 ++++ internal/filesystem/operation_unlink.go | 2 + 6 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 internal/filesystem/operation_rename.go create mode 100644 internal/filesystem/operation_rename_test.go diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index 79dec47a5..0d664fd71 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -443,10 +443,10 @@ filesystem service operations within bounds, tracks local FUSE predecessors, and tests mounted SQLite PERSIST/DELETE workloads. This branch adds a transaction-primary generation fence and optional journal-backed paths for fixed-offset FUSE writes, metadata-only setattr, -file unlink, and empty-directory removal. +file unlink, empty-directory removal, and same-directory rename. The adapter retains their successful operation IDs per inode. Its flush and fsync paths resolve those IDs and check participant proofs. -Directory fsync checks the removal receipts recorded for that parent. +Directory fsync checks the removal and rename receipts recorded for that parent. The default mount path still uses the legacy mutations. Other mutation categories lack journal-backed planners. `Service.Fsync` still uses an attribute read after the adapter checks the diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index ef489a146..03e098b48 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -52,6 +52,7 @@ type Adapter struct { journaledMetadata bool journaledUnlink bool journaledRmdir bool + journaledRename bool operationSequence atomic.Uint64 receiptMu sync.Mutex receipts map[uint64]map[filesystem.OperationID]struct{} @@ -148,6 +149,14 @@ func WithJournaledRmdir(session filesystem.OperationSessionID) Option { } } +// WithJournaledRename journals same-directory rename requests. +func WithJournaledRename(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledRename = true + } +} + func (a *Adapter) setOperationSession(session filesystem.OperationSessionID) { if a.operationSession != (filesystem.OperationSessionID{}) && a.operationSession != session { a.sessionConflict = true @@ -473,7 +482,21 @@ func (a *Adapter) Rename( } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - err = a.core.Rename(ctx, oldParent, oldName, newParent, newName) + if a.journaledRename { + renamer, ok := a.core.(interface { + RenameWithOperation(context.Context, filesystem.OperationID, uint64, []byte, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return syscall.EOPNOTSUPP + } + id := a.nextOperationID() + err = renamer.RenameWithOperation(ctx, id, oldParent, oldName, newParent, newName) + if err == nil { + a.recordReceipt(oldParent, id) + } + } else { + err = a.core.Rename(ctx, oldParent, oldName, newParent, newName) + } return Errno(err) } diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index a388284b9..f0a3a098f 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -457,7 +457,8 @@ func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { require.NoError(t, err) mountPoint := t.TempDir() mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-unlink", mountPoint, - fuseadapter.WithJournaledUnlink(session), fuseadapter.WithJournaledRmdir(session)) + fuseadapter.WithJournaledUnlink(session), fuseadapter.WithJournaledRmdir(session), + fuseadapter.WithJournaledRename(session)) path := filepath.Join(mountPoint, "file") require.NoError(t, os.WriteFile(path, []byte("old"), 0o600)) require.NoError(t, os.Remove(path)) @@ -484,4 +485,21 @@ func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { stat, err := os.Stat(dirPath) require.NoError(t, err) require.True(t, stat.IsDir()) + source := filepath.Join(mountPoint, "source") + target := filepath.Join(mountPoint, "target") + require.NoError(t, os.WriteFile(source, []byte("old"), 0o600)) + require.NoError(t, os.Rename(source, target)) + dir, err = os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, dir.Sync()) + require.NoError(t, dir.Close()) + require.NoError(t, os.WriteFile(source, []byte("new"), 0o600)) + renameID := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 3} + require.NoError(t, svc.RenameWithOperation(t.Context(), renameID, filesystem.RootInode, []byte("source"), filesystem.RootInode, []byte("target"))) + sourceData, err := os.ReadFile(source) + require.NoError(t, err) + require.Equal(t, []byte("new"), sourceData) + targetData, err := os.ReadFile(target) + require.NoError(t, err) + require.Equal(t, []byte("old"), targetData) } diff --git a/internal/filesystem/operation_rename.go b/internal/filesystem/operation_rename.go new file mode 100644 index 000000000..170b4daa2 --- /dev/null +++ b/internal/filesystem/operation_rename.go @@ -0,0 +1,246 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + renameIntentVersion = 1 + renameEffectLimit = 64 << 10 + renameResultSuccess = 0 + renameResultMissing = 1 + renameResultNotDir = 2 + renameResultIsDir = 3 + renameResultNotEmpty = 4 +) + +type renameOperationIntent struct { + Version uint8 `json:"version"` + Parent uint64 `json:"parent"` + OldName []byte `json:"old_name"` + NewName []byte `json:"new_name"` +} + +// RenameWithOperation journals one same-directory rename and its result. +func (s *Service) RenameWithOperation(ctx context.Context, id OperationID, oldParent uint64, oldName []byte, newParent uint64, newName []byte) error { + if oldParent != newParent { + return errors.WithStack(ErrCrossDevice) + } + if err := validateName(oldName); err != nil { + return err + } + if err := validateName(newName); err != nil { + return err + } + oldName = bytes.Clone(oldName) + newName = bytes.Clone(newName) + intent, err := json.Marshal(renameOperationIntent{ + Version: renameIntentVersion, Parent: oldParent, + OldName: oldName, NewName: newName, + }) + if err != nil { + return errors.Wrap(err, "encode rename intent") + } + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + admitted, err := journal.Admit(ctx, id, intent, 1, renameEffectLimit) + if err != nil { + return err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveRenameOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return err + } + return s.executeRenameOperation(ctx, journal, id, attempt, oldParent, oldName, newName, intent) +} + +func (s *Service) executeRenameOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, oldName, newName, intent []byte) error { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + err := s.publishRenameOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, parent, oldName, newName, intent) + if !isDefinitiveWriteConflict(err) { + return err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return err + } + if retry == writeConflictRetryLimit-1 { + return errors.WithStack(ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishRenameOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, parent uint64, oldName, newName, intent []byte) error { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return err + } + plan, cleanup, semanticErr, err := s.planRenameOperation(ctx, snapshot, parent, oldName, newName, intent) + if err != nil { + return err + } + if semanticErr != nil { + return rejectRenameOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + err = decodeRenameOperationResult(record) + } else if !isDefinitiveWriteConflict(err) { + err = resolveRenameAttempt(ctx, journal, id, attempt) + } + if err == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return err +} + +func (s *Service) planRenameOperation(ctx context.Context, snapshot operationPlanningSnapshot, parent uint64, oldName, newName, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + ts := snapshot.ReadTS + oldKey := fskeys.DirEntryKey(parent, oldName) + newKey := fskeys.DirEntryKey(parent, newName) + plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(parent), oldKey, newKey}} + parentMeta, semanticErr, err := s.unlinkParentAt(ctx, parent, ts) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + if semanticErr != nil { + return plan, nil, semanticErr, nil + } + oldEntry, err := s.dirEntryAt(ctx, parent, oldName, ts) + if errors.Is(err, ErrNotFound) { + return plan, nil, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + if bytes.Equal(oldName, newName) { + plan.Result = []byte{renameResultSuccess} + return plan, nil, nil, nil + } + replacedMeta, err := s.renameReplacedFileMeta(ctx, parent, newName, oldEntry, ts) + if err != nil { + return plan, nil, err, nil + } + return s.planRenameEffects(ctx, plan, parentMeta, oldEntry, replacedMeta, parent, oldKey, newKey) +} + +func (s *Service) planRenameEffects(ctx context.Context, plan operationAttemptPlan, parentMeta *InodeMeta, oldEntry DirEntry, replaced *InodeMeta, parent uint64, oldKey, newKey []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + now := s.now().UnixNano() + parentMeta.MtimeNsec, parentMeta.CtimeNsec = now, now + putNew, err := putElem(newKey, DirEntry{Inode: oldEntry.Inode, Type: oldEntry.Type}) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + putVersion, err := putElem(fskeys.DirVersionKey(parent), unixNsecVersion(now)) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = []*kv.Elem[kv.OP]{{Op: kv.Del, Key: oldKey}, putNew, putVersion} + targetElems, targetReads, delta, cleanup, err := s.renameTargetTxnParts(ctx, plan.ReadTS, parentMeta, replaced, now) + if errors.Is(err, ErrNotEmpty) { + if replaced != nil { + plan.ReadKeys = append(plan.ReadKeys, fskeys.DirVersionKey(replaced.Inode)) + } + return plan, nil, ErrNotEmpty, nil + } + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = append(plan.Effects, targetElems...) + plan.ReadKeys = append(plan.ReadKeys, targetReads...) + putParent, err := putElem(fskeys.InodeKey(parent), parentMeta) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = append(plan.Effects, putParent) + plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, plan.ReadTS, plan.Effects, plan.ReadKeys, delta) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Result = []byte{renameResultSuccess} + return plan, cleanup, nil, nil +} + +func decodeRenameOperationResult(record operationRecord) error { + if len(record.Result) != 1 { + return errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationCommitted && record.Result[0] == renameResultSuccess { + return nil + } + if record.State != OperationRejected { + return errors.WithStack(ErrOperationRecordCorrupt) + } + switch record.Result[0] { + case renameResultMissing: + return errors.WithStack(ErrNotFound) + case renameResultNotDir: + return errors.WithStack(ErrNotDir) + case renameResultIsDir: + return errors.WithStack(ErrIsDir) + case renameResultNotEmpty: + return errors.WithStack(ErrNotEmpty) + } + return errors.WithStack(ErrOperationRecordCorrupt) +} + +func resolveRenameOperation(ctx context.Context, journal *OperationJournal, id OperationID) error { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return err + } + return decodeRenameOperationResult(record) +} + +func resolveRenameAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) error { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeRenameOperationResult(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectRenameOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) error { + var status byte + switch { + case errors.Is(semanticErr, ErrNotFound): + status = renameResultMissing + case errors.Is(semanticErr, ErrNotDir): + status = renameResultNotDir + case errors.Is(semanticErr, ErrIsDir): + status = renameResultIsDir + case errors.Is(semanticErr, ErrNotEmpty): + status = renameResultNotEmpty + default: + return semanticErr + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, []byte{status}, plan.ReadKeys) + if err != nil { + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + return err + } + return resolveRenameAttempt(ctx, journal, id, attempt) + } + return decodeRenameOperationResult(record) +} diff --git a/internal/filesystem/operation_rename_test.go b/internal/filesystem/operation_rename_test.go new file mode 100644 index 000000000..ad966e74f --- /dev/null +++ b/internal/filesystem/operation_rename_test.go @@ -0,0 +1,52 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenameWithOperationDoesNotMoveRecreatedSource(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + old, err := svc.Create(ctx, RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("source"), RootInode, []byte("target"))) + newFile, err := svc.Create(ctx, RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + require.NoError(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("source"), RootInode, []byte("target"))) + source, err := svc.Resolve(ctx, RootInode, []byte("source")) + require.NoError(t, err) + require.Equal(t, newFile.Inode, source) + target, err := svc.Resolve(ctx, RootInode, []byte("target")) + require.NoError(t, err) + require.Equal(t, old.Inode, target) +} + +func TestRenameWithOperationRetainsMissingSource(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + require.ErrorIs(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("source"), RootInode, []byte("target")), ErrNotFound) + file, err := svc.Create(ctx, RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + require.ErrorIs(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("source"), RootInode, []byte("target")), ErrNotFound) + source, err := svc.Resolve(ctx, RootInode, []byte("source")) + require.NoError(t, err) + require.Equal(t, file.Inode, source) +} + +func TestRenameWithOperationNoopIsRetained(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + _, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("file"), RootInode, []byte("file"))) + require.NoError(t, svc.RenameWithOperation(ctx, id, RootInode, []byte("file"), RootInode, []byte("file"))) +} diff --git a/internal/filesystem/operation_unlink.go b/internal/filesystem/operation_unlink.go index 933fc6764..1d52be23b 100644 --- a/internal/filesystem/operation_unlink.go +++ b/internal/filesystem/operation_unlink.go @@ -1,6 +1,7 @@ package filesystem import ( + "bytes" "context" "encoding/binary" @@ -35,6 +36,7 @@ func (s *Service) unlinkNodeWithOperation(ctx context.Context, id OperationID, p if err := validateName(name); err != nil { return err } + name = bytes.Clone(name) intent := make([]byte, unlinkIntentHeader+len(name)) intent[0] = unlinkIntentVersion binary.BigEndian.PutUint64(intent[1:9], parent) From c81344ca8eb14d5f3ccdc73db84be4c514cfce1c Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 15:50:38 +0900 Subject: [PATCH 21/35] filesystem: journal setattr size changes Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 4 +- internal/filesystem/fuseadapter/adapter.go | 5 +- .../filesystem/fuseadapter/mount_e2e_test.go | 11 ++ internal/filesystem/operation_setattr.go | 175 +++++++++++++++--- internal/filesystem/operation_setattr_test.go | 61 +++++- 5 files changed, 218 insertions(+), 38 deletions(-) diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index 0d664fd71..dabc4d85a 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -442,13 +442,13 @@ The merged baseline forwards definitive write conflicts, retries several filesystem service operations within bounds, tracks local FUSE predecessors, and tests mounted SQLite PERSIST/DELETE workloads. This branch adds a transaction-primary generation fence and optional -journal-backed paths for fixed-offset FUSE writes, metadata-only setattr, +journal-backed paths for fixed-offset FUSE writes, setattr including size changes, file unlink, empty-directory removal, and same-directory rename. The adapter retains their successful operation IDs per inode. Its flush and fsync paths resolve those IDs and check participant proofs. Directory fsync checks the removal and rename receipts recorded for that parent. The default mount path still uses the legacy mutations. -Other mutation categories lack journal-backed planners. +Create/open/mkdir, release, and lease maintenance still lack journal-backed planners. `Service.Fsync` still uses an attribute read after the adapter checks the receipts for its supported journaled operations. Other namespace mutations still have no durable directory receipt. diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 03e098b48..80a05eb4e 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -124,8 +124,7 @@ func WithJournaledWrites(session filesystem.OperationSessionID) Option { } } -// WithJournaledMetadata journals setattr requests that do not change size. -// Size changes need a separate chunk-deletion planner. +// WithJournaledMetadata journals setattr requests, including size changes. func WithJournaledMetadata(session filesystem.OperationSessionID) Option { return func(a *Adapter) { a.setOperationSession(session) @@ -236,7 +235,7 @@ func (a *Adapter) SetAttr( } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - if a.journaledMetadata && !mask.Size { + if a.journaledMetadata { setter, ok := a.core.(interface { SetAttrWithOperation(context.Context, filesystem.OperationID, uint64, filesystem.SetAttrMask, filesystem.SetAttr) (filesystem.Stat, error) }) diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index f0a3a098f..1b4bd1e4e 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -442,6 +442,17 @@ func TestKernelMountJournaledMetadataChange(t *testing.T) { stat, err := os.Stat(path) require.NoError(t, err) require.Equal(t, os.FileMode(0o640), stat.Mode().Perm()) + require.NoError(t, os.Truncate(path, 2)) + f, err = os.Open(path) + require.NoError(t, err) + require.NoError(t, f.Sync()) + require.NoError(t, f.Close()) + stat, err = os.Stat(path) + require.NoError(t, err) + require.EqualValues(t, 2, stat.Size()) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("da"), data) } func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { diff --git a/internal/filesystem/operation_setattr.go b/internal/filesystem/operation_setattr.go index e9b1c2873..2e4c7836d 100644 --- a/internal/filesystem/operation_setattr.go +++ b/internal/filesystem/operation_setattr.go @@ -13,6 +13,9 @@ const ( setAttrOperationVersion = 1 setAttrOperationResultLimit = 512 setAttrOperationEffectLimit = 256 + setAttrSizeEffectLimit = 64 << 10 + setAttrResultNotFound = 1 + setAttrResultIsDir = 2 ) type setAttrOperationIntent struct { @@ -28,12 +31,8 @@ type setAttrOperationResult struct { Error uint8 `json:"error"` } -// SetAttrWithOperation journals metadata-only attribute changes. Size changes -// require a separate planner because chunk deletion may span transactions. +// SetAttrWithOperation journals attribute changes and retains the original Stat. func (s *Service) SetAttrWithOperation(ctx context.Context, id OperationID, inode uint64, mask SetAttrMask, attrs SetAttr) (Stat, error) { - if mask.Size { - return Stat{}, errors.WithStack(ErrUnsupported) - } intent, err := json.Marshal(setAttrOperationIntent{Version: setAttrOperationVersion, Inode: inode, Mask: mask, Attrs: attrs}) if err != nil { return Stat{}, errors.Wrap(err, "encode setattr intent") @@ -42,7 +41,11 @@ func (s *Service) SetAttrWithOperation(ctx context.Context, id OperationID, inod if err != nil { return Stat{}, err } - admitted, err := journal.Admit(ctx, id, intent, setAttrOperationResultLimit, setAttrOperationEffectLimit) + effectLimit := uint32(setAttrOperationEffectLimit) + if mask.Size { + effectLimit = setAttrSizeEffectLimit + } + admitted, err := journal.Admit(ctx, id, intent, setAttrOperationResultLimit, effectLimit) if err != nil { return Stat{}, err } @@ -79,11 +82,7 @@ func (s *Service) executeSetAttrOperation(ctx context.Context, journal *Operatio } func (s *Service) publishSetAttrOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (Stat, error) { - snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) - if err != nil { - return Stat{}, err - } - plan, semanticErr, err := s.planSetAttrOperation(ctx, snapshot, inode, mask, attrs, intent) + plan, cleanup, semanticErr, err := s.prepareSetAttrOperationPlan(ctx, journal, id, attempt, inode, mask, attrs, intent) if err != nil { return Stat{}, err } @@ -92,25 +91,128 @@ func (s *Service) publishSetAttrOperationAttempt(ctx context.Context, journal *O } record, err := journal.Publish(ctx, id, attempt, plan) if err == nil { - return decodeSetAttrOperationResult(record) + stat, decodeErr := decodeSetAttrOperationResult(record) + if decodeErr == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return stat, decodeErr } if !isDefinitiveWriteConflict(err) { - return resolveSetAttrAttempt(ctx, journal, id, attempt) + stat, resolveErr := resolveSetAttrAttempt(ctx, journal, id, attempt) + if resolveErr == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return stat, resolveErr } return Stat{}, err } -func (s *Service) planSetAttrOperation(ctx context.Context, snapshot operationPlanningSnapshot, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (operationAttemptPlan, error, error) { +func (s *Service) prepareSetAttrOperationPlan(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { + for retry := range writeConflictRetryLimit { + cleaned, err := s.prepareSetAttrCleanup(ctx, inode, mask.Size) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan, cleanup, semanticErr, err := s.planSetAttrOperation(ctx, snapshot, cleaned, inode, mask, attrs, intent) + if errors.Is(err, errWriteCleanupChanged) { + if err = waitWriteConflictRetry(ctx, retry); err != nil { + return operationAttemptPlan{}, nil, nil, err + } + continue + } + return plan, cleanup, semanticErr, err + } + return operationAttemptPlan{}, nil, nil, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) prepareSetAttrCleanup(ctx context.Context, inode uint64, sizeChange bool) (*InodeMeta, error) { + if !sizeChange { + return nil, nil + } + ts, err := s.readTS(ctx) + if err != nil { + return nil, err + } + meta, err := s.inodeAt(ctx, inode, ts) + if errors.Is(err, ErrNotFound) { + return nil, nil + } + if err != nil || meta.Type == TypeDirectory { + return meta, err + } + if err = s.ensureActiveHomeAt(ctx, meta, ts); err != nil { + return nil, err + } + _, cleaned, _, err := s.cleanupStaleChunksBeforeSizeChange(ctx, inode, meta) + return cleaned, err +} + +func (s *Service) planSetAttrOperation(ctx context.Context, snapshot operationPlanningSnapshot, cleaned *InodeMeta, inode uint64, mask SetAttrMask, attrs SetAttr, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error, error) { plan := operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, ReadKeys: [][]byte{fskeys.InodeKey(inode), fskeys.HomeKey(inode)}} meta, err := s.inodeAt(ctx, inode, snapshot.ReadTS) if errors.Is(err, ErrNotFound) { - return plan, ErrNotFound, nil + return plan, nil, ErrNotFound, nil } if err != nil { - return operationAttemptPlan{}, nil, err + return operationAttemptPlan{}, nil, nil, err + } + oldSize := meta.Size + var cleanup *chunkDeletePlan + if mask.Size { + cleanup, err = s.planSetAttrSize(ctx, &plan, meta, cleaned, attrs.Size) + if errors.Is(err, ErrIsDir) { + return plan, nil, ErrIsDir, nil + } + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } } now := s.now().UnixNano() + applySetAttrFields(meta, mask, attrs, oldSize, now) + meta.CtimeNsec = now + elem, err := putElem(fskeys.InodeKey(inode), meta) + if err != nil { + return operationAttemptPlan{}, nil, nil, err + } + plan.Effects = append(plan.Effects, elem) + plan.Result, err = json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Stat: meta.Stat()}) + return plan, cleanup, nil, errors.Wrap(err, "encode setattr result") +} + +func (s *Service) planSetAttrSize(ctx context.Context, plan *operationAttemptPlan, meta, cleaned *InodeMeta, size uint64) (*chunkDeletePlan, error) { + if meta.Type == TypeDirectory { + return nil, ErrIsDir + } + if err := s.ensureActiveHomeAt(ctx, meta, plan.ReadTS); err != nil { + return nil, err + } + if cleaned == nil || (size > meta.Size && (meta.Size != cleaned.Size || meta.HomeSlot != cleaned.HomeSlot)) { + return nil, errWriteCleanupChanged + } + if size < meta.Size { + elems, reads, delta, cleanup, err := s.truncateChunkElems(ctx, meta, plan.ReadTS, size, meta.effectiveChunkSize(s.chunkSize)) + if err != nil { + return nil, err + } + plan.Effects = append(plan.Effects, elems...) + plan.ReadKeys = append(plan.ReadKeys, reads...) + plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, plan.ReadTS, plan.Effects, plan.ReadKeys, delta) + if err != nil { + return nil, err + } + meta.Size = size + return cleanup, nil + } + meta.Size = size + return nil, nil +} + +func applySetAttrFields(meta *InodeMeta, mask SetAttrMask, attrs SetAttr, oldSize uint64, now int64) { if mask.Mode { meta.Mode = attrs.Mode } @@ -125,25 +227,24 @@ func (s *Service) planSetAttrOperation(ctx context.Context, snapshot operationPl } if mask.Mtime { meta.MtimeNsec = attrs.MtimeNsec + } else if mask.Size && attrs.Size != oldSize { + meta.MtimeNsec = now } - meta.CtimeNsec = now - elem, err := putElem(fskeys.InodeKey(inode), meta) - if err != nil { - return operationAttemptPlan{}, nil, err - } - plan.Effects = []*kv.Elem[kv.OP]{elem} - plan.Result, err = json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Stat: meta.Stat()}) - return plan, nil, errors.Wrap(err, "encode setattr result") } func decodeSetAttrOperationResult(record operationRecord) (Stat, error) { var result setAttrOperationResult - if len(record.Result) == 0 || len(record.Result) > setAttrOperationResultLimit || - json.Unmarshal(record.Result, &result) != nil || result.Version != setAttrOperationVersion { + if len(record.Result) == 0 || len(record.Result) > setAttrOperationResultLimit { return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) } - if record.State == OperationRejected && result.Error == 1 { - return Stat{}, errors.WithStack(ErrNotFound) + if err := json.Unmarshal(record.Result, &result); err != nil { + return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) + } + if result.Version != setAttrOperationVersion { + return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationRejected { + return Stat{}, rejectedSetAttrError(result.Error) } if record.State != OperationCommitted || result.Error != 0 || result.Stat.Inode == 0 { return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) @@ -151,6 +252,17 @@ func decodeSetAttrOperationResult(record operationRecord) (Stat, error) { return result.Stat, nil } +func rejectedSetAttrError(status uint8) error { + switch status { + case setAttrResultNotFound: + return errors.WithStack(ErrNotFound) + case setAttrResultIsDir: + return errors.WithStack(ErrIsDir) + default: + return errors.WithStack(ErrOperationRecordCorrupt) + } +} + func resolveSetAttrOperation(ctx context.Context, journal *OperationJournal, id OperationID) (Stat, error) { record, err := journal.Resolve(ctx, id) if err != nil { @@ -171,10 +283,13 @@ func resolveSetAttrAttempt(ctx context.Context, journal *OperationJournal, id Op } func rejectSetAttrOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (Stat, error) { - if !errors.Is(semanticErr, ErrNotFound) { + status := uint8(setAttrResultNotFound) + if errors.Is(semanticErr, ErrIsDir) { + status = setAttrResultIsDir + } else if !errors.Is(semanticErr, ErrNotFound) { return Stat{}, semanticErr } - result, err := json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Error: 1}) + result, err := json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Error: status}) if err != nil { return Stat{}, errors.Wrap(err, "encode setattr rejection") } diff --git a/internal/filesystem/operation_setattr_test.go b/internal/filesystem/operation_setattr_test.go index 5215f3b9a..3c6eaf53b 100644 --- a/internal/filesystem/operation_setattr_test.go +++ b/internal/filesystem/operation_setattr_test.go @@ -1,6 +1,7 @@ package filesystem import ( + "bytes" "context" "testing" @@ -43,9 +44,63 @@ func TestSetAttrWithOperationRetainsMissingInode(t *testing.T) { } } -func TestSetAttrWithOperationRequiresSeparateSizePlanner(t *testing.T) { +func TestSetAttrWithOperationDoesNotRepeatShrinkAfterExtension(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + _, err = svc.Write(ctx, file.Inode, 0, 0, []byte("abcdefghijkl")) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + mask := SetAttrMask{Size: true, Mode: true} + attrs := SetAttr{Size: 4, Mode: 0o600} + original, err := svc.SetAttrWithOperation(ctx, id, file.Inode, mask, attrs) + require.NoError(t, err) + require.EqualValues(t, 4, original.Size) + require.EqualValues(t, 0o600, original.Mode) + _, err = svc.Write(ctx, file.Inode, 0, 4, []byte("xyz")) + require.NoError(t, err) + replayed, err := svc.SetAttrWithOperation(ctx, id, file.Inode, mask, attrs) + require.NoError(t, err) + require.Equal(t, original, replayed) + current, err := svc.GetAttr(ctx, file.Inode) + require.NoError(t, err) + require.EqualValues(t, 7, current.Size) + data, err := svc.Read(ctx, file.Inode, 0, 0, 7) + require.NoError(t, err) + require.Equal(t, []byte("abcdxyz"), data) +} + +func TestSetAttrWithOperationRetainsDirectorySizeRejection(t *testing.T) { ctx := context.Background() svc := newTestServiceWithOptions(t, nil) - _, err := svc.SetAttrWithOperation(ctx, OperationID{}, RootInode, SetAttrMask{Size: true}, SetAttr{Size: 1}) - require.ErrorIs(t, err, ErrUnsupported) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + for range 2 { + _, err := svc.SetAttrWithOperation(ctx, id, RootInode, SetAttrMask{Size: true}, SetAttr{Size: 1}) + require.ErrorIs(t, err, ErrIsDir) + } +} + +func TestSetAttrWithOperationFinishesPagedChunkDeletion(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + data := bytes.Repeat([]byte("x"), (chunkDeleteTxnPageSize+5)*int(testChunkSize)) + _, err = svc.Write(ctx, file.Inode, 0, 0, data) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + stat, err := svc.SetAttrWithOperation(ctx, id, file.Inode, SetAttrMask{Size: true}, SetAttr{Size: 0}) + require.NoError(t, err) + require.Zero(t, stat.Size) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + meta, err := svc.inodeAt(ctx, file.Inode, ts) + require.NoError(t, err) + page, err := svc.scanExistingChunkPage(ctx, meta.HomeSlot, file.Inode, ts, nil, chunkDeleteTxnPageSize+1) + require.NoError(t, err) + require.Empty(t, page) } From 13e6d35dc3d8000dbd33dddadaf28a94d2397703 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 16:09:19 +0900 Subject: [PATCH 22/35] filesystem: journal handle and creation mutations Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 23 +- internal/filesystem/fuseadapter/adapter.go | 128 ++++++++- .../fuseadapter/adapter_ops_test.go | 42 +++ .../filesystem/fuseadapter/mount_e2e_test.go | 39 +++ internal/filesystem/operation_crash_test.go | 93 +++++++ internal/filesystem/operation_create.go | 262 ++++++++++++++++++ internal/filesystem/operation_create_test.go | 83 ++++++ internal/filesystem/operation_lease.go | 158 +++++++++++ internal/filesystem/operation_lease_test.go | 33 +++ internal/filesystem/operation_open.go | 203 ++++++++++++++ internal/filesystem/operation_open_test.go | 64 +++++ internal/filesystem/operation_receipt_test.go | 99 +++++++ internal/filesystem/operation_release.go | 140 ++++++++++ internal/filesystem/operation_release_test.go | 55 ++++ 14 files changed, 1410 insertions(+), 12 deletions(-) create mode 100644 internal/filesystem/operation_crash_test.go create mode 100644 internal/filesystem/operation_create.go create mode 100644 internal/filesystem/operation_create_test.go create mode 100644 internal/filesystem/operation_lease.go create mode 100644 internal/filesystem/operation_lease_test.go create mode 100644 internal/filesystem/operation_open.go create mode 100644 internal/filesystem/operation_open_test.go create mode 100644 internal/filesystem/operation_release.go create mode 100644 internal/filesystem/operation_release_test.go diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index dabc4d85a..e7ec35ac7 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -443,15 +443,23 @@ filesystem service operations within bounds, tracks local FUSE predecessors, and tests mounted SQLite PERSIST/DELETE workloads. This branch adds a transaction-primary generation fence and optional journal-backed paths for fixed-offset FUSE writes, setattr including size changes, -file unlink, empty-directory removal, and same-directory rename. +create/mknod, mkdir, open, release, file unlink, empty-directory removal, and +same-directory rename. The adapter retains their successful operation IDs per inode. Its flush and fsync paths resolve those IDs and check participant proofs. -Directory fsync checks the removal and rename receipts recorded for that parent. +Directory fsync checks create, mkdir, removal and rename receipts recorded for +that parent. File fsync also checks create, open and release receipts for its +inode when those optional paths are selected. The default mount path still uses the legacy mutations. -Create/open/mkdir, release, and lease maintenance still lack journal-backed planners. +Lease maintenance still uses the legacy path; a typed backend planner exists +but is not yet connected to the adapter's foreground and background renewal +paths. Its predecessor ordering and journal retention need completion before +it can be selected for strict mode. `Service.Fsync` still uses an attribute read after the adapter checks the receipts for its supported journaled operations. -Other namespace mutations still have no durable directory receipt. +The optional receipt tracker is local to one mount and retains only successful +operations in memory. Session acknowledgement and journal garbage collection +are not wired to the kernel's reply boundary. The baseline and optional-path tests do not satisfy the strict-mode gates below. @@ -460,8 +468,11 @@ before applying them. The pinned etcd WAL calls sync for a Ready containing entries. The Pebble FSM apply path defaults to `pebble.Sync`; its optional no-sync mode depends on replay from the synced Raft WAL. -The local Pebble close/reopen test recovers a committed write and its receipt. -It does not exercise a Raft process or node crash. +Local Pebble close/reopen tests recover committed write, metadata, namespace, +create and open results with their receipts. A separate process-kill test +recovers create, write, truncate and rename receipts from the Pebble WAL +without closing the store. Neither test exercises a Raft process or node +crash. This code audit supports the receipt approach but does not close the M4 gate. Crash tests must cover failure between WAL persistence, FSM apply, proof visibility, FUSE reply, and the next fsync for every participant group. diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 80a05eb4e..8196db0b7 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -53,6 +53,10 @@ type Adapter struct { journaledUnlink bool journaledRmdir bool journaledRename bool + journaledCreate bool + journaledMkdir bool + journaledOpen bool + journaledRelease bool operationSequence atomic.Uint64 receiptMu sync.Mutex receipts map[uint64]map[filesystem.OperationID]struct{} @@ -156,6 +160,38 @@ func WithJournaledRename(session filesystem.OperationSessionID) Option { } } +// WithJournaledCreate journals file creation and returns its original inode and handle. +func WithJournaledCreate(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledCreate = true + } +} + +// WithJournaledMkdir journals directory creation and its original inode. +func WithJournaledMkdir(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledMkdir = true + } +} + +// WithJournaledOpen journals handle allocation and its original lease. +func WithJournaledOpen(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledOpen = true + } +} + +// WithJournaledRelease records a terminal release for each FUSE handle. +func WithJournaledRelease(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledRelease = true + } +} + func (a *Adapter) setOperationSession(session filesystem.OperationSessionID) { if a.operationSession != (filesystem.OperationSessionID{}) && a.operationSession != session { a.sessionConflict = true @@ -259,7 +295,23 @@ func (a *Adapter) nextOperationID() filesystem.OperationID { } func (a *Adapter) Open(ctx context.Context, inode uint64) (uint64, syscall.Errno) { - fh, err := a.core.Open(ctx, inode, a.clientID) + var fh uint64 + var err error + if a.journaledOpen { + opener, ok := a.core.(interface { + OpenWithOperation(context.Context, filesystem.OperationID, uint64, []byte) (uint64, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return 0, syscall.EOPNOTSUPP + } + id := a.nextOperationID() + fh, err = opener.OpenWithOperation(ctx, id, inode, a.clientID) + if err == nil { + a.recordReceipt(inode, id) + } + } else { + fh, err = a.core.Open(ctx, inode, a.clientID) + } if err == nil { a.trackOpenHandle(inode, fh) } else if Errno(err) == syscall.EIO { @@ -357,11 +409,31 @@ func (a *Adapter) FsyncDirectory(ctx context.Context, inode uint64) syscall.Errn } func (a *Adapter) Release(ctx context.Context, inode uint64, fh uint64) syscall.Errno { - err := a.core.Release(ctx, inode, fh, a.clientID) + defer a.untrackOpenHandle(inode, fh) + var err error + if a.journaledRelease { + ticket, errno := a.mutations.begin(ctx, inode) + if errno != 0 { + return errno + } + defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() + releaser, ok := a.core.(interface { + ReleaseWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return syscall.EOPNOTSUPP + } + id := a.nextOperationID() + err = releaser.ReleaseWithOperation(ctx, id, inode, fh, a.clientID) + if err == nil { + a.recordReceipt(inode, id) + } + } else { + err = a.core.Release(ctx, inode, fh, a.clientID) + } if err != nil && Errno(err) == syscall.EIO { slog.WarnContext(ctx, "filesystem FUSE release uncertain", "inode", inode, "fh", fh, "error", err.Error()) } - a.untrackOpenHandle(inode, fh) return Errno(err) } @@ -378,7 +450,22 @@ func (a *Adapter) Create( var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() opts.ClientID = append([]byte(nil), a.clientID...) - result, err = a.core.Create(ctx, parent, name, opts) + if a.journaledCreate { + creator, ok := a.core.(interface { + CreateWithOperation(context.Context, filesystem.OperationID, uint64, []byte, filesystem.CreateOptions) (filesystem.CreateResult, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.CreateResult{}, syscall.EOPNOTSUPP + } + id := a.nextOperationID() + result, err = creator.CreateWithOperation(ctx, id, parent, name, opts) + if err == nil { + a.recordReceipt(parent, id) + a.recordReceipt(result.Inode, id) + } + } else { + result, err = a.core.Create(ctx, parent, name, opts) + } if err == nil { a.trackOpenHandle(result.Inode, result.FH) } @@ -398,7 +485,22 @@ func (a *Adapter) Mknod( var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() opts.ClientID = nil - result, err = a.core.Create(ctx, parent, name, opts) + if a.journaledCreate { + creator, ok := a.core.(interface { + CreateWithOperation(context.Context, filesystem.OperationID, uint64, []byte, filesystem.CreateOptions) (filesystem.CreateResult, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.CreateResult{}, syscall.EOPNOTSUPP + } + id := a.nextOperationID() + result, err = creator.CreateWithOperation(ctx, id, parent, name, opts) + if err == nil { + a.recordReceipt(parent, id) + a.recordReceipt(result.Inode, id) + } + } else { + result, err = a.core.Create(ctx, parent, name, opts) + } return result, Errno(err) } @@ -414,7 +516,21 @@ func (a *Adapter) Mkdir( } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - result, err = a.core.Mkdir(ctx, parent, name, opts) + if a.journaledMkdir { + creator, ok := a.core.(interface { + MkdirWithOperation(context.Context, filesystem.OperationID, uint64, []byte, filesystem.CreateOptions) (filesystem.CreateResult, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.CreateResult{}, syscall.EOPNOTSUPP + } + id := a.nextOperationID() + result, err = creator.MkdirWithOperation(ctx, id, parent, name, opts) + if err == nil { + a.recordReceipt(parent, id) + } + } else { + result, err = a.core.Mkdir(ctx, parent, name, opts) + } return result, Errno(err) } diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index dcbea2f79..f4eaab1f0 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -80,6 +80,48 @@ type journalUnlinkCore struct { unlinkIDs []filesystem.OperationID } +type journalLifecycleCore struct { + *receiptCore + createID filesystem.OperationID + openID filesystem.OperationID + releaseID filesystem.OperationID +} + +func (c *journalLifecycleCore) CreateWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte, _ filesystem.CreateOptions) (filesystem.CreateResult, error) { + c.createID = id + return filesystem.CreateResult{Inode: 7, FH: 9, Stat: filesystem.Stat{Inode: 7}}, nil +} + +func (c *journalLifecycleCore) OpenWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte) (uint64, error) { + c.openID = id + return 10, nil +} + +func (c *journalLifecycleCore) ReleaseWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ uint64, _ []byte) error { + c.releaseID = id + return nil +} + +func TestAdapterFsyncVerifiesCreateOpenAndReleaseReceipts(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLifecycleCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), + WithJournaledCreate(session), WithJournaledOpen(session), WithJournaledRelease(session)) + t.Cleanup(adapter.Close) + result, errno := adapter.Create(t.Context(), filesystem.RootInode, []byte("file"), filesystem.CreateOptions{Mode: 0o600}) + require.Zero(t, errno) + fh, errno := adapter.Open(t.Context(), result.Inode) + require.Zero(t, errno) + require.Zero(t, adapter.Release(t.Context(), result.Inode, fh)) + require.Zero(t, adapter.Fsync(t.Context(), result.Inode, result.FH, false)) + require.ElementsMatch(t, []filesystem.OperationID{core.createID, core.openID, core.releaseID}, core.verified) + core.verified = nil + require.Zero(t, adapter.FsyncDirectory(t.Context(), filesystem.RootInode)) + require.Equal(t, []filesystem.OperationID{core.createID}, core.verified) +} + func (c *journalUnlinkCore) UnlinkWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte) error { c.unlinkIDs = append(c.unlinkIDs, id) return nil diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index 1b4bd1e4e..4563afef2 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -418,6 +418,45 @@ func TestKernelMountJournaledFixedOffsetWrite(t *testing.T) { require.Equal(t, filesystem.OperationCommitted, record.State) } +func TestKernelMountJournaledCreateAndMkdir(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-create") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-create", mountPoint, + fuseadapter.WithJournaledCreate(session), fuseadapter.WithJournaledMkdir(session), + fuseadapter.WithJournaledOpen(session), fuseadapter.WithJournaledRelease(session)) + filePath := filepath.Join(mountPoint, "file") + file, err := os.Create(filePath) + require.NoError(t, err) + require.NoError(t, file.Sync()) + require.NoError(t, file.Close()) + file, err = os.Open(filePath) + require.NoError(t, err) + require.NoError(t, file.Sync()) + require.NoError(t, file.Close()) + directoryPath := filepath.Join(mountPoint, "dir") + require.NoError(t, os.Mkdir(directoryPath, 0o700)) + directory, err := os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, directory.Sync()) + require.NoError(t, directory.Close()) + for sequence := uint64(1); sequence <= 5; sequence++ { + record, err := journal.Resolve(t.Context(), filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: sequence, + }) + require.NoError(t, err) + require.Equal(t, filesystem.OperationCommitted, record.State) + } +} + func TestKernelMountJournaledMetadataChange(t *testing.T) { if _, err := os.Stat("/dev/fuse"); err != nil { skipOrFail(t, "no /dev/fuse: %v", err) diff --git a/internal/filesystem/operation_crash_test.go b/internal/filesystem/operation_crash_test.go new file mode 100644 index 000000000..4530d577f --- /dev/null +++ b/internal/filesystem/operation_crash_test.go @@ -0,0 +1,93 @@ +package filesystem + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +const operationCrashDirectory = "ELASTICKV_OPERATION_CRASH_DIRECTORY" + +type operationCrashEvidence struct { + IDs []OperationID `json:"ids"` + Inode uint64 `json:"inode"` +} + +func TestJournalReceiptsSurviveProcessKill(t *testing.T) { + if dir := os.Getenv(operationCrashDirectory); dir != "" { + runOperationCrashHelper(t, dir) + return + } + dir := t.TempDir() + cmd := exec.Command(os.Args[0], "-test.run=^TestJournalReceiptsSurviveProcessKill$") + cmd.Env = append(os.Environ(), operationCrashDirectory+"="+dir) + output, err := cmd.CombinedOutput() + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, string(output)) + status, ok := exit.Sys().(syscall.WaitStatus) + require.True(t, ok, string(output)) + require.Equal(t, syscall.SIGKILL, status.Signal(), string(output)) + raw, err := os.ReadFile(filepath.Join(dir, "evidence.json")) + require.NoError(t, err) + var evidence operationCrashEvidence + require.NoError(t, json.Unmarshal(raw, &evidence)) + require.Len(t, evidence.IDs, 4) + + st, err := store.NewPebbleStore(filepath.Join(dir, "pebble")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, st.Close()) }) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + for _, id := range evidence.IDs { + require.NoError(t, svc.VerifyOperationReceipt(t.Context(), id)) + } + inode, err := svc.Resolve(t.Context(), RootInode, []byte("target")) + require.NoError(t, err) + require.Equal(t, evidence.Inode, inode) + data, err := svc.Read(t.Context(), inode, 0, 0, 3) + require.NoError(t, err) + require.Equal(t, []byte("dur"), data) +} + +func runOperationCrashHelper(t *testing.T, dir string) { + ctx := context.Background() + st, err := store.NewPebbleStore(filepath.Join(dir, "pebble")) + require.NoError(t, err) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + firstID := testWriteOperationID(t, svc, 1) + evidence := operationCrashEvidence{IDs: make([]OperationID, 4)} + for i := range evidence.IDs { + evidence.IDs[i] = firstID + evidence.IDs[i].Sequence = uint64(i + 1) + } + file, err := svc.CreateWithOperation(ctx, evidence.IDs[0], RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + evidence.Inode = file.Inode + _, err = svc.WriteWithOperation(ctx, evidence.IDs[1], file.Inode, 0, []byte("durable")) + require.NoError(t, err) + _, err = svc.SetAttrWithOperation(ctx, evidence.IDs[2], file.Inode, SetAttrMask{Size: true}, SetAttr{Size: 3}) + require.NoError(t, err) + require.NoError(t, svc.RenameWithOperation(ctx, evidence.IDs[3], RootInode, []byte("source"), RootInode, []byte("target"))) + for _, id := range evidence.IDs { + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + } + raw, err := json.Marshal(evidence) + require.NoError(t, err) + fileOnDisk, err := os.OpenFile(filepath.Join(dir, "evidence.json"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + require.NoError(t, err) + _, err = fileOnDisk.Write(raw) + require.NoError(t, err) + require.NoError(t, fileOnDisk.Sync()) + require.NoError(t, fileOnDisk.Close()) + // Kill without closing Pebble or running test cleanup. + require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGKILL)) +} diff --git a/internal/filesystem/operation_create.go b/internal/filesystem/operation_create.go new file mode 100644 index 000000000..fd7bd2020 --- /dev/null +++ b/internal/filesystem/operation_create.go @@ -0,0 +1,262 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + createOperationVersion = 1 + createOperationResultLimit = 512 + createOperationEffectLimit = 4096 + createResultMissing = 1 + createResultNotDir = 2 + createResultExists = 3 +) + +type createOperationIntent struct { + Version uint8 `json:"version"` + Parent uint64 `json:"parent"` + Name []byte `json:"name"` + Type FileType `json:"type"` + Options CreateOptions `json:"options"` +} + +type createOperationResult struct { + Version uint8 `json:"version"` + Result CreateResult `json:"result"` + Error uint8 `json:"error"` +} + +// CreateWithOperation retains the created inode, open handle and original Stat. +func (s *Service) CreateWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte, opts CreateOptions) (CreateResult, error) { + return s.createNodeWithOperation(ctx, id, parent, name, TypeFile, opts) +} + +// MkdirWithOperation retains the created directory inode and original Stat. +func (s *Service) MkdirWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte, opts CreateOptions) (CreateResult, error) { + return s.createNodeWithOperation(ctx, id, parent, name, TypeDirectory, opts) +} + +func (s *Service) createNodeWithOperation(ctx context.Context, id OperationID, parent uint64, name []byte, typ FileType, opts CreateOptions) (CreateResult, error) { + if err := validateName(name); err != nil { + return CreateResult{}, err + } + name = bytes.Clone(name) + opts.ClientID = bytes.Clone(opts.ClientID) + intent, err := json.Marshal(createOperationIntent{Version: createOperationVersion, Parent: parent, Name: name, Type: typ, Options: opts}) + if err != nil { + return CreateResult{}, errors.Wrap(err, "encode create intent") + } + journal, err := NewOperationJournal(s) + if err != nil { + return CreateResult{}, err + } + admitted, err := journal.Admit(ctx, id, intent, createOperationResultLimit, createOperationEffectLimit) + if err != nil { + return CreateResult{}, err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveCreateOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return CreateResult{}, err + } + return s.executeCreateOperation(ctx, journal, id, attempt, parent, name, typ, opts, intent) +} + +func (s *Service) executeCreateOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, name []byte, typ FileType, opts CreateOptions, intent []byte) (CreateResult, error) { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + result, err := s.publishCreateOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, parent, name, typ, opts, intent) + if !isDefinitiveWriteConflict(err) { + return result, err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return CreateResult{}, err + } + if retry == writeConflictRetryLimit-1 { + return CreateResult{}, errors.WithStack(ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return CreateResult{}, err + } + } + return CreateResult{}, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishCreateOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, parent uint64, name []byte, typ FileType, opts CreateOptions, intent []byte) (CreateResult, error) { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return CreateResult{}, err + } + plan, semanticErr, err := s.planCreateOperation(ctx, snapshot, parent, name, typ, opts, intent) + if err != nil { + return CreateResult{}, err + } + if semanticErr != nil { + return rejectCreateOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeCreateOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveCreateAttempt(ctx, journal, id, attempt) + } + return CreateResult{}, err +} + +func (s *Service) planCreateOperation(ctx context.Context, snapshot operationPlanningSnapshot, parent uint64, name []byte, typ FileType, opts CreateOptions, intent []byte) (operationAttemptPlan, error, error) { + ts := snapshot.ReadTS + entryKey := fskeys.DirEntryKey(parent, name) + plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(parent), entryKey}} + parentMeta, err := s.inodeAt(ctx, parent, ts) + if errors.Is(err, ErrNotFound) { + return plan, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, err + } + if parentMeta.Type != TypeDirectory { + return plan, ErrNotDir, nil + } + if _, err = s.dirEntryAt(ctx, parent, name, ts); err == nil { + return plan, ErrExists, nil + } else if !errors.Is(err, ErrNotFound) { + return operationAttemptPlan{}, nil, err + } + inode, err := s.allocateInode(ctx, ts) + if err != nil { + return operationAttemptPlan{}, nil, err + } + result, effects, err := s.createOperationEffects(parent, parentMeta, inode, name, typ, opts) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.ReadKeys = append(plan.ReadKeys, fskeys.InodeKey(inode)) + plan.Effects, plan.ReadKeys, err = s.appendUsageUpdate(ctx, ts, effects, plan.ReadKeys, usageDelta{filesAdd: 1}) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.Result, err = json.Marshal(createOperationResult{Version: createOperationVersion, Result: result}) + return plan, nil, errors.Wrap(err, "encode create result") +} + +func (s *Service) createOperationEffects(parent uint64, parentMeta *InodeMeta, inode uint64, name []byte, typ FileType, opts CreateOptions) (CreateResult, []*kv.Elem[kv.OP], error) { + now := s.now().UnixNano() + homeSlot := s.homeSlot(inode) + nlink := fileInitialNlink + if typ == TypeDirectory { + nlink = directoryInitialNlink + parentMeta.Nlink++ + } + meta := InodeMeta{Inode: inode, Parent: parent, Type: typ, Mode: opts.Mode, UID: opts.UID, GID: opts.GID, + AtimeNsec: now, MtimeNsec: now, CtimeNsec: now, ChunkSize: s.chunkSize, + HomeSlot: homeSlot, Epoch: initialEpoch, Nlink: nlink, Generation: defaultGeneration} + parentMeta.MtimeNsec, parentMeta.CtimeNsec = now, now + effects, err := putElems( + fskeys.InodeKey(inode), meta, + fskeys.HomeKey(inode), Home{HomeSlot: homeSlot, State: HomeStateActive, Epoch: initialEpoch}, + fskeys.DirEntryKey(parent, name), DirEntry{Inode: inode, Type: typ}, + fskeys.InodeKey(parent), parentMeta, + fskeys.DirVersionKey(parent), unixNsecVersion(now), + ) + if err != nil { + return CreateResult{}, nil, err + } + var fh uint64 + if len(opts.ClientID) > 0 { + fh, err = s.allocID() + if err != nil { + return CreateResult{}, nil, err + } + ref, err := s.refPutElem(inode, opts.ClientID, fh) + if err != nil { + return CreateResult{}, nil, err + } + effects = append(effects, ref) + } + return CreateResult{Inode: inode, FH: fh, Stat: meta.Stat()}, effects, nil +} + +func decodeCreateOperationResult(record operationRecord) (CreateResult, error) { + var result createOperationResult + if len(record.Result) == 0 || len(record.Result) > createOperationResultLimit || json.Unmarshal(record.Result, &result) != nil || result.Version != createOperationVersion { + return CreateResult{}, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationRejected { + return CreateResult{}, rejectedCreateError(result.Error) + } + if record.State != OperationCommitted || result.Error != 0 || result.Result.Inode == 0 || result.Result.Stat.Inode != result.Result.Inode { + return CreateResult{}, errors.WithStack(ErrOperationRecordCorrupt) + } + return result.Result, nil +} + +func rejectedCreateError(status uint8) error { + switch status { + case createResultMissing: + return errors.WithStack(ErrNotFound) + case createResultNotDir: + return errors.WithStack(ErrNotDir) + case createResultExists: + return errors.WithStack(ErrExists) + default: + return errors.WithStack(ErrOperationRecordCorrupt) + } +} + +func resolveCreateOperation(ctx context.Context, journal *OperationJournal, id OperationID) (CreateResult, error) { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return CreateResult{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return CreateResult{}, err + } + return decodeCreateOperationResult(record) +} + +func resolveCreateAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) (CreateResult, error) { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeCreateOperationResult(record) + } + return CreateResult{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectCreateOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (CreateResult, error) { + var status uint8 + switch { + case errors.Is(semanticErr, ErrNotFound): + status = createResultMissing + case errors.Is(semanticErr, ErrNotDir): + status = createResultNotDir + case errors.Is(semanticErr, ErrExists): + status = createResultExists + default: + return CreateResult{}, semanticErr + } + result, err := json.Marshal(createOperationResult{Version: createOperationVersion, Error: status}) + if err != nil { + return CreateResult{}, errors.Wrap(err, "encode create rejection") + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) + if err != nil { + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + return CreateResult{}, err + } + return resolveCreateAttempt(ctx, journal, id, attempt) + } + return decodeCreateOperationResult(record) +} diff --git a/internal/filesystem/operation_create_test.go b/internal/filesystem/operation_create_test.go new file mode 100644 index 000000000..51a47067f --- /dev/null +++ b/internal/filesystem/operation_create_test.go @@ -0,0 +1,83 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/stretchr/testify/require" +) + +func TestCreateWithOperationRetainsOriginalInodeAndHandle(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + opts := CreateOptions{Mode: testFileMode, ClientID: []byte("client")} + original, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), opts) + require.NoError(t, err) + require.NotZero(t, original.FH) + require.NoError(t, svc.Unlink(ctx, RootInode, []byte("file"))) + replacement, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + replayed, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), opts) + require.NoError(t, err) + require.Equal(t, original, replayed) + require.NotEqual(t, replacement.Inode, replayed.Inode) + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("other"), opts) + require.ErrorIs(t, err, ErrOperationIDReused) +} + +func TestMkdirWithOperationDoesNotCreateReplacement(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + original, err := svc.MkdirWithOperation(ctx, id, RootInode, []byte("dir"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + require.NoError(t, svc.Rmdir(ctx, RootInode, []byte("dir"))) + replayed, err := svc.MkdirWithOperation(ctx, id, RootInode, []byte("dir"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + require.Equal(t, original, replayed) + _, err = svc.Resolve(ctx, RootInode, []byte("dir")) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestCreateWithOperationRetainsExistingNameRejection(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + original, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + for range 2 { + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.ErrorIs(t, err, ErrExists) + } + require.NoError(t, svc.Unlink(ctx, RootInode, []byte("file"))) + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.ErrorIs(t, err, ErrExists) + _, err = svc.Resolve(ctx, RootInode, []byte("file")) + require.ErrorIs(t, err, ErrNotFound) + require.NotZero(t, original.Inode) +} + +func TestCreateWithOperationResolvesLostCommitReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, + key: fskeys.DirEntryKey(RootInode, []byte("file")), loseAck: true} + result, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode, ClientID: []byte("mount")}) + require.NoError(t, err) + require.NotZero(t, result.FH) + replayed, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode, ClientID: []byte("mount")}) + require.NoError(t, err) + require.Equal(t, result, replayed) + resolved, err := svc.Resolve(ctx, RootInode, []byte("file")) + require.NoError(t, err) + require.Equal(t, result.Inode, resolved) +} diff --git a/internal/filesystem/operation_lease.go b/internal/filesystem/operation_lease.go new file mode 100644 index 000000000..c1025b02d --- /dev/null +++ b/internal/filesystem/operation_lease.go @@ -0,0 +1,158 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/cockroachdb/errors" +) + +const ( + leaseOperationVersion = 1 + leaseOperationResultLimit = 1 + leaseOperationEffectLimit = 256 + leaseResultSuccess = 0 + leaseResultMissing = 1 +) + +type leaseOperationIntent struct { + Version uint8 `json:"version"` + Inode uint64 `json:"inode"` + FH uint64 `json:"fh"` + ClientID []byte `json:"client_id"` +} + +// RefreshOpenHandleLeaseWithOperation cannot recreate a released or reaped lease. +func (s *Service) RefreshOpenHandleLeaseWithOperation(ctx context.Context, id OperationID, inode, fh uint64, clientID []byte) error { + clientID = bytes.Clone(clientID) + intent, err := json.Marshal(leaseOperationIntent{Version: leaseOperationVersion, Inode: inode, FH: fh, ClientID: clientID}) + if err != nil { + return errors.Wrap(err, "encode lease intent") + } + journal, attempt, terminal, err := s.admitHandleOperation(ctx, id, intent, leaseOperationResultLimit, leaseOperationEffectLimit) + if err != nil { + return err + } + if terminal { + return resolveLeaseOperation(ctx, journal, id) + } + return s.executeLeaseOperation(ctx, journal, id, attempt, inode, fh, clientID, intent) +} + +func (s *Service) executeLeaseOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode, fh uint64, clientID, intent []byte) error { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + err := s.publishLeaseOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, fh, clientID, intent) + if !isDefinitiveWriteConflict(err) { + return err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return err + } + if retry == writeConflictRetryLimit-1 { + return errors.WithStack(ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishLeaseOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, fh uint64, clientID, intent []byte) error { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return err + } + plan, semanticErr, err := s.planLeaseOperation(ctx, snapshot, inode, fh, clientID, intent) + if err != nil { + return err + } + if semanticErr != nil { + return rejectLeaseOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeLeaseOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveLeaseAttempt(ctx, journal, id, attempt) + } + return err +} + +func (s *Service) planLeaseOperation(ctx context.Context, snapshot operationPlanningSnapshot, inode, fh uint64, clientID, intent []byte) (operationAttemptPlan, error, error) { + refKey := fskeys.RefKey(inode, clientID, fh) + plan := operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(inode), refKey}} + if _, err := s.inodeAt(ctx, inode, snapshot.ReadTS); errors.Is(err, ErrNotFound) { + return plan, ErrNotFound, nil + } else if err != nil { + return operationAttemptPlan{}, nil, err + } + if _, err := s.store.GetAt(ctx, refKey, snapshot.ReadTS); errors.Is(err, store.ErrKeyNotFound) { + return plan, ErrNotFound, nil + } else if err != nil { + return operationAttemptPlan{}, nil, errors.Wrap(err, "read open handle lease") + } + elem, err := s.refPutElem(inode, clientID, fh) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.Effects = []*kv.Elem[kv.OP]{elem} + plan.Result = []byte{leaseResultSuccess} + return plan, nil, nil +} + +func decodeLeaseOperationResult(record operationRecord) error { + if len(record.Result) != 1 { + return errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationCommitted && record.Result[0] == leaseResultSuccess { + return nil + } + if record.State == OperationRejected && record.Result[0] == leaseResultMissing { + return errors.WithStack(ErrNotFound) + } + return errors.WithStack(ErrOperationRecordCorrupt) +} + +func resolveLeaseOperation(ctx context.Context, journal *OperationJournal, id OperationID) error { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return err + } + return decodeLeaseOperationResult(record) +} + +func resolveLeaseAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) error { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeLeaseOperationResult(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectLeaseOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) error { + if !errors.Is(semanticErr, ErrNotFound) { + return semanticErr + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, + []byte{leaseResultMissing}, plan.ReadKeys) + if err != nil { + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + return err + } + return resolveLeaseAttempt(ctx, journal, id, attempt) + } + return decodeLeaseOperationResult(record) +} diff --git a/internal/filesystem/operation_lease_test.go b/internal/filesystem/operation_lease_test.go new file mode 100644 index 000000000..f83d01f78 --- /dev/null +++ b/internal/filesystem/operation_lease_test.go @@ -0,0 +1,33 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/stretchr/testify/require" +) + +func TestLeaseOperationCannotResurrectReleasedHandle(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + firstID := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, firstID, file.Inode, fh, clientID)) + require.NoError(t, svc.Release(ctx, file.Inode, fh, clientID)) + require.NoError(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, firstID, file.Inode, fh, clientID)) + missingID := firstID + missingID.Sequence = 2 + require.ErrorIs(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, missingID, file.Inode, fh, clientID), ErrNotFound) + require.ErrorIs(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, missingID, file.Inode, fh, clientID), ErrNotFound) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + _, exists, err := svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, fh), ts) + require.NoError(t, err) + require.False(t, exists) +} diff --git a/internal/filesystem/operation_open.go b/internal/filesystem/operation_open.go new file mode 100644 index 000000000..416b7553a --- /dev/null +++ b/internal/filesystem/operation_open.go @@ -0,0 +1,203 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/cockroachdb/errors" +) + +const ( + openOperationVersion = 1 + openOperationResultLimit = 128 + openOperationEffectLimit = 256 + openResultMissing = 1 +) + +type openOperationIntent struct { + Version uint8 `json:"version"` + Inode uint64 `json:"inode"` + ClientID []byte `json:"client_id"` +} + +type openOperationResult struct { + Version uint8 `json:"version"` + FH uint64 `json:"fh"` + Error uint8 `json:"error"` +} + +// OpenWithOperation retains the original handle and its lease publication. +func (s *Service) OpenWithOperation(ctx context.Context, id OperationID, inode uint64, clientID []byte) (uint64, error) { + clientID = bytes.Clone(clientID) + intent, err := json.Marshal(openOperationIntent{Version: openOperationVersion, Inode: inode, ClientID: clientID}) + if err != nil { + return 0, errors.Wrap(err, "encode open intent") + } + journal, err := NewOperationJournal(s) + if err != nil { + return 0, err + } + admitted, err := journal.Admit(ctx, id, intent, openOperationResultLimit, openOperationEffectLimit) + if err != nil { + return 0, err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return resolveOpenOperation(ctx, journal, id) + } + attempt, err := journal.BeginAttempt(ctx, id) + if err != nil { + return 0, err + } + return s.executeOpenOperation(ctx, journal, id, attempt, inode, clientID, intent) +} + +func (s *Service) executeOpenOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode uint64, clientID, intent []byte) (uint64, error) { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + fh, err := s.publishOpenOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, clientID, intent) + if !isDefinitiveWriteConflict(err) { + return fh, err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return 0, err + } + if retry == writeConflictRetryLimit-1 { + return 0, errors.WithStack(ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return 0, err + } + } + return 0, errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishOpenOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode uint64, clientID, intent []byte) (uint64, error) { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return 0, err + } + plan, semanticErr, err := s.planOpenOperation(ctx, snapshot, inode, clientID, intent) + if err != nil { + return 0, err + } + if semanticErr != nil { + return rejectOpenOperation(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeOpenOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveOpenAttempt(ctx, journal, id, attempt) + } + return 0, err +} + +func (s *Service) planOpenOperation(ctx context.Context, snapshot operationPlanningSnapshot, inode uint64, clientID, intent []byte) (operationAttemptPlan, error, error) { + ts := snapshot.ReadTS + plan := operationAttemptPlan{Intent: intent, ReadTS: ts, RouteVersion: snapshot.RouteVersion, + ReadKeys: [][]byte{fskeys.InodeKey(inode)}} + meta, err := s.inodeAt(ctx, inode, ts) + if errors.Is(err, ErrNotFound) { + return plan, ErrNotFound, nil + } + if err != nil { + return operationAttemptPlan{}, nil, err + } + if meta.Orphaned || meta.Nlink == 0 { + return plan, ErrNotFound, nil + } + fh, err := s.allocID() + if err != nil { + return operationAttemptPlan{}, nil, err + } + refElem, fenceElem, refKey, err := s.planOpenLease(ctx, inode, clientID, fh, ts) + if err != nil { + return operationAttemptPlan{}, nil, err + } + plan.ReadKeys = append(plan.ReadKeys, refKey, fskeys.RefFenceKey(inode)) + plan.Effects = []*kv.Elem[kv.OP]{refElem, fenceElem} + plan.Result, err = json.Marshal(openOperationResult{Version: openOperationVersion, FH: fh}) + return plan, nil, errors.Wrap(err, "encode open result") +} + +func (s *Service) planOpenLease(ctx context.Context, inode uint64, clientID []byte, fh, ts uint64) (*kv.Elem[kv.OP], *kv.Elem[kv.OP], []byte, error) { + if fh == 0 { + return nil, nil, nil, errors.WithStack(store.ErrWriteConflict) + } + refKey := fskeys.RefKey(inode, clientID, fh) + if _, err := s.store.GetAt(ctx, refKey, ts); err == nil { + return nil, nil, nil, errors.WithStack(store.ErrWriteConflict) + } else if !errors.Is(err, store.ErrKeyNotFound) { + return nil, nil, nil, errors.Wrap(err, "read open handle lease") + } + refElem, err := s.refPutElem(inode, clientID, fh) + if err != nil { + return nil, nil, nil, err + } + fenceElem, err := s.refFenceElem(inode) + if err != nil { + return nil, nil, nil, err + } + return refElem, fenceElem, refKey, nil +} + +func decodeOpenOperationResult(record operationRecord) (uint64, error) { + var result openOperationResult + if len(record.Result) == 0 || len(record.Result) > openOperationResultLimit || json.Unmarshal(record.Result, &result) != nil || result.Version != openOperationVersion { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State == OperationRejected { + if result.Error == openResultMissing { + return 0, errors.WithStack(ErrNotFound) + } + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + if record.State != OperationCommitted || result.Error != 0 || result.FH == 0 { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return result.FH, nil +} + +func resolveOpenOperation(ctx context.Context, journal *OperationJournal, id OperationID) (uint64, error) { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return 0, err + } + return decodeOpenOperationResult(record) +} + +func resolveOpenAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) (uint64, error) { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decodeOpenOperationResult(record) + } + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func rejectOpenOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, semanticErr error) (uint64, error) { + if !errors.Is(semanticErr, ErrNotFound) { + return 0, semanticErr + } + result, err := json.Marshal(openOperationResult{Version: openOperationVersion, Error: openResultMissing}) + if err != nil { + return 0, errors.Wrap(err, "encode open rejection") + } + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) + if err != nil { + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + return 0, err + } + return resolveOpenAttempt(ctx, journal, id, attempt) + } + return decodeOpenOperationResult(record) +} diff --git a/internal/filesystem/operation_open_test.go b/internal/filesystem/operation_open_test.go new file mode 100644 index 000000000..ca4677e4d --- /dev/null +++ b/internal/filesystem/operation_open_test.go @@ -0,0 +1,64 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/stretchr/testify/require" +) + +func TestOpenWithOperationRetainsOneHandle(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + clientID := []byte("mount") + fh, err := svc.OpenWithOperation(ctx, id, file.Inode, clientID) + require.NoError(t, err) + otherFH, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + replayed, err := svc.OpenWithOperation(ctx, id, file.Inode, clientID) + require.NoError(t, err) + require.Equal(t, fh, replayed) + require.NotEqual(t, fh, otherFH) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + _, exists, err := svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, fh), ts) + require.NoError(t, err) + require.True(t, exists) + _, err = svc.OpenWithOperation(ctx, id, file.Inode, []byte("other")) + require.ErrorIs(t, err, ErrOperationIDReused) +} + +func TestOpenWithOperationRetainsMissingInode(t *testing.T) { + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(t.Context(), testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + for range 2 { + _, err := svc.OpenWithOperation(t.Context(), id, 999, []byte("mount")) + require.ErrorIs(t, err, ErrNotFound) + } +} + +func TestOpenWithOperationResolvesLostCommitReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, + key: fskeys.RefKey(file.Inode, []byte("mount"), 3), loseAck: true} + fh, err := svc.OpenWithOperation(ctx, id, file.Inode, []byte("mount")) + require.NoError(t, err) + require.EqualValues(t, 3, fh) + replayed, err := svc.OpenWithOperation(ctx, id, file.Inode, []byte("mount")) + require.NoError(t, err) + require.Equal(t, fh, replayed) +} diff --git a/internal/filesystem/operation_receipt_test.go b/internal/filesystem/operation_receipt_test.go index fed3eccaa..b5710aab7 100644 --- a/internal/filesystem/operation_receipt_test.go +++ b/internal/filesystem/operation_receipt_test.go @@ -35,3 +35,102 @@ func TestVerifyOperationReceiptAfterPebbleReopen(t *testing.T) { require.NoError(t, err) require.Equal(t, []byte("durable"), data) } + +func TestNamespaceAndMetadataReceiptsAfterPebbleReopen(t *testing.T) { + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "pebble") + st, err := store.NewPebbleStore(dir) + require.NoError(t, err) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("source"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + removed, err := svc.Create(ctx, RootInode, []byte("removed"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + _, err = svc.Mkdir(ctx, RootInode, []byte("empty"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + + ids := make([]OperationID, 5) + firstID := testWriteOperationID(t, svc, 1) + for i := range ids { + ids[i] = firstID + ids[i].Sequence = uint64(i + 1) + } + _, err = svc.WriteWithOperation(ctx, ids[0], file.Inode, 0, []byte("durable")) + require.NoError(t, err) + _, err = svc.SetAttrWithOperation(ctx, ids[1], file.Inode, SetAttrMask{Size: true}, SetAttr{Size: 3}) + require.NoError(t, err) + require.NoError(t, svc.RenameWithOperation(ctx, ids[2], RootInode, []byte("source"), RootInode, []byte("target"))) + require.NoError(t, svc.UnlinkWithOperation(ctx, ids[3], RootInode, []byte("removed"))) + require.NoError(t, svc.RmdirWithOperation(ctx, ids[4], RootInode, []byte("empty"))) + for _, id := range ids { + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + } + require.NoError(t, st.Close()) + + reopened, err := store.NewPebbleStore(dir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + restarted, err := NewService(reopened, &testCoordinator{st: reopened}) + require.NoError(t, err) + for _, id := range ids { + require.NoError(t, restarted.VerifyOperationReceipt(ctx, id)) + } + inode, err := restarted.Resolve(ctx, RootInode, []byte("target")) + require.NoError(t, err) + require.Equal(t, file.Inode, inode) + data, err := restarted.Read(ctx, inode, 0, 0, 3) + require.NoError(t, err) + require.Equal(t, []byte("dur"), data) + _, err = restarted.Resolve(ctx, RootInode, []byte("source")) + require.ErrorIs(t, err, ErrNotFound) + _, err = restarted.Resolve(ctx, RootInode, []byte("removed")) + require.ErrorIs(t, err, ErrNotFound) + _, err = restarted.Resolve(ctx, RootInode, []byte("empty")) + require.ErrorIs(t, err, ErrNotFound) + require.NotEqual(t, file.Inode, removed.Inode) +} + +func TestCreateReceiptsAfterPebbleReopen(t *testing.T) { + ctx := context.Background() + dir := filepath.Join(t.TempDir(), "pebble") + st, err := store.NewPebbleStore(dir) + require.NoError(t, err) + svc, err := NewService(st, &testCoordinator{st: st}) + require.NoError(t, err) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + fileID := testWriteOperationID(t, svc, 1) + dirID := fileID + dirID.Sequence = 2 + openID := fileID + openID.Sequence = 3 + file, err := svc.CreateWithOperation(ctx, fileID, RootInode, []byte("file"), CreateOptions{Mode: testFileMode, ClientID: []byte("mount")}) + require.NoError(t, err) + createdDir, err := svc.MkdirWithOperation(ctx, dirID, RootInode, []byte("dir"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + fh, err := svc.OpenWithOperation(ctx, openID, file.Inode, []byte("mount")) + require.NoError(t, err) + require.NoError(t, svc.VerifyOperationReceipt(ctx, fileID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, dirID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, openID)) + require.NoError(t, st.Close()) + + reopened, err := store.NewPebbleStore(dir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reopened.Close()) }) + restarted, err := NewService(reopened, &testCoordinator{st: reopened}) + require.NoError(t, err) + require.NoError(t, restarted.VerifyOperationReceipt(ctx, fileID)) + require.NoError(t, restarted.VerifyOperationReceipt(ctx, dirID)) + require.NoError(t, restarted.VerifyOperationReceipt(ctx, openID)) + replayed, err := restarted.CreateWithOperation(ctx, fileID, RootInode, []byte("file"), CreateOptions{Mode: testFileMode, ClientID: []byte("mount")}) + require.NoError(t, err) + require.Equal(t, file, replayed) + replayedDir, err := restarted.MkdirWithOperation(ctx, dirID, RootInode, []byte("dir"), CreateOptions{Mode: testRootMode}) + require.NoError(t, err) + require.Equal(t, createdDir, replayedDir) + replayedFH, err := restarted.OpenWithOperation(ctx, openID, file.Inode, []byte("mount")) + require.NoError(t, err) + require.Equal(t, fh, replayedFH) +} diff --git a/internal/filesystem/operation_release.go b/internal/filesystem/operation_release.go new file mode 100644 index 000000000..0e4071dfc --- /dev/null +++ b/internal/filesystem/operation_release.go @@ -0,0 +1,140 @@ +package filesystem + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const ( + releaseOperationVersion = 1 + releaseOperationResultLimit = 1 + releaseOperationEffectLimit = 64 << 10 +) + +type releaseOperationIntent struct { + Version uint8 `json:"version"` + Inode uint64 `json:"inode"` + FH uint64 `json:"fh"` + ClientID []byte `json:"client_id"` +} + +// ReleaseWithOperation makes a handle release terminal under one operation ID. +func (s *Service) ReleaseWithOperation(ctx context.Context, id OperationID, inode, fh uint64, clientID []byte) error { + clientID = bytes.Clone(clientID) + intent, err := json.Marshal(releaseOperationIntent{Version: releaseOperationVersion, Inode: inode, FH: fh, ClientID: clientID}) + if err != nil { + return errors.Wrap(err, "encode release intent") + } + journal, attempt, terminal, err := s.admitHandleOperation(ctx, id, intent, releaseOperationResultLimit, releaseOperationEffectLimit) + if err != nil { + return err + } + if terminal { + return resolveReleaseOperation(ctx, journal, id) + } + return s.executeReleaseOperation(ctx, journal, id, attempt, inode, fh, clientID, intent) +} + +func (s *Service) admitHandleOperation(ctx context.Context, id OperationID, intent []byte, resultLimit, effectLimit uint32) (*OperationJournal, operationRecord, bool, error) { + journal, err := NewOperationJournal(s) + if err != nil { + return nil, operationRecord{}, false, err + } + admitted, err := journal.Admit(ctx, id, intent, resultLimit, effectLimit) + if err != nil { + return nil, operationRecord{}, false, err + } + if admitted.State != OperationPending || admitted.AttemptID != [16]byte{} { + return journal, operationRecord{}, true, nil + } + attempt, err := journal.BeginAttempt(ctx, id) + return journal, attempt, false, err +} + +func (s *Service) executeReleaseOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, inode, fh uint64, clientID, intent []byte) error { + deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) + defer cancel() + for retry := range writeConflictRetryLimit { + err := s.publishReleaseOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, fh, clientID, intent) + if !isDefinitiveWriteConflict(err) { + return err + } + attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) + if err != nil { + return err + } + if retry == writeConflictRetryLimit-1 { + return errors.WithStack(ErrContentionLimit) + } + if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { + return err + } + } + return errors.WithStack(ErrContentionLimit) +} + +func (s *Service) publishReleaseOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, fh uint64, clientID, intent []byte) error { + snapshot, err := journal.PlanSnapshotAtRoute(ctx, id, attempt) + if err != nil { + return err + } + plan, cleanup, err := s.planReleaseOperation(ctx, snapshot, inode, fh, clientID, intent) + if err != nil { + return err + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + err = decodeReleaseOperationResult(record) + } else if !isDefinitiveWriteConflict(err) { + err = resolveReleaseAttempt(ctx, journal, id, attempt) + } + if err == nil { + s.reclaimCommittedOrphanBestEffort(ctx, cleanup) + } + return err +} + +func (s *Service) planReleaseOperation(ctx context.Context, snapshot operationPlanningSnapshot, inode, fh uint64, clientID, intent []byte) (operationAttemptPlan, *chunkDeletePlan, error) { + refKey := fskeys.RefKey(inode, clientID, fh) + elems, reads, delta, cleanup, _, err := s.releaseRefTxnParts(ctx, snapshot.ReadTS, inode, refKey) + if err != nil { + return operationAttemptPlan{}, nil, err + } + elems, reads, err = s.appendUsageUpdate(ctx, snapshot.ReadTS, elems, reads, delta) + if err != nil { + return operationAttemptPlan{}, nil, err + } + return operationAttemptPlan{Intent: intent, ReadTS: snapshot.ReadTS, RouteVersion: snapshot.RouteVersion, + ReadKeys: reads, Effects: elems, Result: []byte{releaseOperationVersion}}, cleanup, nil +} + +func decodeReleaseOperationResult(record operationRecord) error { + if record.State != OperationCommitted || len(record.Result) != 1 || record.Result[0] != releaseOperationVersion { + return errors.WithStack(ErrOperationRecordCorrupt) + } + return nil +} + +func resolveReleaseOperation(ctx context.Context, journal *OperationJournal, id OperationID) error { + record, err := journal.Resolve(ctx, id) + if errors.Is(err, ErrOperationPending) { + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) + } + if err != nil { + return err + } + return decodeReleaseOperationResult(record) +} + +func resolveReleaseAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte) error { + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && outcome == AttemptCommitted { + return decodeReleaseOperationResult(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} diff --git a/internal/filesystem/operation_release_test.go b/internal/filesystem/operation_release_test.go new file mode 100644 index 000000000..3a35d21c7 --- /dev/null +++ b/internal/filesystem/operation_release_test.go @@ -0,0 +1,55 @@ +package filesystem + +import ( + "context" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/stretchr/testify/require" +) + +func TestReleaseWithOperationDoesNotRemoveNewHandle(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + require.NoError(t, svc.ReleaseWithOperation(ctx, id, file.Inode, fh, clientID)) + otherFH, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + require.NoError(t, svc.ReleaseWithOperation(ctx, id, file.Inode, fh, clientID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + _, exists, err := svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, otherFH), ts) + require.NoError(t, err) + require.True(t, exists) + _, exists, err = svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, fh), ts) + require.NoError(t, err) + require.False(t, exists) + // A delayed lease refresh cannot revive the terminal release. + require.ErrorIs(t, svc.RefreshOpenHandleLease(ctx, file.Inode, fh, clientID), ErrNotFound) +} + +func TestReleaseWithOperationResolvesLostCommitReply(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, + key: fskeys.RefKey(file.Inode, clientID, fh), loseAck: true} + require.NoError(t, svc.ReleaseWithOperation(ctx, id, file.Inode, fh, clientID)) + require.NoError(t, svc.ReleaseWithOperation(ctx, id, file.Inode, fh, clientID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) +} From 8f8e98d114ed5098bfde8b916874d130e02d9f3f Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 16:16:43 +0900 Subject: [PATCH 23/35] filesystem: order journaled lease renewals with fsync Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 11 ++-- internal/filesystem/fuseadapter/adapter.go | 64 ++++++++++++++++--- .../fuseadapter/adapter_ops_test.go | 54 ++++++++++++++++ internal/filesystem/fuseadapter/barrier.go | 9 +++ .../filesystem/fuseadapter/mount_e2e_test.go | 25 ++++++++ 5 files changed, 148 insertions(+), 15 deletions(-) diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index e7ec35ac7..40c96096a 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -443,18 +443,17 @@ filesystem service operations within bounds, tracks local FUSE predecessors, and tests mounted SQLite PERSIST/DELETE workloads. This branch adds a transaction-primary generation fence and optional journal-backed paths for fixed-offset FUSE writes, setattr including size changes, -create/mknod, mkdir, open, release, file unlink, empty-directory removal, and -same-directory rename. +create/mknod, mkdir, open, release, lease renewal, file unlink, +empty-directory removal, and same-directory rename. The adapter retains their successful operation IDs per inode. Its flush and fsync paths resolve those IDs and check participant proofs. Directory fsync checks create, mkdir, removal and rename receipts recorded for that parent. File fsync also checks create, open and release receipts for its inode when those optional paths are selected. The default mount path still uses the legacy mutations. -Lease maintenance still uses the legacy path; a typed backend planner exists -but is not yet connected to the adapter's foreground and background renewal -paths. Its predecessor ordering and journal retention need completion before -it can be selected for strict mode. +The optional lease renewal path serializes foreground and background refreshes +with other inode mutations and blocks later fsync after an unknown renewal. +Its retained records still need safe acknowledgement and garbage collection. `Service.Fsync` still uses an attribute read after the adapter checks the receipts for its supported journaled operations. The optional receipt tracker is local to one mount and retains only successful diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 8196db0b7..bedcc2b05 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -57,6 +57,7 @@ type Adapter struct { journaledMkdir bool journaledOpen bool journaledRelease bool + journaledLease bool operationSequence atomic.Uint64 receiptMu sync.Mutex receipts map[uint64]map[filesystem.OperationID]struct{} @@ -192,6 +193,14 @@ func WithJournaledRelease(session filesystem.OperationSessionID) Option { } } +// WithJournaledLeaseRefresh journals foreground and keepalive lease renewals. +func WithJournaledLeaseRefresh(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.journaledLease = true + } +} + func (a *Adapter) setOperationSession(session filesystem.OperationSessionID) { if a.operationSession != (filesystem.OperationSessionID{}) && a.operationSession != session { a.sessionConflict = true @@ -321,7 +330,7 @@ func (a *Adapter) Open(ctx context.Context, inode uint64) (uint64, syscall.Errno } func (a *Adapter) Read(ctx context.Context, inode uint64, fh uint64, offset uint64, size uint64) ([]byte, syscall.Errno) { - if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { + if errno := a.refreshOpenHandleLease(ctx, inode, fh, false); errno != 0 { return nil, errno } data, err := a.core.Read(ctx, inode, fh, offset, size) @@ -335,7 +344,7 @@ func (a *Adapter) Write(ctx context.Context, inode uint64, fh uint64, offset uin } var writeErr error defer func() { a.mutations.end(ticket, errors.Is(writeErr, kv.ErrTransactionOutcomeUnknown)) }() - if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { + if errno := a.refreshOpenHandleLease(ctx, inode, fh, true); errno != 0 { return 0, errno } if len(data) == 0 { @@ -366,7 +375,7 @@ func (a *Adapter) Flush(ctx context.Context, inode uint64, fh uint64) syscall.Er if errno := a.mutations.wait(ctx, inode); errno != 0 { return errno } - if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { + if errno := a.refreshOpenHandleLease(ctx, inode, fh, false); errno != 0 { return errno } if _, errno := a.verifyReceipts(ctx, inode); errno != 0 { @@ -379,7 +388,7 @@ func (a *Adapter) Fsync(ctx context.Context, inode uint64, fh uint64, datasync b if errno := a.mutations.wait(ctx, inode); errno != 0 { return errno } - if errno := a.refreshOpenHandleLease(ctx, inode, fh); errno != 0 { + if errno := a.refreshOpenHandleLease(ctx, inode, fh, false); errno != 0 { return errno } receipts, errno := a.verifyReceipts(ctx, inode) @@ -625,7 +634,44 @@ func (a *Adapter) StatFS(ctx context.Context, inode uint64) (filesystem.StatFS, return stat, Errno(err) } -func (a *Adapter) refreshOpenHandleLease(ctx context.Context, inode uint64, fh uint64) syscall.Errno { +func (a *Adapter) refreshOpenHandleLease(ctx context.Context, inode uint64, fh uint64, alreadyTracked bool) syscall.Errno { + if !a.journaledLease { + return a.refreshLegacyLease(ctx, inode, fh) + } + var err error + if !alreadyTracked { + ticket, errno := a.mutations.begin(ctx, inode) + if errno != 0 { + return errno + } + defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() + } + err = a.refreshJournaledLease(ctx, inode, fh) + if alreadyTracked && errors.Is(err, kv.ErrTransactionOutcomeUnknown) { + a.mutations.markUnknown(inode) + } + if err != nil && Errno(err) == syscall.EIO { + slog.WarnContext(ctx, "filesystem FUSE lease refresh failed", "inode", inode, "fh", fh, "error", err.Error()) + } + return Errno(err) +} + +func (a *Adapter) refreshJournaledLease(ctx context.Context, inode uint64, fh uint64) error { + refresher, ok := a.core.(interface { + RefreshOpenHandleLeaseWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) error + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return filesystem.ErrUnsupported + } + id := a.nextOperationID() + err := refresher.RefreshOpenHandleLeaseWithOperation(ctx, id, inode, fh, a.clientID) + if err == nil { + a.recordReceipt(inode, id) + } + return errors.Wrap(err, "refresh journaled open handle lease") +} + +func (a *Adapter) refreshLegacyLease(ctx context.Context, inode uint64, fh uint64) syscall.Errno { err := a.core.RefreshOpenHandleLease(ctx, inode, fh, a.clientID) if err != nil && Errno(err) == syscall.EIO { slog.WarnContext(ctx, "filesystem FUSE lease refresh failed", "inode", inode, "fh", fh, "error", err.Error()) @@ -683,11 +729,11 @@ func (a *Adapter) keepOpenHandlesAlive(ctx context.Context) { func (a *Adapter) refreshTrackedOpenHandles(ctx context.Context) { for _, handle := range a.openHandleSnapshot() { handleCtx, cancel := context.WithTimeout(ctx, a.keepaliveTimeout) - err := a.core.RefreshOpenHandleLease(handleCtx, handle.inode, handle.fh, a.clientID) + errno := a.refreshOpenHandleLease(handleCtx, handle.inode, handle.fh, false) cancel() switch { - case err == nil: - case errors.Is(err, filesystem.ErrNotFound): + case errno == 0: + case errno == syscall.ENOENT: a.untrackOpenHandle(handle.inode, handle.fh) case ctx.Err() != nil: return @@ -695,7 +741,7 @@ func (a *Adapter) refreshTrackedOpenHandles(ctx context.Context) { slog.WarnContext(ctx, "filesystem open handle lease refresh failed", "inode", handle.inode, "fh", handle.fh, - "err", err, + "errno", errno, ) } } diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index f4eaab1f0..9bb56ca3c 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/bootjp/elastickv/internal/filesystem" + "github.com/bootjp/elastickv/kv" "github.com/stretchr/testify/require" ) @@ -87,6 +88,59 @@ type journalLifecycleCore struct { releaseID filesystem.OperationID } +type journalLeaseCore struct { + *receiptCore + leaseIDs []filesystem.OperationID + leaseErr error +} + +func (c *journalLeaseCore) RefreshOpenHandleLeaseWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ uint64, _ []byte) error { + c.leaseIDs = append(c.leaseIDs, id) + return c.leaseErr +} + +func TestAdapterJournaledLeaseRefreshPrecedesFsync(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Read(t.Context(), 7, 9, 0, 1) + require.Zero(t, errno) + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.ElementsMatch(t, core.leaseIDs, core.verified) + require.Len(t, core.leaseIDs, 2) + require.True(t, core.fsyncCalled) +} + +func TestAdapterUnknownLeaseRefreshStopsLaterFsync(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, leaseErr: kv.ErrTransactionOutcomeUnknown} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Read(t.Context(), 7, 9, 0, 1) + require.Equal(t, syscall.EIO, errno) + require.Equal(t, syscall.EIO, adapter.Fsync(t.Context(), 7, 9, false)) + require.False(t, core.fsyncCalled) +} + +func TestAdapterKeepaliveUsesJournaledLeaseRefresh(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + adapter.trackOpenHandle(7, 9) + adapter.refreshTrackedOpenHandles(t.Context()) + require.Len(t, core.leaseIDs, 1) + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.ElementsMatch(t, core.leaseIDs, core.verified) +} + func (c *journalLifecycleCore) CreateWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte, _ filesystem.CreateOptions) (filesystem.CreateResult, error) { c.createID = id return filesystem.CreateResult{Inode: 7, FH: 9, Stat: filesystem.Stat{Inode: 7}}, nil diff --git a/internal/filesystem/fuseadapter/barrier.go b/internal/filesystem/fuseadapter/barrier.go index f5465c92b..a86284aab 100644 --- a/internal/filesystem/fuseadapter/barrier.go +++ b/internal/filesystem/fuseadapter/barrier.go @@ -111,6 +111,15 @@ func (m *mutationTracker) end(ticket *mutationTicket, unknown bool) { close(ticket.done) } +func (m *mutationTracker) markUnknown(key uint64) { + m.mu.Lock() + defer m.mu.Unlock() + if m.sticky == nil { + m.sticky = make(map[uint64]syscall.Errno) + } + m.sticky[key] = syscall.EIO +} + func (m *mutationTracker) wait(ctx context.Context, key uint64) syscall.Errno { waitCtx, cancel := context.WithTimeout(ctx, mutationQueueWaitBudget) defer cancel() diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index 4563afef2..e8851d2e3 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -457,6 +457,31 @@ func TestKernelMountJournaledCreateAndMkdir(t *testing.T) { } } +func TestKernelMountJournaledLeaseRefreshAndFsync(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-journaled-lease") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-journaled-lease", mountPoint, + fuseadapter.WithJournaledLeaseRefresh(session)) + file, err := os.Create(filepath.Join(mountPoint, "file")) + require.NoError(t, err) + require.NoError(t, file.Sync()) + require.NoError(t, file.Close()) + record, err := journal.Resolve(t.Context(), filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, + }) + require.NoError(t, err) + require.Equal(t, filesystem.OperationCommitted, record.State) +} + func TestKernelMountJournaledMetadataChange(t *testing.T) { if _, err := os.Stat("/dev/fuse"); err != nil { skipOrFail(t, "no /dev/fuse: %v", err) From f572339bf66c9b285a78043faf62f1eea3904382 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 16:22:36 +0900 Subject: [PATCH 24/35] filesystem: order journaled open before fsync Signed-off-by: bootjp --- internal/filesystem/fuseadapter/adapter.go | 43 +++++++++++++------ .../fuseadapter/adapter_ops_test.go | 16 ++++++- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index bedcc2b05..a0a26aa28 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -304,23 +304,14 @@ func (a *Adapter) nextOperationID() filesystem.OperationID { } func (a *Adapter) Open(ctx context.Context, inode uint64) (uint64, syscall.Errno) { - var fh uint64 - var err error if a.journaledOpen { - opener, ok := a.core.(interface { - OpenWithOperation(context.Context, filesystem.OperationID, uint64, []byte) (uint64, error) - }) - if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { - return 0, syscall.EOPNOTSUPP - } - id := a.nextOperationID() - fh, err = opener.OpenWithOperation(ctx, id, inode, a.clientID) - if err == nil { - a.recordReceipt(inode, id) + fh, errno := a.openWithOperation(ctx, inode) + if errno == 0 { + a.trackOpenHandle(inode, fh) } - } else { - fh, err = a.core.Open(ctx, inode, a.clientID) + return fh, errno } + fh, err := a.core.Open(ctx, inode, a.clientID) if err == nil { a.trackOpenHandle(inode, fh) } else if Errno(err) == syscall.EIO { @@ -329,6 +320,30 @@ func (a *Adapter) Open(ctx context.Context, inode uint64) (uint64, syscall.Errno return fh, Errno(err) } +func (a *Adapter) openWithOperation(ctx context.Context, inode uint64) (uint64, syscall.Errno) { + opener, ok := a.core.(interface { + OpenWithOperation(context.Context, filesystem.OperationID, uint64, []byte) (uint64, error) + }) + if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { + return 0, syscall.EOPNOTSUPP + } + ticket, errno := a.mutations.begin(ctx, inode) + if errno != 0 { + return 0, errno + } + var err error + defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() + id := a.nextOperationID() + fh, err := opener.OpenWithOperation(ctx, id, inode, a.clientID) + if err == nil { + a.recordReceipt(inode, id) + } + if err != nil && Errno(err) == syscall.EIO { + slog.WarnContext(ctx, "filesystem FUSE open failed", "inode", inode, "error", err.Error()) + } + return fh, Errno(err) +} + func (a *Adapter) Read(ctx context.Context, inode uint64, fh uint64, offset uint64, size uint64) ([]byte, syscall.Errno) { if errno := a.refreshOpenHandleLease(ctx, inode, fh, false); errno != 0 { return nil, errno diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index 9bb56ca3c..b3dae9d34 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -85,6 +85,7 @@ type journalLifecycleCore struct { *receiptCore createID filesystem.OperationID openID filesystem.OperationID + openErr error releaseID filesystem.OperationID } @@ -148,7 +149,20 @@ func (c *journalLifecycleCore) CreateWithOperation(_ context.Context, id filesys func (c *journalLifecycleCore) OpenWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte) (uint64, error) { c.openID = id - return 10, nil + return 10, c.openErr +} + +func TestAdapterUnknownOpenStopsLaterFsync(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLifecycleCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, openErr: kv.ErrTransactionOutcomeUnknown} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledOpen(session)) + t.Cleanup(adapter.Close) + _, errno := adapter.Open(t.Context(), 7) + require.Equal(t, syscall.EIO, errno) + require.Equal(t, syscall.EIO, adapter.Fsync(t.Context(), 7, 9, false)) + require.False(t, core.fsyncCalled) } func (c *journalLifecycleCore) ReleaseWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ uint64, _ []byte) error { From a90009111261ae10afcb3f28aec0719e1ed2c4d5 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 16:24:57 +0900 Subject: [PATCH 25/35] docs: record FUSE reply acknowledgement gap Signed-off-by: bootjp --- docs/design/2026_10_25_partial_fuse_conflict_semantics.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index 40c96096a..e6b7dbd7c 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -459,6 +459,10 @@ receipts for its supported journaled operations. The optional receipt tracker is local to one mount and retains only successful operations in memory. Session acknowledgement and journal garbage collection are not wired to the kernel's reply boundary. +In the pinned go-fuse version, `Server.handleRequest` writes the kernel reply +after the raw filesystem method returns. The raw adapter has no callback for +that write, so acknowledging when the adapter method returns would retire the +operation before the reply attempt required by the journal contract. The baseline and optional-path tests do not satisfy the strict-mode gates below. From ef81ad2a0cf2c529a97cd27185bb448a28e4fa11 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 16:29:52 +0900 Subject: [PATCH 26/35] filesystem: replan definitive rejection conflicts Signed-off-by: bootjp --- internal/filesystem/operation_create.go | 5 +- internal/filesystem/operation_create_test.go | 16 +++++++ internal/filesystem/operation_lease.go | 5 +- .../filesystem/operation_namespace_reject.go | 26 +++++++++++ internal/filesystem/operation_open.go | 5 +- internal/filesystem/operation_rename.go | 9 +--- internal/filesystem/operation_setattr.go | 5 +- internal/filesystem/operation_unlink.go | 10 +--- internal/filesystem/operation_write.go | 37 +++++++++------ internal/filesystem/operation_write_test.go | 46 +++++++++++++++---- 10 files changed, 122 insertions(+), 42 deletions(-) create mode 100644 internal/filesystem/operation_namespace_reject.go diff --git a/internal/filesystem/operation_create.go b/internal/filesystem/operation_create.go index fd7bd2020..01070abcf 100644 --- a/internal/filesystem/operation_create.go +++ b/internal/filesystem/operation_create.go @@ -253,7 +253,10 @@ func rejectCreateOperation(ctx context.Context, journal *OperationJournal, id Op } record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) if err != nil { - if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + if isDefinitiveWriteConflict(err) { + return CreateResult{}, err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { return CreateResult{}, err } return resolveCreateAttempt(ctx, journal, id, attempt) diff --git a/internal/filesystem/operation_create_test.go b/internal/filesystem/operation_create_test.go index 51a47067f..91fc01f85 100644 --- a/internal/filesystem/operation_create_test.go +++ b/internal/filesystem/operation_create_test.go @@ -81,3 +81,19 @@ func TestCreateWithOperationResolvesLostCommitReply(t *testing.T) { require.NoError(t, err) require.Equal(t, result.Inode, resolved) } + +func TestCreateWithOperationReplansDefinitiveRejectionConflict(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + _, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, rejectConflict: true} + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.ErrorIs(t, err, ErrExists) + _, err = svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.ErrorIs(t, err, ErrExists) +} diff --git a/internal/filesystem/operation_lease.go b/internal/filesystem/operation_lease.go index c1025b02d..014a9619a 100644 --- a/internal/filesystem/operation_lease.go +++ b/internal/filesystem/operation_lease.go @@ -149,7 +149,10 @@ func rejectLeaseOperation(ctx context.Context, journal *OperationJournal, id Ope record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, []byte{leaseResultMissing}, plan.ReadKeys) if err != nil { - if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + if isDefinitiveWriteConflict(err) { + return err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { return err } return resolveLeaseAttempt(ctx, journal, id, attempt) diff --git a/internal/filesystem/operation_namespace_reject.go b/internal/filesystem/operation_namespace_reject.go new file mode 100644 index 000000000..bca8786ee --- /dev/null +++ b/internal/filesystem/operation_namespace_reject.go @@ -0,0 +1,26 @@ +package filesystem + +import ( + "context" + + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +// rejectNamespaceOperation preserves a definitive OCC conflict for the caller's +// generation-fencing retry loop. Only an uncertain decision is resolved. +func rejectNamespaceOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, plan operationAttemptPlan, status byte, decode func(operationRecord) error) error { + record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, + []byte{status}, plan.ReadKeys) + if err == nil { + return decode(record) + } + if isDefinitiveWriteConflict(err) || (!errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration)) { + return err + } + outcome, record, err := journal.ResolveAttempt(ctx, id, attempt) + if err == nil && (outcome == AttemptCommitted || (outcome == AttemptAborted && record.State == OperationRejected)) { + return decode(record) + } + return errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} diff --git a/internal/filesystem/operation_open.go b/internal/filesystem/operation_open.go index 416b7553a..e3f35d781 100644 --- a/internal/filesystem/operation_open.go +++ b/internal/filesystem/operation_open.go @@ -194,7 +194,10 @@ func rejectOpenOperation(ctx context.Context, journal *OperationJournal, id Oper } record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) if err != nil { - if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + if isDefinitiveWriteConflict(err) { + return 0, err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { return 0, err } return resolveOpenAttempt(ctx, journal, id, attempt) diff --git a/internal/filesystem/operation_rename.go b/internal/filesystem/operation_rename.go index 170b4daa2..b10c36f3e 100644 --- a/internal/filesystem/operation_rename.go +++ b/internal/filesystem/operation_rename.go @@ -235,12 +235,5 @@ func rejectRenameOperation(ctx context.Context, journal *OperationJournal, id Op default: return semanticErr } - record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, []byte{status}, plan.ReadKeys) - if err != nil { - if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { - return err - } - return resolveRenameAttempt(ctx, journal, id, attempt) - } - return decodeRenameOperationResult(record) + return rejectNamespaceOperation(ctx, journal, id, attempt, plan, status, decodeRenameOperationResult) } diff --git a/internal/filesystem/operation_setattr.go b/internal/filesystem/operation_setattr.go index 2e4c7836d..a620684f7 100644 --- a/internal/filesystem/operation_setattr.go +++ b/internal/filesystem/operation_setattr.go @@ -295,7 +295,10 @@ func rejectSetAttrOperation(ctx context.Context, journal *OperationJournal, id O } record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, result, plan.ReadKeys) if err != nil { - if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + if isDefinitiveWriteConflict(err) { + return Stat{}, err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { return Stat{}, err } return resolveSetAttrAttempt(ctx, journal, id, attempt) diff --git a/internal/filesystem/operation_unlink.go b/internal/filesystem/operation_unlink.go index 1d52be23b..895444b19 100644 --- a/internal/filesystem/operation_unlink.go +++ b/internal/filesystem/operation_unlink.go @@ -301,13 +301,5 @@ func rejectUnlinkOperation(ctx context.Context, journal *OperationJournal, id Op default: return semanticErr } - record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, - []byte{status}, plan.ReadKeys) - if err != nil { - if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { - return err - } - return resolveUnlinkAttempt(ctx, journal, id, attempt) - } - return decodeUnlinkOperationResult(record) + return rejectNamespaceOperation(ctx, journal, id, attempt, plan, status, decodeUnlinkOperationResult) } diff --git a/internal/filesystem/operation_write.go b/internal/filesystem/operation_write.go index 34ea75bad..5249d3448 100644 --- a/internal/filesystem/operation_write.go +++ b/internal/filesystem/operation_write.go @@ -99,19 +99,9 @@ func (s *Service) executeWriteOperation(ctx context.Context, journal *OperationJ if err := deadlineCtx.Err(); err != nil { return 0, errors.WithStack(err) } - plan, semanticErr, err := s.planJournalWrite(deadlineCtx, journal, id, attempt.AttemptID, inode, offset, end, data, intent) - if err != nil { - return 0, err - } - if semanticErr != nil { - return publishWriteRejection(deadlineCtx, journal, id, attempt.AttemptID, plan, semanticErr) - } - record, err := journal.Publish(deadlineCtx, id, attempt.AttemptID, plan) - if err == nil { - return decodeWriteOperationResult(record) - } + result, err := s.publishWriteOperationAttempt(deadlineCtx, journal, id, attempt.AttemptID, inode, offset, end, data, intent) if !isDefinitiveWriteConflict(err) { - return resolveWriteAttempt(deadlineCtx, journal, id, attempt.AttemptID) + return result, err } attempt, err = journal.AdvanceGeneration(deadlineCtx, id, attempt.AttemptID) if err != nil { @@ -127,6 +117,24 @@ func (s *Service) executeWriteOperation(ctx context.Context, journal *OperationJ return 0, errors.WithStack(ErrContentionLimit) } +func (s *Service) publishWriteOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, offset, end uint64, data, intent []byte) (int, error) { + plan, semanticErr, err := s.planJournalWrite(ctx, journal, id, attempt, inode, offset, end, data, intent) + if err != nil { + return 0, err + } + if semanticErr != nil { + return publishWriteRejection(ctx, journal, id, attempt, plan, semanticErr) + } + record, err := journal.Publish(ctx, id, attempt, plan) + if err == nil { + return decodeWriteOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + return resolveWriteAttempt(ctx, journal, id, attempt) + } + return 0, err +} + func encodeWriteOperationIntent(inode, offset uint64, data []byte) []byte { intent := make([]byte, writeOperationIntentHeader+len(data)) intent[0] = writeOperationIntentVersion @@ -201,7 +209,10 @@ func publishWriteRejection(ctx context.Context, journal *OperationJournal, id Op record, err := journal.RejectAtRoute(ctx, id, attempt, plan.ReadTS, plan.RouteVersion, plan.Intent, encodeWriteOperationResult(0, status), plan.ReadKeys) if err != nil { - if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !IsWriteConflict(err) && !errors.Is(err, ErrOperationGeneration) { + if isDefinitiveWriteConflict(err) { + return 0, err + } + if !errors.Is(err, kv.ErrTransactionOutcomeUnknown) && !errors.Is(err, ErrOperationGeneration) { return 0, err } return resolveWriteAttempt(ctx, journal, id, attempt) diff --git a/internal/filesystem/operation_write_test.go b/internal/filesystem/operation_write_test.go index be21c6853..096df772a 100644 --- a/internal/filesystem/operation_write_test.go +++ b/internal/filesystem/operation_write_test.go @@ -11,17 +11,18 @@ import ( ) type writeOperationFaultDispatcher struct { - inner *testCoordinator - key []byte - once bool - before func(context.Context) error - loseAck bool - loseRejection bool + inner *testCoordinator + key []byte + once bool + before func(context.Context) error + loseAck bool + loseRejection bool + rejectConflict bool } func (d *writeOperationFaultDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { - if !d.once && d.loseRejection && isWriteOperationRejection(req) { - return d.rejectWithLostReply(ctx, req) + if response, err, handled := d.handleRejection(ctx, req); handled { + return response, err } if !d.once && writeOperationTouchesKey(req, d.key) { d.once = true @@ -39,6 +40,21 @@ func (d *writeOperationFaultDispatcher) Dispatch(ctx context.Context, req *kv.Op return d.inner.Dispatch(ctx, req) } +func (d *writeOperationFaultDispatcher) handleRejection(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error, bool) { + if d.once || !isWriteOperationRejection(req) { + return nil, nil, false + } + if d.rejectConflict { + d.once = true + return nil, store.ErrWriteConflict, true + } + if d.loseRejection { + response, err := d.rejectWithLostReply(ctx, req) + return response, err, true + } + return nil, nil, false +} + func (d *writeOperationFaultDispatcher) rejectWithLostReply(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { d.once = true if _, err := d.inner.Dispatch(ctx, req); err != nil { @@ -221,3 +237,17 @@ func TestWriteWithOperationResolvesLostRejectionReply(t *testing.T) { _, err = svc.WriteWithOperation(ctx, id, RootInode, 0, []byte("x")) require.ErrorIs(t, err, ErrIsDir) } + +func TestWriteWithOperationReplansDefinitiveRejectionConflict(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, nil) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + svc.dispatch = &writeOperationFaultDispatcher{inner: inner, rejectConflict: true} + _, err := svc.WriteWithOperation(ctx, id, 999, 0, []byte("x")) + require.ErrorIs(t, err, ErrNotFound) + _, err = svc.WriteWithOperation(ctx, id, 999, 0, []byte("x")) + require.ErrorIs(t, err, ErrNotFound) +} From ffbab236fa6b025cc16e9b647d04249c7e85e140 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 16:32:38 +0900 Subject: [PATCH 27/35] filesystem: wait for asynchronous FUSE release receipt Signed-off-by: bootjp --- internal/filesystem/fuseadapter/mount_e2e_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index e8851d2e3..604b65a86 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -448,6 +448,12 @@ func TestKernelMountJournaledCreateAndMkdir(t *testing.T) { require.NoError(t, err) require.NoError(t, directory.Sync()) require.NoError(t, directory.Close()) + require.Eventually(t, func() bool { + _, err := journal.Resolve(t.Context(), filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 5, + }) + return err == nil + }, 3*time.Second, 10*time.Millisecond) for sequence := uint64(1); sequence <= 5; sequence++ { record, err := journal.Resolve(t.Context(), filesystem.OperationID{ Filesystem: session.Filesystem, Session: session.Session, Sequence: sequence, From 6df1a6a7155ebcea7a13b3b6f0c64edc85bf03b4 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 17:29:39 +0900 Subject: [PATCH 28/35] filesystem: bound lease renewals and cancel failed write plans Signed-off-by: bootjp --- ...6_10_25_partial_fuse_conflict_semantics.md | 8 ++ internal/filesystem/fuseadapter/adapter.go | 68 ++++++++++++++- .../fuseadapter/adapter_ops_test.go | 45 ++++++++++ .../filesystem/fuseadapter/mount_e2e_test.go | 10 ++- internal/filesystem/fuseadapter/receipts.go | 26 ++++++ internal/filesystem/operation_journal_exec.go | 14 +++ internal/filesystem/operation_lease_test.go | 19 +++++ internal/filesystem/operation_write.go | 85 ++++++++++++++++++- internal/filesystem/operation_write_test.go | 58 +++++++++++++ internal/filesystem/service.go | 24 ++++++ 10 files changed, 351 insertions(+), 6 deletions(-) diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index e6b7dbd7c..48a703a98 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -453,12 +453,20 @@ inode when those optional paths are selected. The default mount path still uses the legacy mutations. The optional lease renewal path serializes foreground and background refreshes with other inode mutations and blocks later fsync after an unknown renewal. +It journals at most one renewal per handle within a quarter of the configured +lease lifetime, capped at one minute. Other reads validate the current lease +without extending it. A later renewal verifies the preceding lease receipt +before removing that receipt from the local fsync set; failed verification +keeps the older receipt. Its retained records still need safe acknowledgement and garbage collection. `Service.Fsync` still uses an attribute read after the adapter checks the receipts for its supported journaled operations. The optional receipt tracker is local to one mount and retains only successful operations in memory. Session acknowledgement and journal garbage collection are not wired to the kernel's reply boundary. +Write planning failures before publication cancel their pending attempt with a +typed result so a duplicate observes the same failure and the session can +close. An uncertain cancellation remains unknown until resolution. In the pinned go-fuse version, `Server.handleRequest` writes the kernel reply after the raw filesystem method returns. The raw adapter has no callback for that write, so acknowledging when the adapter method returns would retire the diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index a0a26aa28..a04237d29 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -19,6 +19,7 @@ const ( defaultHandleKeepaliveInterval = time.Minute defaultHandleKeepaliveTimeout = 5 * time.Second disabledHandleKeepaliveInterval = 0 + leaseRenewalFraction = 4 ) // Core is the filesystem backend surface required by the FUSE adapter. @@ -61,6 +62,8 @@ type Adapter struct { operationSequence atomic.Uint64 receiptMu sync.Mutex receipts map[uint64]map[filesystem.OperationID]struct{} + leaseReceiptMu sync.Mutex + leaseReceipts map[openHandleKey][]filesystem.OperationID readdirLimit int keepaliveInterval time.Duration keepaliveTimeout time.Duration @@ -68,6 +71,8 @@ type Adapter struct { closeOnce sync.Once handlesMu sync.Mutex handles map[openHandleKey]struct{} + leaseRefreshed map[openHandleKey]time.Time + leaseRenewalMu sync.Mutex } type Option func(*Adapter) @@ -672,6 +677,14 @@ func (a *Adapter) refreshOpenHandleLease(ctx context.Context, inode uint64, fh u } func (a *Adapter) refreshJournaledLease(ctx context.Context, inode uint64, fh uint64) error { + if verifier, recent := a.recentLeaseVerifier(inode, fh); recent { + return errors.Wrap(verifier.ValidateOpenHandleLease(ctx, inode, fh, a.clientID), "validate open handle lease") + } + a.leaseRenewalMu.Lock() + defer a.leaseRenewalMu.Unlock() + if verifier, recent := a.recentLeaseVerifier(inode, fh); recent { + return errors.Wrap(verifier.ValidateOpenHandleLease(ctx, inode, fh, a.clientID), "validate open handle lease") + } refresher, ok := a.core.(interface { RefreshOpenHandleLeaseWithOperation(context.Context, filesystem.OperationID, uint64, uint64, []byte) error }) @@ -682,10 +695,52 @@ func (a *Adapter) refreshJournaledLease(ctx context.Context, inode uint64, fh ui err := refresher.RefreshOpenHandleLeaseWithOperation(ctx, id, inode, fh, a.clientID) if err == nil { a.recordReceipt(inode, id) + a.retainLatestLeaseReceipt(ctx, inode, fh, id) + a.markLeaseRefreshed(inode, fh) } return errors.Wrap(err, "refresh journaled open handle lease") } +type leaseReadVerifier interface { + ValidateOpenHandleLease(context.Context, uint64, uint64, []byte) error + OpenHandleLeaseTTL() time.Duration +} + +func (a *Adapter) recentLeaseVerifier(inode, fh uint64) (leaseReadVerifier, bool) { + verifier, ok := a.core.(leaseReadVerifier) + if !ok { + return nil, false + } + a.handlesMu.Lock() + last, known := a.leaseRefreshed[openHandleKey{inode: inode, fh: fh}] + a.handlesMu.Unlock() + if !known { + return nil, false + } + ttl := verifier.OpenHandleLeaseTTL() + if ttl == 0 || time.Since(last) < leaseRefreshWindow(ttl) { + return verifier, true + } + return nil, false +} + +func leaseRefreshWindow(ttl time.Duration) time.Duration { + window := min(ttl/leaseRenewalFraction, time.Minute) + if window <= 0 { + return time.Nanosecond + } + return window +} + +func (a *Adapter) markLeaseRefreshed(inode, fh uint64) { + a.handlesMu.Lock() + defer a.handlesMu.Unlock() + if a.leaseRefreshed == nil { + a.leaseRefreshed = make(map[openHandleKey]time.Time) + } + a.leaseRefreshed[openHandleKey{inode: inode, fh: fh}] = time.Now() +} + func (a *Adapter) refreshLegacyLease(ctx context.Context, inode uint64, fh uint64) syscall.Errno { err := a.core.RefreshOpenHandleLease(ctx, inode, fh, a.clientID) if err != nil && Errno(err) == syscall.EIO { @@ -704,15 +759,24 @@ func (a *Adapter) trackOpenHandle(inode uint64, fh uint64) { a.handles = make(map[openHandleKey]struct{}) } a.handles[openHandleKey{inode: inode, fh: fh}] = struct{}{} + if a.leaseRefreshed == nil { + a.leaseRefreshed = make(map[openHandleKey]time.Time) + } + a.leaseRefreshed[openHandleKey{inode: inode, fh: fh}] = time.Now() } func (a *Adapter) untrackOpenHandle(inode uint64, fh uint64) { if fh == 0 { return } + key := openHandleKey{inode: inode, fh: fh} a.handlesMu.Lock() - defer a.handlesMu.Unlock() - delete(a.handles, openHandleKey{inode: inode, fh: fh}) + delete(a.handles, key) + delete(a.leaseRefreshed, key) + a.handlesMu.Unlock() + a.leaseReceiptMu.Lock() + delete(a.leaseReceipts, key) + a.leaseReceiptMu.Unlock() } func (a *Adapter) openHandleSnapshot() []openHandleKey { diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index b3dae9d34..644f5ded9 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -95,6 +95,51 @@ type journalLeaseCore struct { leaseErr error } +type validatingLeaseCore struct { + *journalLeaseCore + ttl time.Duration + validationErr error + validations int +} + +func (c *validatingLeaseCore) OpenHandleLeaseTTL() time.Duration { return c.ttl } + +func (c *validatingLeaseCore) ValidateOpenHandleLease(context.Context, uint64, uint64, []byte) error { + c.validations++ + return c.validationErr +} + +func TestAdapterReadCoalescesJournaledLeaseRefresh(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &validatingLeaseCore{ttl: 5 * time.Minute, journalLeaseCore: &journalLeaseCore{ + receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, + }} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + for range 100 { + _, errno := adapter.Read(t.Context(), 7, 9, 0, 1) + require.Zero(t, errno) + } + require.Len(t, core.leaseIDs, 1) + require.Equal(t, 99, core.validations) + adapter.handlesMu.Lock() + adapter.leaseRefreshed[openHandleKey{inode: 7, fh: 9}] = time.Now().Add(-2 * time.Minute) + adapter.handlesMu.Unlock() + _, errno := adapter.Read(t.Context(), 7, 9, 0, 1) + require.Zero(t, errno) + require.Len(t, core.leaseIDs, 2) + require.Equal(t, []filesystem.OperationID{core.leaseIDs[0]}, core.verified) + adapter.receiptMu.Lock() + require.Len(t, adapter.receipts[7], 1) + adapter.receiptMu.Unlock() + core.validationErr = filesystem.ErrNotFound + _, errno = adapter.Read(t.Context(), 7, 9, 0, 1) + require.Equal(t, syscall.ENOENT, errno) + require.Len(t, core.leaseIDs, 2) +} + func (c *journalLeaseCore) RefreshOpenHandleLeaseWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ uint64, _ []byte) error { c.leaseIDs = append(c.leaseIDs, id) return c.leaseErr diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index 604b65a86..c4f19609d 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -71,8 +71,12 @@ func newMemoryServiceForMount(t *testing.T) *filesystem.Service { } func newMemoryServiceForMountWithChunkSize(t *testing.T, chunkSize uint64) *filesystem.Service { + return newMemoryServiceForMountWithOptions(t, filesystem.WithChunkSize(chunkSize)) +} + +func newMemoryServiceForMountWithOptions(t *testing.T, opts ...filesystem.Option) *filesystem.Service { t.Helper() - svc, err := fstest.NewMemoryService(filesystem.WithChunkSize(chunkSize)) + svc, err := fstest.NewMemoryService(opts...) require.NoError(t, err) info, err := os.Stat(t.TempDir()) require.NoError(t, err) @@ -467,7 +471,8 @@ func TestKernelMountJournaledLeaseRefreshAndFsync(t *testing.T) { if _, err := os.Stat("/dev/fuse"); err != nil { skipOrFail(t, "no /dev/fuse: %v", err) } - svc := newMemoryServiceForMount(t) + svc := newMemoryServiceForMountWithOptions(t, filesystem.WithChunkSize(e2eChunkSize), + filesystem.WithOpenHandleLeaseTTL(2*time.Second)) journal, err := filesystem.NewOperationJournal(svc) require.NoError(t, err) filesystemID, err := journal.EnsureFilesystem(t.Context()) @@ -479,6 +484,7 @@ func TestKernelMountJournaledLeaseRefreshAndFsync(t *testing.T) { fuseadapter.WithJournaledLeaseRefresh(session)) file, err := os.Create(filepath.Join(mountPoint, "file")) require.NoError(t, err) + time.Sleep(600 * time.Millisecond) require.NoError(t, file.Sync()) require.NoError(t, file.Close()) record, err := journal.Resolve(t.Context(), filesystem.OperationID{ diff --git a/internal/filesystem/fuseadapter/receipts.go b/internal/filesystem/fuseadapter/receipts.go index e82002836..d08bc0489 100644 --- a/internal/filesystem/fuseadapter/receipts.go +++ b/internal/filesystem/fuseadapter/receipts.go @@ -2,6 +2,7 @@ package fuseadapter import ( "context" + "log/slog" "syscall" "github.com/bootjp/elastickv/internal/filesystem" @@ -23,6 +24,31 @@ func (a *Adapter) recordReceipt(inode uint64, id filesystem.OperationID) { a.receipts[inode][id] = struct{}{} } +// retainLatestLeaseReceipt drops older lease receipts only after their +// participant proofs have been checked. Failed checks leave them for fsync. +func (a *Adapter) retainLatestLeaseReceipt(ctx context.Context, inode, fh uint64, id filesystem.OperationID) { + a.leaseReceiptMu.Lock() + defer a.leaseReceiptMu.Unlock() + key := openHandleKey{inode: inode, fh: fh} + if a.leaseReceipts == nil { + a.leaseReceipts = make(map[openHandleKey][]filesystem.OperationID) + } + previous := a.leaseReceipts[key] + a.leaseReceipts[key] = append(previous, id) + verifier, ok := a.core.(operationReceiptVerifier) + if !ok { + return + } + for _, old := range previous { + if err := verifier.VerifyOperationReceipt(ctx, old); err != nil { + slog.WarnContext(ctx, "filesystem lease receipt verification failed", "inode", inode, "fh", fh, "error", err.Error()) + return + } + } + a.clearReceipts(inode, previous) + a.leaseReceipts[key] = []filesystem.OperationID{id} +} + func (a *Adapter) verifyReceipts(ctx context.Context, inode uint64) ([]filesystem.OperationID, syscall.Errno) { a.receiptMu.Lock() receipts := make([]filesystem.OperationID, 0, len(a.receipts[inode])) diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index fdac733f9..1c1b597e0 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -376,11 +376,25 @@ func (j *OperationJournal) resolveDuplicateRejection(ctx context.Context, id Ope // Cancel must win an OCC transaction against any active publication. A lost // cancellation response remains unknown until Resolve observes a terminal row. func (j *OperationJournal) Cancel(ctx context.Context, id OperationID) (operationRecord, error) { + return j.cancelWithResult(ctx, id, nil) +} + +// CancelWithResult retains a typed failure when planning ended before any +// publication was dispatched. The result is immutable once cancellation wins. +func (j *OperationJournal) CancelWithResult(ctx context.Context, id OperationID, result []byte) (operationRecord, error) { + return j.cancelWithResult(ctx, id, result) +} + +func (j *OperationJournal) cancelWithResult(ctx context.Context, id OperationID, result []byte) (operationRecord, error) { ts, record, session, err := j.pendingSnapshotAllowClosed(ctx, id, true) if err != nil { return j.resolveWinningCancellation(ctx, id, err) } + if uint64(len(result)) > uint64(record.ResultLimit) { + return operationRecord{}, errors.WithStack(ErrOperationQuota) + } record.State = OperationCancelled + record.Result = append([]byte(nil), result...) session.Pending-- if err = j.storeRecordAndSession(ctx, ts, record, session, nil, nil); err != nil { return operationRecord{}, err diff --git a/internal/filesystem/operation_lease_test.go b/internal/filesystem/operation_lease_test.go index f83d01f78..72af2a3ef 100644 --- a/internal/filesystem/operation_lease_test.go +++ b/internal/filesystem/operation_lease_test.go @@ -3,11 +3,30 @@ package filesystem import ( "context" "testing" + "time" "github.com/bootjp/elastickv/internal/fskeys" "github.com/stretchr/testify/require" ) +func TestValidateOpenHandleLeaseDoesNotRenewExpiredHandle(t *testing.T) { + ctx := context.Background() + now := time.Unix(100, 0) + svc := newTestServiceWithOptions(t, []uint64{2, 3}, + WithClock(func() time.Time { return now }), WithOpenHandleLeaseTTL(time.Second)) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + require.Equal(t, time.Second, svc.OpenHandleLeaseTTL()) + require.NoError(t, svc.ValidateOpenHandleLease(ctx, file.Inode, fh, clientID)) + now = now.Add(2 * time.Second) + require.ErrorIs(t, svc.ValidateOpenHandleLease(ctx, file.Inode, fh, clientID), ErrNotFound) + require.ErrorIs(t, svc.ValidateOpenHandleLease(ctx, file.Inode, fh+1, clientID), ErrNotFound) +} + func TestLeaseOperationCannotResurrectReleasedHandle(t *testing.T) { ctx := context.Background() svc := newTestServiceWithOptions(t, []uint64{2, 3}) diff --git a/internal/filesystem/operation_write.go b/internal/filesystem/operation_write.go index 5249d3448..b2fedaa9b 100644 --- a/internal/filesystem/operation_write.go +++ b/internal/filesystem/operation_write.go @@ -20,9 +20,19 @@ const ( writeResultSuccess = 0 writeResultIsDir = 1 writeResultNotFound = 2 + writeResultPlanQuota = 3 + writeResultPlanContention = 4 + writeResultPlanInvalid = 5 + writeResultPlanStaleHome = 6 + writeResultPlanCancelled = 7 + writeResultPlanDeadline = 8 + writeResultPlanFailed = 9 ) -var errWriteCleanupChanged = errors.New("filesystem: write cleanup snapshot changed") +var ( + errWriteCleanupChanged = errors.New("filesystem: write cleanup snapshot changed") + ErrWritePlanningFailed = errors.New("filesystem: write planning failed") +) // WriteWithOperation publishes one fixed-offset write and its original byte // count under id. A pending duplicate is resolved without issuing a new write. @@ -120,7 +130,10 @@ func (s *Service) executeWriteOperation(ctx context.Context, journal *OperationJ func (s *Service) publishWriteOperationAttempt(ctx context.Context, journal *OperationJournal, id OperationID, attempt [16]byte, inode, offset, end uint64, data, intent []byte) (int, error) { plan, semanticErr, err := s.planJournalWrite(ctx, journal, id, attempt, inode, offset, end, data, intent) if err != nil { - return 0, err + if isDefinitiveWriteConflict(err) { + return 0, err + } + return cancelWritePlanningFailure(ctx, journal, id, err) } if semanticErr != nil { return publishWriteRejection(ctx, journal, id, attempt, plan, semanticErr) @@ -135,6 +148,45 @@ func (s *Service) publishWriteOperationAttempt(ctx context.Context, journal *Ope return 0, err } +func cancelWritePlanningFailure(ctx context.Context, journal *OperationJournal, id OperationID, planningErr error) (int, error) { + result := encodeWriteOperationResult(0, writePlanningFailureStatus(planningErr)) + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), writeConflictRetryBudget) + defer cancel() + for range operationCASRetries { + record, err := journal.CancelWithResult(cleanupCtx, id, result) + if err == nil { + return decodeWriteOperationResult(record) + } + if !isDefinitiveWriteConflict(err) { + break + } + } + record, err := journal.Resolve(cleanupCtx, id) + if err == nil { + return decodeWriteOperationResult(record) + } + return 0, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} + +func writePlanningFailureStatus(err error) byte { + switch { + case errors.Is(err, ErrOperationQuota): + return writeResultPlanQuota + case errors.Is(err, ErrContentionLimit): + return writeResultPlanContention + case errors.Is(err, ErrInvalid): + return writeResultPlanInvalid + case errors.Is(err, ErrStaleHome): + return writeResultPlanStaleHome + case errors.Is(err, context.Canceled): + return writeResultPlanCancelled + case errors.Is(err, context.DeadlineExceeded): + return writeResultPlanDeadline + default: + return writeResultPlanFailed + } +} + func encodeWriteOperationIntent(inode, offset uint64, data []byte) []byte { intent := make([]byte, writeOperationIntentHeader+len(data)) intent[0] = writeOperationIntentVersion @@ -156,6 +208,9 @@ func decodeWriteOperationResult(record operationRecord) (int, error) { if len(record.Result) != writeOperationResultBytes { return 0, errors.WithStack(ErrOperationRecordCorrupt) } + if record.State == OperationCancelled { + return decodeCancelledWriteResult(record.Result[0]) + } switch record.Result[0] { case writeResultSuccess: if record.State != OperationCommitted { @@ -177,6 +232,32 @@ func decodeWriteOperationResult(record operationRecord) (int, error) { } } +func decodeCancelledWriteResult(status byte) (int, error) { + if status < writeResultPlanQuota || status > writeResultPlanFailed { + return 0, errors.WithStack(ErrOperationRecordCorrupt) + } + return 0, writePlanningFailureError(status) +} + +func writePlanningFailureError(status byte) error { + switch status { + case writeResultPlanQuota: + return errors.WithStack(ErrOperationQuota) + case writeResultPlanContention: + return errors.WithStack(ErrContentionLimit) + case writeResultPlanInvalid: + return errors.WithStack(ErrInvalid) + case writeResultPlanStaleHome: + return errors.WithStack(ErrStaleHome) + case writeResultPlanCancelled: + return errors.WithStack(context.Canceled) + case writeResultPlanDeadline: + return errors.WithStack(context.DeadlineExceeded) + default: + return errors.WithStack(ErrWritePlanningFailed) + } +} + func resolveWriteOperation(ctx context.Context, journal *OperationJournal, id OperationID) (int, error) { record, err := journal.Resolve(ctx, id) if err != nil { diff --git a/internal/filesystem/operation_write_test.go b/internal/filesystem/operation_write_test.go index 096df772a..aeb564c22 100644 --- a/internal/filesystem/operation_write_test.go +++ b/internal/filesystem/operation_write_test.go @@ -1,7 +1,9 @@ package filesystem import ( + "bytes" "context" + "errors" "testing" "github.com/bootjp/elastickv/internal/fskeys" @@ -20,6 +22,21 @@ type writeOperationFaultDispatcher struct { rejectConflict bool } +type failWritePlanStore struct { + store.MVCCStore + key []byte + err error + once bool +} + +func (s *failWritePlanStore) GetAt(ctx context.Context, key []byte, ts uint64) ([]byte, error) { + if !s.once && bytes.Equal(key, s.key) { + s.once = true + return nil, s.err + } + return s.MVCCStore.GetAt(ctx, key, ts) +} + func (d *writeOperationFaultDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { if response, err, handled := d.handleRejection(ctx, req); handled { return response, err @@ -251,3 +268,44 @@ func TestWriteWithOperationReplansDefinitiveRejectionConflict(t *testing.T) { _, err = svc.WriteWithOperation(ctx, id, 999, 0, []byte("x")) require.ErrorIs(t, err, ErrNotFound) } + +func TestWritePlanningFailureCancelsAttemptWithStableResult(t *testing.T) { + for _, tc := range []struct { + name string + err error + want error + }{ + {name: "quota", err: ErrOperationQuota, want: ErrOperationQuota}, + {name: "other", err: errors.New("planner failed"), want: ErrWritePlanningFailed}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + intent := encodeWriteOperationIntent(file.Inode, 0, []byte("x")) + limit, err := svc.writeOperationEffectLimit(ctx, file.Inode, 0, 1) + require.NoError(t, err) + _, err = journal.Admit(ctx, id, intent, writeOperationResultBytes, limit) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, id) + require.NoError(t, err) + svc.store = &failWritePlanStore{MVCCStore: svc.store, key: fskeys.InodeKey(file.Inode), err: tc.err} + _, err = svc.executeWriteOperation(ctx, journal, id, attempt, file.Inode, 0, 1, []byte("x"), intent) + require.ErrorIs(t, err, tc.want) + _, err = svc.WriteWithOperation(ctx, id, file.Inode, 0, []byte("x")) + require.ErrorIs(t, err, tc.want) + record, err := journal.Resolve(ctx, id) + require.NoError(t, err) + require.Equal(t, OperationCancelled, record.State) + require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session})) + data, err := svc.Read(ctx, file.Inode, 0, 0, 1) + require.NoError(t, err) + require.Empty(t, data) + }) + } +} diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index 7d490c4d4..d2ae05037 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -1637,6 +1637,30 @@ func (s *Service) RefreshOpenHandleLease(ctx context.Context, inode uint64, fh u return ErrContentionLimit } +// OpenHandleLeaseTTL exposes the configured renewal budget to FUSE. +func (s *Service) OpenHandleLeaseTTL() time.Duration { + return s.openLeaseTTL +} + +// ValidateOpenHandleLease checks a handle without extending its lease. +func (s *Service) ValidateOpenHandleLease(ctx context.Context, inode uint64, fh uint64, clientID []byte) error { + ts, err := s.readTS(ctx) + if err != nil { + return err + } + if _, err := s.inodeAt(ctx, inode, ts); err != nil { + return err + } + lease, exists, err := s.openHandleLeaseAt(ctx, fskeys.RefKey(inode, clientID, fh), ts) + if err != nil { + return err + } + if !exists || lease.expired(s.now().UnixNano()) { + return errors.WithStack(ErrNotFound) + } + return nil +} + func (s *Service) refreshOpenHandleLeaseOnce(ctx context.Context, inode uint64, fh uint64, clientID []byte) error { ts, err := s.readTS(ctx) if err != nil { From 51ecd92acd7b47ff4737e5227f4c41e13ceaf708 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 18:02:06 +0900 Subject: [PATCH 29/35] filesystem: acknowledge FUSE replies and enable strict mounts Signed-off-by: bootjp --- .github/workflows/jepsen-test-scheduled.yml | 1 + ...6_10_25_partial_fuse_conflict_semantics.md | 20 +- docs/filesystem.md | 2 + go.mod | 2 + internal/filesystem/fuseadapter/ack.go | 184 ++++ internal/filesystem/fuseadapter/adapter.go | 69 +- .../filesystem/fuseadapter/mount_e2e_test.go | 99 +- internal/filesystem/fuseadapter/receipts.go | 5 +- internal/filesystem/fuseadapter/server.go | 61 +- .../filesystem/fuseadapter/unmount_context.go | 14 +- internal/filesystem/operation_journal_exec.go | 26 + internal/filesystem/operation_journal_test.go | 34 + internal/filesystem/operation_receipt.go | 25 + main.go | 49 +- main_filesystem_test.go | 42 +- third_party/go-fuse/LICENSE | 30 + third_party/go-fuse/README.md | 12 + third_party/go-fuse/fuse/.gitignore | 1 + third_party/go-fuse/fuse/api.go | 522 ++++++++++ third_party/go-fuse/fuse/attr.go | 79 ++ third_party/go-fuse/fuse/attr_linux.go | 51 + third_party/go-fuse/fuse/attr_unix.go | 29 + third_party/go-fuse/fuse/bufferpool.go | 79 ++ third_party/go-fuse/fuse/constants.go | 44 + third_party/go-fuse/fuse/constants_freebsd.go | 9 + third_party/go-fuse/fuse/constants_linux.go | 12 + third_party/go-fuse/fuse/context.go | 61 ++ third_party/go-fuse/fuse/defaultraw.go | 188 ++++ third_party/go-fuse/fuse/direntry.go | 264 +++++ third_party/go-fuse/fuse/direntry_darwin.go | 19 + third_party/go-fuse/fuse/direntry_freebsd.go | 20 + third_party/go-fuse/fuse/direntry_linux.go | 22 + third_party/go-fuse/fuse/ext.go | 114 +++ third_party/go-fuse/fuse/fusefd.go | 253 +++++ third_party/go-fuse/fuse/fusefd_linux.go | 43 + third_party/go-fuse/fuse/fusefd_other.go | 33 + third_party/go-fuse/fuse/misc.go | 96 ++ third_party/go-fuse/fuse/misc_darwin.go | 4 + third_party/go-fuse/fuse/misc_unix.go | 7 + third_party/go-fuse/fuse/mount.go | 72 ++ third_party/go-fuse/fuse/mount_darwin.go | 114 +++ third_party/go-fuse/fuse/mount_freebsd.go | 146 +++ third_party/go-fuse/fuse/mount_linux.go | 279 ++++++ third_party/go-fuse/fuse/opcode.go | 806 ++++++++++++++++ third_party/go-fuse/fuse/opcode_darwin.go | 27 + third_party/go-fuse/fuse/opcode_linux.go | 26 + third_party/go-fuse/fuse/passthrough_linux.go | 61 ++ third_party/go-fuse/fuse/poll.go | 52 + third_party/go-fuse/fuse/poll_darwin.go | 56 ++ third_party/go-fuse/fuse/poll_unix.go | 27 + third_party/go-fuse/fuse/print.go | 446 +++++++++ third_party/go-fuse/fuse/print_darwin.go | 50 + third_party/go-fuse/fuse/print_freebsd.go | 5 + third_party/go-fuse/fuse/print_linux.go | 92 ++ third_party/go-fuse/fuse/print_unix.go | 55 ++ third_party/go-fuse/fuse/protocol-server.go | 437 +++++++++ third_party/go-fuse/fuse/read.go | 142 +++ third_party/go-fuse/fuse/read_linux.go | 22 + third_party/go-fuse/fuse/request.go | 390 ++++++++ third_party/go-fuse/fuse/request_darwin.go | 13 + third_party/go-fuse/fuse/request_freebsd.go | 9 + third_party/go-fuse/fuse/request_linux.go | 13 + third_party/go-fuse/fuse/server.go | 872 +++++++++++++++++ third_party/go-fuse/fuse/splice_darwin.go | 13 + third_party/go-fuse/fuse/splice_freebsd.go | 9 + third_party/go-fuse/fuse/splice_linux.go | 152 +++ third_party/go-fuse/fuse/syscall_darwin.go | 128 +++ third_party/go-fuse/fuse/syscall_linux.go | 14 + third_party/go-fuse/fuse/syscall_unix.go | 43 + third_party/go-fuse/fuse/typeprint.go | 9 + third_party/go-fuse/fuse/types.go | 905 ++++++++++++++++++ third_party/go-fuse/fuse/types_darwin.go | 142 +++ third_party/go-fuse/fuse/types_freebsd.go | 38 + third_party/go-fuse/fuse/types_linux.go | 59 ++ third_party/go-fuse/fuse/types_unix.go | 68 ++ third_party/go-fuse/go.mod | 15 + third_party/go-fuse/splice/copy.go | 72 ++ third_party/go-fuse/splice/pair.go | 95 ++ third_party/go-fuse/splice/pair_linux.go | 118 +++ third_party/go-fuse/splice/pool.go | 107 +++ third_party/go-fuse/splice/splice.go | 114 +++ 81 files changed, 8861 insertions(+), 77 deletions(-) create mode 100644 internal/filesystem/fuseadapter/ack.go create mode 100644 third_party/go-fuse/LICENSE create mode 100644 third_party/go-fuse/README.md create mode 100644 third_party/go-fuse/fuse/.gitignore create mode 100644 third_party/go-fuse/fuse/api.go create mode 100644 third_party/go-fuse/fuse/attr.go create mode 100644 third_party/go-fuse/fuse/attr_linux.go create mode 100644 third_party/go-fuse/fuse/attr_unix.go create mode 100644 third_party/go-fuse/fuse/bufferpool.go create mode 100644 third_party/go-fuse/fuse/constants.go create mode 100644 third_party/go-fuse/fuse/constants_freebsd.go create mode 100644 third_party/go-fuse/fuse/constants_linux.go create mode 100644 third_party/go-fuse/fuse/context.go create mode 100644 third_party/go-fuse/fuse/defaultraw.go create mode 100644 third_party/go-fuse/fuse/direntry.go create mode 100644 third_party/go-fuse/fuse/direntry_darwin.go create mode 100644 third_party/go-fuse/fuse/direntry_freebsd.go create mode 100644 third_party/go-fuse/fuse/direntry_linux.go create mode 100644 third_party/go-fuse/fuse/ext.go create mode 100644 third_party/go-fuse/fuse/fusefd.go create mode 100644 third_party/go-fuse/fuse/fusefd_linux.go create mode 100644 third_party/go-fuse/fuse/fusefd_other.go create mode 100644 third_party/go-fuse/fuse/misc.go create mode 100644 third_party/go-fuse/fuse/misc_darwin.go create mode 100644 third_party/go-fuse/fuse/misc_unix.go create mode 100644 third_party/go-fuse/fuse/mount.go create mode 100644 third_party/go-fuse/fuse/mount_darwin.go create mode 100644 third_party/go-fuse/fuse/mount_freebsd.go create mode 100644 third_party/go-fuse/fuse/mount_linux.go create mode 100644 third_party/go-fuse/fuse/opcode.go create mode 100644 third_party/go-fuse/fuse/opcode_darwin.go create mode 100644 third_party/go-fuse/fuse/opcode_linux.go create mode 100644 third_party/go-fuse/fuse/passthrough_linux.go create mode 100644 third_party/go-fuse/fuse/poll.go create mode 100644 third_party/go-fuse/fuse/poll_darwin.go create mode 100644 third_party/go-fuse/fuse/poll_unix.go create mode 100644 third_party/go-fuse/fuse/print.go create mode 100644 third_party/go-fuse/fuse/print_darwin.go create mode 100644 third_party/go-fuse/fuse/print_freebsd.go create mode 100644 third_party/go-fuse/fuse/print_linux.go create mode 100644 third_party/go-fuse/fuse/print_unix.go create mode 100644 third_party/go-fuse/fuse/protocol-server.go create mode 100644 third_party/go-fuse/fuse/read.go create mode 100644 third_party/go-fuse/fuse/read_linux.go create mode 100644 third_party/go-fuse/fuse/request.go create mode 100644 third_party/go-fuse/fuse/request_darwin.go create mode 100644 third_party/go-fuse/fuse/request_freebsd.go create mode 100644 third_party/go-fuse/fuse/request_linux.go create mode 100644 third_party/go-fuse/fuse/server.go create mode 100644 third_party/go-fuse/fuse/splice_darwin.go create mode 100644 third_party/go-fuse/fuse/splice_freebsd.go create mode 100644 third_party/go-fuse/fuse/splice_linux.go create mode 100644 third_party/go-fuse/fuse/syscall_darwin.go create mode 100644 third_party/go-fuse/fuse/syscall_linux.go create mode 100644 third_party/go-fuse/fuse/syscall_unix.go create mode 100644 third_party/go-fuse/fuse/typeprint.go create mode 100644 third_party/go-fuse/fuse/types.go create mode 100644 third_party/go-fuse/fuse/types_darwin.go create mode 100644 third_party/go-fuse/fuse/types_freebsd.go create mode 100644 third_party/go-fuse/fuse/types_linux.go create mode 100644 third_party/go-fuse/fuse/types_unix.go create mode 100644 third_party/go-fuse/go.mod create mode 100644 third_party/go-fuse/splice/copy.go create mode 100644 third_party/go-fuse/splice/pair.go create mode 100644 third_party/go-fuse/splice/pair_linux.go create mode 100644 third_party/go-fuse/splice/pool.go create mode 100644 third_party/go-fuse/splice/splice.go diff --git a/.github/workflows/jepsen-test-scheduled.yml b/.github/workflows/jepsen-test-scheduled.yml index bbbb740bb..0afa783b3 100644 --- a/.github/workflows/jepsen-test-scheduled.yml +++ b/.github/workflows/jepsen-test-scheduled.yml @@ -284,6 +284,7 @@ jobs: --dynamoAddress "127.0.0.1:6380${node}" \ --s3Address "127.0.0.1:6390${node}" \ --filesystemMount "/tmp/elastickv-fs/fs-n${node}" \ + --filesystemStrict \ --filesystemRootUID "$(id -u)" \ --filesystemRootGID "$(id -g)" \ --metricsAddress "" \ diff --git a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md index 48a703a98..de149176c 100644 --- a/docs/design/2026_10_25_partial_fuse_conflict_semantics.md +++ b/docs/design/2026_10_25_partial_fuse_conflict_semantics.md @@ -458,21 +458,23 @@ lease lifetime, capped at one minute. Other reads validate the current lease without extending it. A later renewal verifies the preceding lease receipt before removing that receipt from the local fsync set; failed verification keeps the older receipt. -Its retained records still need safe acknowledgement and garbage collection. `Service.Fsync` still uses an attribute read after the adapter checks the receipts for its supported journaled operations. The optional receipt tracker is local to one mount and retains only successful -operations in memory. Session acknowledgement and journal garbage collection -are not wired to the kernel's reply boundary. +operations in memory. Successful replies now trigger receipt verification and +session acknowledgement after the FUSE device write attempt. Write planning failures before publication cancel their pending attempt with a typed result so a duplicate observes the same failure and the session can close. An uncertain cancellation remains unknown until resolution. -In the pinned go-fuse version, `Server.handleRequest` writes the kernel reply -after the raw filesystem method returns. The raw adapter has no callback for -that write, so acknowledging when the adapter method returns would retire the -operation before the reply attempt required by the journal contract. -The baseline and optional-path tests do not satisfy the strict-mode gates -below. +The local go-fuse bridge now notifies the adapter after `Server.handleRequest` +attempts the kernel reply. The adapter verifies participant receipts before +acknowledging terminal operations and retries acknowledgements that could not +complete. Suppressed replies do not trigger acknowledgement. The explicit +`--filesystemStrict` mount option selects all supported journaled mutation +paths. Recovery of a previous incarnation requires the separate +`--filesystemStrictRecover` flag after its process has stopped. +The explicit strict mount is available for validation, but the default remains +the legacy path until the strict-mode acceptance gates below pass. The current Raft path persists Ready entries through `persistReadyToWAL` before applying them. diff --git a/docs/filesystem.md b/docs/filesystem.md index b37ecbeb4..001f70439 100644 --- a/docs/filesystem.md +++ b/docs/filesystem.md @@ -79,6 +79,8 @@ flags: |---|---|---| | `--filesystemMount` | empty (disabled) | Directory to mount the filesystem on. | | `--filesystemClientID` | value of `--raftId` | Identity recorded in open-handle lease keys and in the mount log line, for attribution. Does not have to be unique: lease keys also carry a per-open handle ID. | +| `--filesystemStrict` | `false` | Use a registered operation session for supported FUSE mutations. This validation mode requires the reply-aware go-fuse bridge. | +| `--filesystemStrictRecover` | `false` | Fence and recover a prior strict session for the same client ID before registering a new one. Set this only after confirming the prior mount process has stopped. Requires `--filesystemStrict`. | | `--filesystemRootUID` | `0` | Owner UID of the root directory. Applied only when the root is first created. | | `--filesystemRootGID` | `0` | Owner GID of the root directory. Applied only when the root is first created. | | `--filesystemRootMode` | `0755` | Permission bits of the root directory. Applied only when the root is first created. | diff --git a/go.mod b/go.mod index b42641de5..27e03cbbe 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,7 @@ module github.com/bootjp/elastickv +replace github.com/hanwen/go-fuse/v2 => ./third_party/go-fuse + go 1.26.0 toolchain go1.27.2 diff --git a/internal/filesystem/fuseadapter/ack.go b/internal/filesystem/fuseadapter/ack.go new file mode 100644 index 000000000..f9c4a9acf --- /dev/null +++ b/internal/filesystem/fuseadapter/ack.go @@ -0,0 +1,184 @@ +package fuseadapter + +import ( + "cmp" + "context" + "log/slog" + "slices" + "time" + + "github.com/bootjp/elastickv/internal/filesystem" + "github.com/cockroachdb/errors" +) + +const ( + ackRetryInterval = 5 * time.Second + ackTimeout = 5 * time.Second + ackBatchSize = 64 + ackDrainInterval = 10 * time.Millisecond +) + +type replyIdentityKey struct{} + +func withReplyIdentity(ctx context.Context, unique uint64) context.Context { + if unique == 0 { + return ctx + } + return context.WithValue(ctx, replyIdentityKey{}, unique) +} + +func (a *Adapter) recordReplyIdentity(ctx context.Context, id filesystem.OperationID) { + unique, ok := ctx.Value(replyIdentityKey{}).(uint64) + if !ok || unique == 0 { + return + } + a.replyMu.Lock() + if a.replyIDs == nil { + a.replyIDs = make(map[uint64][]filesystem.OperationID) + } + a.replyIDs[unique] = append(a.replyIDs[unique], id) + a.replyMu.Unlock() +} + +// replyAttempted is called only after go-fuse has tried writing the reply. +// A failed device write is still a reply attempt; suppressed replies are not. +func (a *Adapter) replyAttempted(unique uint64) { + a.replyMu.Lock() + ids := a.replyIDs[unique] + delete(a.replyIDs, unique) + if a.ackPending == nil { + a.ackPending = make(map[filesystem.OperationID]struct{}) + } + for _, id := range ids { + a.ackPending[id] = struct{}{} + } + a.replyMu.Unlock() + if len(ids) > 0 && a.ackWake != nil { + select { + case a.ackWake <- struct{}{}: + default: + } + } +} + +func (a *Adapter) retryAcknowledgements(ctx context.Context) { + ticker := time.NewTicker(ackRetryInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-a.ackWake: + } + if a.processAcknowledgementBatch(ctx) { + select { + case a.ackWake <- struct{}{}: + default: + } + } + } +} + +func (a *Adapter) processAcknowledgementBatch(ctx context.Context) bool { + progress := false + for _, id := range a.pendingAcknowledgements() { + attemptCtx, cancel := context.WithTimeout(ctx, ackTimeout) + err := a.acknowledgeReceipt(attemptCtx, id) + cancel() + if err != nil { + slog.Warn("filesystem FUSE operation acknowledgement deferred", "sequence", id.Sequence, "error", err.Error()) + continue + } + a.replyMu.Lock() + delete(a.ackPending, id) + progress = true + a.replyMu.Unlock() + } + a.replyMu.Lock() + remaining := len(a.ackPending) + a.replyMu.Unlock() + return progress && remaining > 0 +} + +func (a *Adapter) pendingAcknowledgements() []filesystem.OperationID { + a.replyMu.Lock() + defer a.replyMu.Unlock() + ids := make([]filesystem.OperationID, 0, min(len(a.ackPending), ackBatchSize)) + for id := range a.ackPending { + ids = append(ids, id) + } + slices.SortFunc(ids, func(left, right filesystem.OperationID) int { + return cmp.Compare(left.Sequence, right.Sequence) + }) + if len(ids) > ackBatchSize { + ids = ids[:ackBatchSize] + } + return ids +} + +func (a *Adapter) acknowledgeReceipt(ctx context.Context, id filesystem.OperationID) error { + acker, ok := a.core.(interface { + AcknowledgeOperation(context.Context, filesystem.OperationID) error + }) + if !ok { + return filesystem.ErrUnsupported + } + a.receiptMu.Lock() + defer a.receiptMu.Unlock() + if a.hasReceiptLocked(id) { + verifier, ok := a.core.(operationReceiptVerifier) + if !ok { + return filesystem.ErrUnsupported + } + if err := verifier.VerifyOperationReceipt(ctx, id); err != nil { + return errors.Wrap(err, "verify operation before acknowledgement") + } + } + if err := acker.AcknowledgeOperation(ctx, id); err != nil { + return errors.Wrap(err, "acknowledge operation after reply") + } + for inode, ids := range a.receipts { + delete(ids, id) + if len(ids) == 0 { + delete(a.receipts, inode) + } + } + return nil +} + +func (a *Adapter) hasReceiptLocked(id filesystem.OperationID) bool { + for _, ids := range a.receipts { + if _, ok := ids[id]; ok { + return true + } + } + return false +} + +func (a *Adapter) finishStrictSession(ctx context.Context) error { + if !a.strictMode { + return nil + } + closer, ok := a.core.(interface { + CloseOperationSession(context.Context, filesystem.OperationSessionID) error + }) + if !ok { + return filesystem.ErrUnsupported + } + ticker := time.NewTicker(ackDrainInterval) + defer ticker.Stop() + for { + a.replyMu.Lock() + pending := len(a.ackPending) + a.replyMu.Unlock() + if pending == 0 { + return errors.Wrap(closer.CloseOperationSession(ctx, a.operationSession), "close strict operation session") + } + select { + case <-ctx.Done(): + return errors.Wrap(ctx.Err(), "wait for strict acknowledgements") + case <-ticker.C: + } + } +} diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index a04237d29..c9cee0af2 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -59,6 +59,7 @@ type Adapter struct { journaledOpen bool journaledRelease bool journaledLease bool + strictMode bool operationSequence atomic.Uint64 receiptMu sync.Mutex receipts map[uint64]map[filesystem.OperationID]struct{} @@ -73,6 +74,11 @@ type Adapter struct { handles map[openHandleKey]struct{} leaseRefreshed map[openHandleKey]time.Time leaseRenewalMu sync.Mutex + replyMu sync.Mutex + replyIDs map[uint64][]filesystem.OperationID + ackPending map[filesystem.OperationID]struct{} + ackWake chan struct{} + ackCancel context.CancelFunc } type Option func(*Adapter) @@ -206,6 +212,25 @@ func WithJournaledLeaseRefresh(session filesystem.OperationSessionID) Option { } } +// WithStrictMode routes every supported mutation through the registered +// operation session and enables reply-bound acknowledgement. +func WithStrictMode(session filesystem.OperationSessionID) Option { + return func(a *Adapter) { + a.setOperationSession(session) + a.strictMode = true + a.journaledWrites = true + a.journaledMetadata = true + a.journaledUnlink = true + a.journaledRmdir = true + a.journaledRename = true + a.journaledCreate = true + a.journaledMkdir = true + a.journaledOpen = true + a.journaledRelease = true + a.journaledLease = true + } +} + func (a *Adapter) setOperationSession(session filesystem.OperationSessionID) { if a.operationSession != (filesystem.OperationSessionID{}) && a.operationSession != session { a.sessionConflict = true @@ -230,6 +255,12 @@ func New(core Core, clientID []byte, opts ...Option) *Adapter { a.keepaliveCancel = cancel go a.keepOpenHandlesAlive(ctx) } + if a.operationSession != (filesystem.OperationSessionID{}) { + ctx, cancel := context.WithCancel(context.Background()) + a.ackCancel = cancel + a.ackWake = make(chan struct{}, 1) + go a.retryAcknowledgements(ctx) + } return a } @@ -238,6 +269,16 @@ func (a *Adapter) Close() { if a.keepaliveCancel != nil { a.keepaliveCancel() } + if a.ackCancel != nil { + a.ackCancel() + } + if a.strictMode { + ctx, cancel := context.WithTimeout(context.Background(), ackTimeout) + defer cancel() + if err := a.finishStrictSession(ctx); err != nil { + slog.Warn("filesystem strict session remains open after close", "error", err.Error()) + } + } }) } @@ -292,7 +333,7 @@ func (a *Adapter) SetAttr( if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return filesystem.Stat{}, syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) stat, err = setter.SetAttrWithOperation(ctx, id, inode, mask, attrs) if err == nil { a.recordReceipt(inode, id) @@ -303,9 +344,11 @@ func (a *Adapter) SetAttr( return stat, Errno(err) } -func (a *Adapter) nextOperationID() filesystem.OperationID { - return filesystem.OperationID{Filesystem: a.operationSession.Filesystem, Session: a.operationSession.Session, +func (a *Adapter) nextOperationID(ctx context.Context) filesystem.OperationID { + id := filesystem.OperationID{Filesystem: a.operationSession.Filesystem, Session: a.operationSession.Session, Sequence: a.operationSequence.Add(1)} + a.recordReplyIdentity(ctx, id) + return id } func (a *Adapter) Open(ctx context.Context, inode uint64) (uint64, syscall.Errno) { @@ -338,7 +381,7 @@ func (a *Adapter) openWithOperation(ctx context.Context, inode uint64) (uint64, } var err error defer func() { a.mutations.end(ticket, errors.Is(err, kv.ErrTransactionOutcomeUnknown)) }() - id := a.nextOperationID() + id := a.nextOperationID(ctx) fh, err := opener.OpenWithOperation(ctx, id, inode, a.clientID) if err == nil { a.recordReceipt(inode, id) @@ -377,7 +420,7 @@ func (a *Adapter) Write(ctx context.Context, inode uint64, fh uint64, offset uin if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return 0, syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) n, writeErr = writer.WriteWithOperation(ctx, id, inode, offset, data) if writeErr == nil { a.recordReceipt(inode, id) @@ -452,7 +495,7 @@ func (a *Adapter) Release(ctx context.Context, inode uint64, fh uint64) syscall. if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) err = releaser.ReleaseWithOperation(ctx, id, inode, fh, a.clientID) if err == nil { a.recordReceipt(inode, id) @@ -486,7 +529,7 @@ func (a *Adapter) Create( if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return filesystem.CreateResult{}, syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) result, err = creator.CreateWithOperation(ctx, id, parent, name, opts) if err == nil { a.recordReceipt(parent, id) @@ -521,7 +564,7 @@ func (a *Adapter) Mknod( if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return filesystem.CreateResult{}, syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) result, err = creator.CreateWithOperation(ctx, id, parent, name, opts) if err == nil { a.recordReceipt(parent, id) @@ -552,7 +595,7 @@ func (a *Adapter) Mkdir( if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return filesystem.CreateResult{}, syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) result, err = creator.MkdirWithOperation(ctx, id, parent, name, opts) if err == nil { a.recordReceipt(parent, id) @@ -577,7 +620,7 @@ func (a *Adapter) Unlink(ctx context.Context, parent uint64, name []byte) (errno if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) err = unlinker.UnlinkWithOperation(ctx, id, parent, name) if err == nil { a.recordReceipt(parent, id) @@ -602,7 +645,7 @@ func (a *Adapter) Rmdir(ctx context.Context, parent uint64, name []byte) (errno if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) err = remover.RmdirWithOperation(ctx, id, parent, name) if err == nil { a.recordReceipt(parent, id) @@ -633,7 +676,7 @@ func (a *Adapter) Rename( if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return syscall.EOPNOTSUPP } - id := a.nextOperationID() + id := a.nextOperationID(ctx) err = renamer.RenameWithOperation(ctx, id, oldParent, oldName, newParent, newName) if err == nil { a.recordReceipt(oldParent, id) @@ -691,7 +734,7 @@ func (a *Adapter) refreshJournaledLease(ctx context.Context, inode uint64, fh ui if !ok || a.sessionConflict || a.operationSession.Filesystem == [16]byte{} || a.operationSession.Session == [16]byte{} { return filesystem.ErrUnsupported } - id := a.nextOperationID() + id := a.nextOperationID(ctx) err := refresher.RefreshOpenHandleLeaseWithOperation(ctx, id, inode, fh, a.clientID) if err == nil { a.recordReceipt(inode, id) diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index c4f19609d..aebd1f9ee 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -393,6 +393,14 @@ func TestKernelMountStatfsReportsNameLimit(t *testing.T) { require.EqualValues(t, 255, stat.Namelen) } +func requireOperationRetired(t *testing.T, journal *filesystem.OperationJournal, id filesystem.OperationID) { + t.Helper() + require.Eventually(t, func() bool { + _, err := journal.Resolve(t.Context(), id) + return errors.Is(err, filesystem.ErrOperationRetired) + }, 5*time.Second, 10*time.Millisecond) +} + func TestKernelMountJournaledFixedOffsetWrite(t *testing.T) { if _, err := os.Stat("/dev/fuse"); err != nil { skipOrFail(t, "no /dev/fuse: %v", err) @@ -415,11 +423,75 @@ func TestKernelMountJournaledFixedOffsetWrite(t *testing.T) { data, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, []byte("journaled"), data) - record, err := journal.Resolve(t.Context(), filesystem.OperationID{ + requireOperationRetired(t, journal, filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, + }) +} + +func TestKernelMountStrictModeMutationsAndReplyAcknowledgement(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-strict") + require.NoError(t, err) + mountPoint := t.TempDir() + mount := mountMemoryServiceAtWithOptions(t, svc, "e2e-strict", mountPoint, fuseadapter.WithStrictMode(session)) + path := filepath.Join(mountPoint, "file") + require.NoError(t, os.WriteFile(path, []byte("strict"), 0o600)) + require.NoError(t, os.Truncate(path, 3)) + f, err := os.Open(path) + require.NoError(t, err) + require.NoError(t, f.Sync()) + require.NoError(t, f.Close()) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("str"), data) + require.NoError(t, os.Rename(path, filepath.Join(mountPoint, "renamed"))) + require.NoError(t, os.Remove(filepath.Join(mountPoint, "renamed"))) + directory, err := os.Open(mountPoint) + require.NoError(t, err) + require.NoError(t, directory.Sync()) + require.NoError(t, directory.Close()) + requireOperationRetired(t, journal, filesystem.OperationID{ Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, }) + require.NoError(t, mount.unmount()) + _, err = journal.RegisterSession(t.Context(), filesystemID, "e2e-strict") require.NoError(t, err) - require.Equal(t, filesystem.OperationCommitted, record.State) +} + +func TestKernelMountStrictModeReclaimsJournalQuota(t *testing.T) { + if _, err := os.Stat("/dev/fuse"); err != nil { + skipOrFail(t, "no /dev/fuse: %v", err) + } + svc := newMemoryServiceForMount(t) + journal, err := filesystem.NewOperationJournal(svc) + require.NoError(t, err) + filesystemID, err := journal.EnsureFilesystem(t.Context()) + require.NoError(t, err) + session, err := journal.RegisterSession(t.Context(), filesystemID, "e2e-strict-quota") + require.NoError(t, err) + mountPoint := t.TempDir() + mountMemoryServiceAtWithOptions(t, svc, "e2e-strict-quota", mountPoint, fuseadapter.WithStrictMode(session)) + file, err := os.Create(filepath.Join(mountPoint, "file")) + require.NoError(t, err) + for range 4200 { + _, err = file.Write([]byte("x")) + require.NoError(t, err) + } + require.NoError(t, file.Sync()) + require.NoError(t, file.Close()) + stat, err := os.Stat(filepath.Join(mountPoint, "file")) + require.NoError(t, err) + require.EqualValues(t, 4200, stat.Size()) + requireOperationRetired(t, journal, filesystem.OperationID{ + Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, + }) } func TestKernelMountJournaledCreateAndMkdir(t *testing.T) { @@ -452,18 +524,10 @@ func TestKernelMountJournaledCreateAndMkdir(t *testing.T) { require.NoError(t, err) require.NoError(t, directory.Sync()) require.NoError(t, directory.Close()) - require.Eventually(t, func() bool { - _, err := journal.Resolve(t.Context(), filesystem.OperationID{ - Filesystem: session.Filesystem, Session: session.Session, Sequence: 5, - }) - return err == nil - }, 3*time.Second, 10*time.Millisecond) for sequence := uint64(1); sequence <= 5; sequence++ { - record, err := journal.Resolve(t.Context(), filesystem.OperationID{ + requireOperationRetired(t, journal, filesystem.OperationID{ Filesystem: session.Filesystem, Session: session.Session, Sequence: sequence, }) - require.NoError(t, err) - require.Equal(t, filesystem.OperationCommitted, record.State) } } @@ -487,11 +551,9 @@ func TestKernelMountJournaledLeaseRefreshAndFsync(t *testing.T) { time.Sleep(600 * time.Millisecond) require.NoError(t, file.Sync()) require.NoError(t, file.Close()) - record, err := journal.Resolve(t.Context(), filesystem.OperationID{ + requireOperationRetired(t, journal, filesystem.OperationID{ Filesystem: session.Filesystem, Session: session.Session, Sequence: 1, }) - require.NoError(t, err) - require.Equal(t, filesystem.OperationCommitted, record.State) } func TestKernelMountJournaledMetadataChange(t *testing.T) { @@ -555,7 +617,8 @@ func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { require.NoError(t, dir.Close()) require.NoError(t, os.WriteFile(path, []byte("new"), 0o600)) id := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 1} - require.NoError(t, svc.UnlinkWithOperation(t.Context(), id, filesystem.RootInode, []byte("file"))) + requireOperationRetired(t, journal, id) + require.ErrorIs(t, svc.UnlinkWithOperation(t.Context(), id, filesystem.RootInode, []byte("file")), filesystem.ErrOperationRetired) data, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, []byte("new"), data) @@ -568,7 +631,8 @@ func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { require.NoError(t, dir.Close()) require.NoError(t, os.Mkdir(dirPath, 0o700)) directoryID := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 2} - require.NoError(t, svc.RmdirWithOperation(t.Context(), directoryID, filesystem.RootInode, []byte("dir"))) + requireOperationRetired(t, journal, directoryID) + require.ErrorIs(t, svc.RmdirWithOperation(t.Context(), directoryID, filesystem.RootInode, []byte("dir")), filesystem.ErrOperationRetired) stat, err := os.Stat(dirPath) require.NoError(t, err) require.True(t, stat.IsDir()) @@ -582,7 +646,8 @@ func TestKernelMountJournaledUnlinkAndDirectoryFsync(t *testing.T) { require.NoError(t, dir.Close()) require.NoError(t, os.WriteFile(source, []byte("new"), 0o600)) renameID := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 3} - require.NoError(t, svc.RenameWithOperation(t.Context(), renameID, filesystem.RootInode, []byte("source"), filesystem.RootInode, []byte("target"))) + requireOperationRetired(t, journal, renameID) + require.ErrorIs(t, svc.RenameWithOperation(t.Context(), renameID, filesystem.RootInode, []byte("source"), filesystem.RootInode, []byte("target")), filesystem.ErrOperationRetired) sourceData, err := os.ReadFile(source) require.NoError(t, err) require.Equal(t, []byte("new"), sourceData) diff --git a/internal/filesystem/fuseadapter/receipts.go b/internal/filesystem/fuseadapter/receipts.go index d08bc0489..712b46a83 100644 --- a/internal/filesystem/fuseadapter/receipts.go +++ b/internal/filesystem/fuseadapter/receipts.go @@ -6,6 +6,7 @@ import ( "syscall" "github.com/bootjp/elastickv/internal/filesystem" + "github.com/cockroachdb/errors" ) type operationReceiptVerifier interface { @@ -40,7 +41,7 @@ func (a *Adapter) retainLatestLeaseReceipt(ctx context.Context, inode, fh uint64 return } for _, old := range previous { - if err := verifier.VerifyOperationReceipt(ctx, old); err != nil { + if err := verifier.VerifyOperationReceipt(ctx, old); err != nil && !errors.Is(err, filesystem.ErrOperationRetired) { slog.WarnContext(ctx, "filesystem lease receipt verification failed", "inode", inode, "fh", fh, "error", err.Error()) return } @@ -51,11 +52,11 @@ func (a *Adapter) retainLatestLeaseReceipt(ctx context.Context, inode, fh uint64 func (a *Adapter) verifyReceipts(ctx context.Context, inode uint64) ([]filesystem.OperationID, syscall.Errno) { a.receiptMu.Lock() + defer a.receiptMu.Unlock() receipts := make([]filesystem.OperationID, 0, len(a.receipts[inode])) for id := range a.receipts[inode] { receipts = append(receipts, id) } - a.receiptMu.Unlock() if len(receipts) == 0 { return receipts, 0 } diff --git a/internal/filesystem/fuseadapter/server.go b/internal/filesystem/fuseadapter/server.go index 45d838171..cd593632c 100644 --- a/internal/filesystem/fuseadapter/server.go +++ b/internal/filesystem/fuseadapter/server.go @@ -89,6 +89,11 @@ func (r *RawFileSystem) String() string { } +// OnReplyAttempt is invoked by the local go-fuse bridge after its device write. +func (r *RawFileSystem) OnReplyAttempt(unique uint64, _ uint32, _ fuse.Status, _ fuse.Status) { + r.adapter.replyAttempted(unique) +} + // toStatus converts an adapter errno into a FUSE status. Errno values // outside the int32 range of fuse.Status cannot be a real errno, so they // map to EIO instead of being truncated. @@ -123,7 +128,7 @@ func (r *RawFileSystem) GetAttr(cancel <-chan struct{}, input *fuse.GetAttrIn, o } func (r *RawFileSystem) SetAttr(cancel <-chan struct{}, input *fuse.SetAttrIn, out *fuse.AttrOut) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() mask, attrs := fuseSetAttr(input) stat, errno := r.adapter.SetAttr(ctx, input.NodeId, mask, attrs) @@ -139,7 +144,7 @@ func (r *RawFileSystem) Mknod(cancel <-chan struct{}, input *fuse.MknodIn, name if input.Mode&syscall.S_IFMT != syscall.S_IFREG { return fuse.Status(syscall.EOPNOTSUPP) } - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() result, errno := r.adapter.Mknod(ctx, input.NodeId, []byte(name), filesystem.CreateOptions{ Mode: input.Mode & permissionModeMask, @@ -154,7 +159,7 @@ func (r *RawFileSystem) Mknod(cancel <-chan struct{}, input *fuse.MknodIn, name } func (r *RawFileSystem) Mkdir(cancel <-chan struct{}, input *fuse.MkdirIn, name string, out *fuse.EntryOut) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() result, errno := r.adapter.Mkdir(ctx, input.NodeId, []byte(name), filesystem.CreateOptions{ Mode: input.Mode & permissionModeMask, @@ -169,13 +174,13 @@ func (r *RawFileSystem) Mkdir(cancel <-chan struct{}, input *fuse.MkdirIn, name } func (r *RawFileSystem) Unlink(cancel <-chan struct{}, header *fuse.InHeader, name string) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, header.Unique) defer done() return toStatus(r.adapter.Unlink(ctx, header.NodeId, []byte(name))) } func (r *RawFileSystem) Rmdir(cancel <-chan struct{}, header *fuse.InHeader, name string) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, header.Unique) defer done() return toStatus(r.adapter.Rmdir(ctx, header.NodeId, []byte(name))) } @@ -184,7 +189,7 @@ func (r *RawFileSystem) Rename(cancel <-chan struct{}, input *fuse.RenameIn, old if input.Flags != 0 { return toStatus(syscall.EOPNOTSUPP) } - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() return toStatus(r.adapter.Rename(ctx, input.NodeId, []byte(oldName), input.Newdir, []byte(newName))) } @@ -209,7 +214,7 @@ func (r *RawFileSystem) Access(cancel <-chan struct{}, input *fuse.AccessIn) fus } func (r *RawFileSystem) Create(cancel <-chan struct{}, input *fuse.CreateIn, name string, out *fuse.CreateOut) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() // EAGAIN can hide an already-applied create whose reply was lost. Do not // replay it and turn its original outcome into a collision/open result. @@ -238,7 +243,7 @@ func (r *RawFileSystem) createOrOpen(ctx context.Context, input *fuse.CreateIn, } func (r *RawFileSystem) Open(cancel <-chan struct{}, input *fuse.OpenIn, out *fuse.OpenOut) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() fh, errno := r.adapter.Open(ctx, input.NodeId) if errno == 0 { @@ -248,7 +253,7 @@ func (r *RawFileSystem) Open(cancel <-chan struct{}, input *fuse.OpenIn, out *fu } func (r *RawFileSystem) Read(cancel <-chan struct{}, input *fuse.ReadIn, _ []byte) (fuse.ReadResult, fuse.Status) { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() data, errno := r.adapter.Read(ctx, input.NodeId, input.Fh, input.Offset, uint64(input.Size)) if errno != 0 { @@ -258,7 +263,7 @@ func (r *RawFileSystem) Read(cancel <-chan struct{}, input *fuse.ReadIn, _ []byt } func (r *RawFileSystem) Write(cancel <-chan struct{}, input *fuse.WriteIn, data []byte) (uint32, fuse.Status) { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() n, errno := r.adapter.Write(ctx, input.NodeId, input.Fh, input.Offset, data) if errno != 0 { @@ -271,7 +276,7 @@ func (r *RawFileSystem) Write(cancel <-chan struct{}, input *fuse.WriteIn, data } func (r *RawFileSystem) Release(cancel <-chan struct{}, input *fuse.ReleaseIn) { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() if errno := r.adapter.Release(ctx, input.NodeId, input.Fh); errno != 0 { slog.WarnContext(ctx, "filesystem FUSE release failed", "inode", input.NodeId, "fh", input.Fh, "errno", errno) @@ -279,13 +284,13 @@ func (r *RawFileSystem) Release(cancel <-chan struct{}, input *fuse.ReleaseIn) { } func (r *RawFileSystem) Flush(cancel <-chan struct{}, input *fuse.FlushIn) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() return toStatus(r.adapter.Flush(ctx, input.NodeId, input.Fh)) } func (r *RawFileSystem) Fsync(cancel <-chan struct{}, input *fuse.FsyncIn) fuse.Status { - ctx, done := r.requestContext(cancel) + ctx, done := r.requestContextWithReply(cancel, input.Unique) defer done() datasync := input.FsyncFlags&1 != 0 return toStatus(r.adapter.Fsync(ctx, input.NodeId, input.Fh, datasync)) @@ -438,6 +443,11 @@ func (r *RawFileSystem) requestContext(cancel <-chan struct{}) (context.Context, return requestContextWithin(parent, cancel) } +func (r *RawFileSystem) requestContextWithReply(cancel <-chan struct{}, unique uint64) (context.Context, context.CancelFunc) { + ctx, done := r.requestContext(cancel) + return withReplyIdentity(ctx, unique), done +} + // cancelInFlight ends the context of every request in flight and of any // that arrives later. A nil RawFileSystem is a no-op. func (r *RawFileSystem) cancelInFlight() { @@ -897,13 +907,30 @@ func (s *Server) Unmount() error { if s == nil { return nil } - if s.adapter != nil { - s.adapter.Close() - } + defer func() { + if s.adapter != nil { + s.adapter.Close() + } + }() if s.inner == nil || s.isReleased() { return nil } - return errors.WithStack(s.inner.Unmount()) + if err := s.inner.Unmount(); err != nil { + return errors.WithStack(err) + } + s.finishStrictSession() + return nil +} + +func (s *Server) finishStrictSession() { + if s.adapter == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), ackTimeout) + defer cancel() + if err := s.adapter.finishStrictSession(ctx); err != nil { + slog.Warn("filesystem strict session remains open after unmount", "error", err.Error()) + } } // UnmountLazy detaches the mount from the namespace even while clients still diff --git a/internal/filesystem/fuseadapter/unmount_context.go b/internal/filesystem/fuseadapter/unmount_context.go index 2c0cfcd29..32d87bc85 100644 --- a/internal/filesystem/fuseadapter/unmount_context.go +++ b/internal/filesystem/fuseadapter/unmount_context.go @@ -13,16 +13,22 @@ func (s *Server) UnmountContext(ctx context.Context) error { if s == nil { return nil } - if s.adapter != nil { - s.adapter.Close() - } + defer func() { + if s.adapter != nil { + s.adapter.Close() + } + }() if s.inner == nil || s.isReleased() || s.mountPoint == "" { return nil } // Ordinary unmount can succeed before backend handlers leave Serve. // Cancel their common parent before the lifecycle can close stores. defer s.fs.cancelInFlight() - return unmountFUSEMount(ctx, s.mountPoint, s.fsType) + if err := unmountFUSEMount(ctx, s.mountPoint, s.fsType); err != nil { + return err + } + s.finishStrictSession() + return nil } func unmountFUSEMount(ctx context.Context, mountPoint, fsType string) error { diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index 1c1b597e0..8089ac89f 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -637,6 +637,32 @@ func (j *OperationJournal) FenceSession(ctx context.Context, id OperationSession return j.setSessionClosed(ctx, id, true) } +// RecoverOwnerSession fences a previous mount incarnation and resolves its +// pending attempts before the owner registers a replacement session. +func (j *OperationJournal) RecoverOwnerSession(ctx context.Context, filesystemID [16]byte, owner string) error { + if filesystemID == [16]byte{} || owner == "" { + return errors.WithStack(ErrInvalid) + } + ts, err := j.backend.Snapshot(ctx) + if err != nil { + return errors.Wrap(err, "snapshot previous operation owner") + } + previous, exists, err := j.loadOwnerAt(ctx, fskeys.OperationOwnerKey(filesystemID, []byte(owner)), ts) + if err != nil || !exists { + return err + } + id := OperationSessionID{Filesystem: filesystemID, Session: previous} + if err := j.FenceSession(ctx, id); err != nil { + return err + } + for { + resolved, err := j.RecoverClosedSession(ctx, id, defaultIntentRecoveryLimit) + if err != nil || resolved == 0 { + return err + } + } +} + func (j *OperationJournal) setSessionClosed(ctx context.Context, id OperationSessionID, allowPending bool) error { for range operationCASRetries { err := j.closeSessionOnce(ctx, id, allowPending) diff --git a/internal/filesystem/operation_journal_test.go b/internal/filesystem/operation_journal_test.go index 0cec23f5f..064648e67 100644 --- a/internal/filesystem/operation_journal_test.go +++ b/internal/filesystem/operation_journal_test.go @@ -694,6 +694,40 @@ func TestOperationJournalOwnerFenceAndRecovery(t *testing.T) { require.ErrorIs(t, err, ErrOperationSessionClosed) } +func TestOperationJournalRecoverOwnerSession(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + _, err = journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.NoError(t, journal.RecoverOwnerSession(ctx, id.Filesystem, "mount-a")) + record, err := journal.Resolve(ctx, id) + require.NoError(t, err) + require.Equal(t, OperationCancelled, record.State) + replacement, err := journal.RegisterSession(ctx, id.Filesystem, "mount-a") + require.NoError(t, err) + require.NotEqual(t, id.Session, replacement.Session) + require.NoError(t, journal.RecoverOwnerSession(ctx, id.Filesystem, "mount-a")) +} + +func TestAcknowledgeOperationCancelsPendingReply(t *testing.T) { + ctx := context.Background() + journal, st, coord := testOperationJournal(t) + svc, err := NewService(st, coord) + require.NoError(t, err) + id := testOperationID(t, journal, 1) + _, err = journal.Admit(ctx, id, []byte("release"), 128, 1024) + require.NoError(t, err) + _, err = journal.BeginAttempt(ctx, id) + require.NoError(t, err) + require.NoError(t, svc.AcknowledgeOperation(ctx, id)) + _, err = journal.Resolve(ctx, id) + require.ErrorIs(t, err, ErrOperationRetired) + require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session})) +} + func TestOperationJournalAcknowledgementGapsRetireWithoutReuse(t *testing.T) { ctx := context.Background() journal, _, _ := testOperationJournal(t) diff --git a/internal/filesystem/operation_receipt.go b/internal/filesystem/operation_receipt.go index 72f03b725..d5a38799c 100644 --- a/internal/filesystem/operation_receipt.go +++ b/internal/filesystem/operation_receipt.go @@ -22,3 +22,28 @@ func (s *Service) VerifyOperationReceipt(ctx context.Context, id OperationID) er } return nil } + +// AcknowledgeOperation retires a terminal operation after its FUSE reply was +// attempted and its participant proofs were verified by the adapter. +func (s *Service) AcknowledgeOperation(ctx context.Context, id OperationID) error { + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + if _, err := journal.Resolve(ctx, id); errors.Is(err, ErrOperationPending) { + if _, err := journal.Cancel(ctx, id); err != nil { + return err + } + } else if err != nil && !errors.Is(err, ErrOperationRetired) { + return err + } + return journal.Acknowledge(ctx, id) +} + +func (s *Service) CloseOperationSession(ctx context.Context, id OperationSessionID) error { + journal, err := NewOperationJournal(s) + if err != nil { + return err + } + return journal.CloseSession(ctx, id) +} diff --git a/main.go b/main.go index b48493495..16fee45a7 100644 --- a/main.go +++ b/main.go @@ -123,6 +123,8 @@ var ( pprofToken = flag.String("pprofToken", "", "Bearer token for pprof; required for non-loopback pprofAddress") filesystemMount = flag.String("filesystemMount", "", "FUSE mount point for the Elastickv filesystem; empty to disable") filesystemClientID = flag.String("filesystemClientID", "", "Stable open-handle lease client ID for the FUSE mount; empty uses raftId") + filesystemStrict = flag.Bool("filesystemStrict", false, "Use the operation journal for supported FUSE mutations") + filesystemStrictRecover = flag.Bool("filesystemStrictRecover", false, "Fence and recover a previous strict FUSE session for the same client ID; use only after the previous mount has stopped") filesystemCapacity = flag.Uint64("filesystemCapacity", 0, "Filesystem byte capacity reported by statfs; zero reports unlimited") filesystemMaxFiles = flag.Uint64("filesystemMaxFiles", 0, "Filesystem inode capacity reported by statfs; zero reports unlimited") filesystemRootMode = flag.Uint("filesystemRootMode", defaultFilesystemRootMode, "Root directory permission mode for first initialization") @@ -994,11 +996,13 @@ func startRaftEngineLifecycleWatchers(ctx context.Context, eg *errgroup.Group, r } type filesystemStartConfig struct { - mountPoint string - clientID string - rootMode uint32 - rootUID uint32 - rootGID uint32 + mountPoint string + clientID string + strict bool + strictRecover bool + rootMode uint32 + rootUID uint32 + rootGID uint32 // mountOptions overrides go-fuse's mount options. Production leaves it // nil (fusermount3 path); tests set DirectMount where no helper exists. mountOptions *fuse.MountOptions @@ -1013,6 +1017,11 @@ func filesystemStartConfigFromFlags() (filesystemStartConfig, error) { return filesystemStartConfig{}, errors.New("filesystem root mode, uid, or gid is out of range") } config.clientID = strings.TrimSpace(*filesystemClientID) + config.strict = *filesystemStrict + config.strictRecover = *filesystemStrictRecover + if config.strictRecover && !config.strict { + return filesystemStartConfig{}, errors.New("filesystemStrictRecover requires filesystemStrict") + } if config.clientID == "" { config.clientID = strings.TrimSpace(*raftId) } @@ -1081,7 +1090,11 @@ func mountFilesystemService( service *filesystem.Service, config filesystemStartConfig, ) (*fuseadapter.Server, <-chan struct{}, error) { - frontend := fuseadapter.New(service, []byte(config.clientID)) + adapterOptions, err := strictFilesystemOptions(ctx, service, config) + if err != nil { + return nil, nil, err + } + frontend := fuseadapter.New(service, []byte(config.clientID), adapterOptions...) server, err := fuseadapter.MountContext(ctx, config.mountPoint, frontend, config.mountOptions) if err != nil { frontend.Close() @@ -1100,6 +1113,30 @@ func mountFilesystemService( return server, serveDone, nil } +func strictFilesystemOptions(ctx context.Context, service *filesystem.Service, config filesystemStartConfig) ([]fuseadapter.Option, error) { + if !config.strict { + return nil, nil + } + journal, err := filesystem.NewOperationJournal(service) + if err != nil { + return nil, errors.Wrap(err, "create strict filesystem journal") + } + filesystemID, err := journal.EnsureFilesystem(ctx) + if err != nil { + return nil, errors.Wrap(err, "identify strict filesystem") + } + if config.strictRecover { + if err := journal.RecoverOwnerSession(ctx, filesystemID, config.clientID); err != nil { + return nil, errors.Wrap(err, "recover previous strict filesystem session") + } + } + session, err := journal.RegisterSession(ctx, filesystemID, config.clientID) + if err != nil { + return nil, errors.Wrap(err, "register strict filesystem session") + } + return []fuseadapter.Option{fuseadapter.WithStrictMode(session)}, nil +} + // filesystemMountServer is the part of *fuseadapter.Server the shutdown // lifecycle drives; tests substitute a fake to exercise the failure paths. type filesystemMountServer interface { diff --git a/main_filesystem_test.go b/main_filesystem_test.go index 89bf536eb..fa6845311 100644 --- a/main_filesystem_test.go +++ b/main_filesystem_test.go @@ -51,22 +51,28 @@ func TestRunFilesystemLeaseReaperRunsPeriodically(t *testing.T) { func TestFilesystemStartConfigFromFlags(t *testing.T) { oldMount, oldClientID, oldRaftID := *filesystemMount, *filesystemClientID, *raftId + oldStrict := *filesystemStrict + oldStrictRecover := *filesystemStrictRecover oldMode, oldUID, oldGID := *filesystemRootMode, *filesystemRootUID, *filesystemRootGID t.Cleanup(func() { *filesystemMount, *filesystemClientID, *raftId = oldMount, oldClientID, oldRaftID + *filesystemStrict = oldStrict + *filesystemStrictRecover = oldStrictRecover *filesystemRootMode, *filesystemRootUID, *filesystemRootGID = oldMode, oldUID, oldGID }) tests := []struct { - name string - mount string - clientID string - raftID string - mode uint - uid uint - gid uint - want filesystemStartConfig - wantErr string + name string + mount string + clientID string + raftID string + mode uint + uid uint + gid uint + want filesystemStartConfig + wantErr string + strict bool + strictRecover bool }{ { name: "disabled when mount point is blank", @@ -91,6 +97,22 @@ func TestFilesystemStartConfigFromFlags(t *testing.T) { mode: 0o755, want: filesystemStartConfig{mountPoint: "/mnt/fs", clientID: "mount-a", rootMode: 0o755}, }, + { + name: "strict mount is explicit", + mount: "/mnt/fs", + raftID: "n1", + mode: 0o755, + strict: true, + want: filesystemStartConfig{mountPoint: "/mnt/fs", clientID: "n1", strict: true, rootMode: 0o755}, + }, + { + name: "strict recovery needs strict mount", + mount: "/mnt/fs", + raftID: "n1", + mode: 0o755, + strictRecover: true, + wantErr: "requires filesystemStrict", + }, { name: "client id required", mount: "/mnt/fs", @@ -125,6 +147,8 @@ func TestFilesystemStartConfigFromFlags(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { *filesystemMount, *filesystemClientID, *raftId = tt.mount, tt.clientID, tt.raftID + *filesystemStrict = tt.strict + *filesystemStrictRecover = tt.strictRecover *filesystemRootMode, *filesystemRootUID, *filesystemRootGID = tt.mode, tt.uid, tt.gid got, err := filesystemStartConfigFromFlags() diff --git a/third_party/go-fuse/LICENSE b/third_party/go-fuse/LICENSE new file mode 100644 index 000000000..5c1d980e3 --- /dev/null +++ b/third_party/go-fuse/LICENSE @@ -0,0 +1,30 @@ +New BSD License + +Copyright (c) 2010 the Go-FUSE Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Ivan Krasin nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/third_party/go-fuse/README.md b/third_party/go-fuse/README.md new file mode 100644 index 000000000..f3c7a8083 --- /dev/null +++ b/third_party/go-fuse/README.md @@ -0,0 +1,12 @@ +# Local go-fuse bridge + +This directory contains the `fuse` and `splice` packages from +`github.com/hanwen/go-fuse/v2` v2.12.0, under its BSD license. + +The local change adds `ReplyAttemptObserver` and calls it immediately after +`Server.handleRequest` attempts to write a response to the FUSE device. +Elastickv uses that boundary to retire terminal filesystem operation IDs. +The callback is skipped when go-fuse suppresses the reply. + +When updating go-fuse, preserve the notification after the device write and +the suppressed-reply condition, then rerun the kernel FUSE tests. diff --git a/third_party/go-fuse/fuse/.gitignore b/third_party/go-fuse/fuse/.gitignore new file mode 100644 index 000000000..13df91618 --- /dev/null +++ b/third_party/go-fuse/fuse/.gitignore @@ -0,0 +1 @@ +version.gen.go diff --git a/third_party/go-fuse/fuse/api.go b/third_party/go-fuse/fuse/api.go new file mode 100644 index 000000000..4691e0816 --- /dev/null +++ b/third_party/go-fuse/fuse/api.go @@ -0,0 +1,522 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package fuse provides APIs to implement filesystems in +// userspace in terms of raw FUSE protocol. +// +// A filesystem is implemented by implementing its server that provides a +// RawFileSystem interface. Typically the server embeds +// NewDefaultRawFileSystem() and implements only subset of filesystem methods: +// +// type MyFS struct { +// fuse.RawFileSystem +// ... +// } +// +// func NewMyFS() *MyFS { +// return &MyFS{ +// RawFileSystem: fuse.NewDefaultRawFileSystem(), +// ... +// } +// } +// +// // Mkdir implements "mkdir" request handler. +// // +// // For other requests - not explicitly implemented by MyFS - ENOSYS +// // will be typically returned to client. +// func (fs *MyFS) Mkdir(...) { +// ... +// } +// +// Then the filesystem can be mounted and served to a client (typically OS +// kernel) by creating Server: +// +// fs := NewMyFS() // implements RawFileSystem +// fssrv, err := fuse.NewServer(fs, mountpoint, &fuse.MountOptions{...}) +// if err != nil { +// ... +// } +// +// and letting the server do its work: +// +// // either synchronously - .Serve() blocks until the filesystem is unmounted. +// fssrv.Serve() +// +// // or in the background - .Serve() is spawned in another goroutine, but +// // before interacting with fssrv from current context we have to wait +// // until the filesystem mounting is complete. +// go fssrv.Serve() +// err = fssrv.WaitMount() +// if err != nil { +// ... +// } +// +// The server will serve clients by dispatching their requests to the +// filesystem implementation and conveying responses back. For example "mkdir" +// FUSE request dispatches to call +// +// fs.Mkdir(*MkdirIn, ..., *EntryOut) +// +// "stat" to call +// +// fs.GetAttr(*GetAttrIn, *AttrOut) +// +// etc. Please refer to RawFileSystem documentation for details. +// +// Typically, each call of the API happens in its own +// goroutine, so take care to make the file system thread-safe. +// +// Be careful when you access the FUSE mount from the same process. An access can +// tie up two OS threads (one on the request side and one on the FUSE server side). +// This can deadlock if there is no free thread to handle the FUSE server side. +// Run your program with GOMAXPROCS=1 to make the problem easier to reproduce, +// see https://github.com/hanwen/go-fuse/issues/261 for an example of that +// problem. +// +// # Higher level interfaces +// +// As said above this packages provides way to implement filesystems in terms of +// raw FUSE protocol. +// +// Package github.com/hanwen/go-fuse/v2/fs provides way to implement +// filesystems in terms of paths and/or inodes. +// +// # Mount styles +// +// The NewServer() handles mounting the filesystem, which +// involves opening `/dev/fuse` and calling the +// `mount(2)` syscall. The latter needs root permissions. +// This is handled in one of three ways: +// +// 1) go-fuse opens `/dev/fuse` and executes the `fusermount` +// setuid-root helper to call `mount(2)` for us. This is the default. +// Does not need root permissions but needs `fusermount` installed. +// +// 2) If `MountOptions.DirectMount` is set, go-fuse calls `mount(2)` itself. +// Needs root permissions, but works without `fusermount`. +// +// 3) If `mountPoint` has the magic `/dev/fd/N` syntax, it means that that a +// privileged parent process: +// +// * Opened /dev/fuse +// +// * Called mount(2) on a real mountpoint directory that we don't know about +// +// * Inherited the fd to /dev/fuse to us +// +// * Informs us about the fd number via /dev/fd/N +// +// This magic syntax originates from libfuse [1] and allows the FUSE server to +// run without any privileges and without needing `fusermount`, as the parent +// process performs all privileged operations. +// +// The "privileged parent" is usually a container manager like Singularity [2], +// but for testing, it can also be the `mount.fuse3` helper with the +// `drop_privileges,setuid=$USER` flags. Example below for gocryptfs: +// +// $ sudo mount.fuse3 "/usr/local/bin/gocryptfs#/tmp/cipher" /tmp/mnt -o drop_privileges,setuid=$USER +// +// [1] https://github.com/libfuse/libfuse/commit/64e11073b9347fcf9c6d1eea143763ba9e946f70 +// +// [2] https://sylabs.io/guides/3.7/user-guide/bind_paths_and_mounts.html#fuse-mounts +// +// # Aborting a file system +// +// A caller that has an open file in a buggy or crashed FUSE +// filesystem will be hung. The easiest way to clean up this situation +// is through the fusectl filesystem. By writing into +// /sys/fs/fuse/connection/$ID/abort, reads from the FUSE device fail, +// and all callers receive ENOTCONN (transport endpoint not connected) +// on their pending syscalls. The FUSE connection ID can be found as +// the Dev field in the Stat_t result for a file in the mount. +package fuse + +import "log" + +// Types for users to implement. + +// The result of Read is an array of bytes, but for performance +// reasons, we can also return data as a file-descriptor/offset/size +// tuple. If the backing store for a file is another filesystem, this +// reduces the amount of copying between the kernel and the FUSE +// server. The ReadResult interface captures both cases. +type ReadResult interface { + // Returns the raw bytes for the read, possibly using the + // passed buffer. The buffer should be larger than the return + // value from Size. + Bytes(buf []byte) ([]byte, Status) + + // Size returns how many bytes this return value takes at most. + Size() int + + // Done() is called after sending the data to the kernel. + Done() +} + +type MountOptions struct { + AllowOther bool + + // Options are the options passed as -o string to fusermount. + Options []string + + // MaxBackground controls the maximum number of allowed backgruond + // asynchronous I/O requests. + // + // If unset, the default is _DEFAULT_BACKGROUND_TASKS, 12. + // Concurrency for synchronous I/O is not limited. + MaxBackground int + + // MaxInflightRequestBytes controls the number of bytes used for + // request structs and input buffers checked out by go-fuse. This + // includes buffers used by readers waiting on the kernel and requests + // being processed concurrently. + // + // It also applies to requests that do not expect a reply, such as + // FORGET and BATCH_FORGET. If unset, it defaults to math.MaxInt. If + // set smaller than the bytes needed for a single request, one request + // is still allowed through. + MaxInflightRequestBytes int + + // CongestionThreshold is the in-flight async-request count at which + // the kernel marks the FUSE backing-dev as congested, throttling new + // submissions. It corresponds to + // /sys/fs/fuse/connections//congestion_threshold. + // + // If 0, go-fuse falls back to the kernel-FUSE convention of + // 3/4 * MaxBackground. The value is silently clamped by the kernel + // to MaxBackground if it is set higher. + CongestionThreshold int + + // MaxWrite is the max size for read and write requests. If 0, use + // go-fuse default (currently 128 kiB). + // This number is internally capped at MAX_KERNEL_WRITE (higher values don't make + // sense). + // + // Non-direct-io reads are mostly served via kernel readahead, which is + // additionally subject to the MaxReadAhead limit. + // + // Implementation notes: + // + // There's four values the Linux kernel looks at when deciding the request size: + // * MaxWrite, passed via InitOut.MaxWrite. Limits the WRITE size. + // * max_read, passed via a string mount option. Limits the READ size. + // go-fuse sets max_read equal to MaxWrite. + // You can see the current max_read value in /proc/self/mounts . + // * MaxPages, passed via InitOut.MaxPages. In Linux 4.20 and later, the value + // can go up to 1 MiB and go-fuse calculates the MaxPages value acc. + // to MaxWrite, rounding up. + // On older kernels, the value is fixed at 128 kiB and the + // passed value is ignored. No request can be larger than MaxPages, so + // READ and WRITE are effectively capped at MaxPages. + // * MaxReadAhead, passed via InitOut.MaxReadAhead. + MaxWrite int + + // MaxReadAhead is the max read ahead size to use. It controls how much data the + // kernel reads in advance to satisfy future read requests from applications. + // How much exactly is subject to clever heuristics in the kernel + // (see https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/readahead.c?h=v6.2-rc5#n375 + // if you are brave) and hence also depends on the kernel version. + // + // If 0, use kernel default. This number is capped at the kernel maximum + // (128 kiB on Linux) and cannot be larger than MaxWrite. + // + // MaxReadAhead only affects buffered reads (=non-direct-io), but even then, the + // kernel can and does send larger reads to satisfy read reqests from applications + // (up to MaxWrite or VM_READAHEAD_PAGES=128 kiB, whichever is less). + MaxReadAhead int + + // IgnoreSecurityLabels, if set, makes security related xattr + // requests return NO_DATA without passing through the + // user defined filesystem. You should only set this if you + // file system implements extended attributes, and you are not + // interested in security labels. + IgnoreSecurityLabels bool // ignoring labels should be provided as a fusermount mount option. + + // RememberInodes, if set, makes go-fuse never forget inodes. + // This may be useful for NFS. + RememberInodes bool + + // FsName is the name of the filesystem, shown in "df -T" + // and friends (as the first column, "Filesystem"). + FsName string + + // Name is the "fuse." suffix, shown in "df -T" and friends + // (as the second column, "Type") + Name string + + // SingleThreaded, if set, wraps the file system in a single-threaded + // locking wrapper. + SingleThreaded bool + + // DisableXAttrs, if set, returns ENOSYS for Getxattr, Setxattr and + // Removexattr calls, so the kernel does not issue any Xattr operations + // at all. + DisableXAttrs bool + + // Debug, if set, enables verbose debugging information. + Debug bool + + // Logger, if set, is an alternate log sink for debug statements. + // + // To increase signal/noise ratio Go-FUSE uses abbreviations in its debug log + // output. Here is how to read it: + // + // - `iX` means `inode X`; + // - `gX` means `generation X`; + // - `tA` and `tE` means timeout for attributes and directory entry correspondingly; + // - `[ +)` means data range from `` inclusive till `+` exclusive; + // - `Xb` means `X bytes`. + // - `pX` means the request originated from PID `x`. 0 means the request originated from the kernel. + // + // Every line is prefixed with either `rx ` (receive from kernel) or `tx ` (send to kernel) + // + // Example debug log output: + // + // rx 2: LOOKUP i1 [".wcfs"] 6b p5874 + // tx 2: OK, {i3 g2 tE=1s tA=1s {M040755 SZ=0 L=0 1000:1000 B0*0 i0:3 A 0.000000 M 0.000000 C 0.000000}} + // rx 3: LOOKUP i3 ["zurl"] 5b p5874 + // tx 3: OK, {i4 g3 tE=1s tA=1s {M0100644 SZ=33 L=1 1000:1000 B0*0 i0:4 A 0.000000 M 0.000000 C 0.000000}} + // rx 4: OPEN i4 {O_RDONLY,0x8000} p5874 + // tx 4: 38=function not implemented, {Fh 0 } + // rx 5: READ i4 {Fh 0 [0 +4096) L 0 RDONLY,0x8000} p5874 + // tx 5: OK, 33b data "file:///"... + // rx 6: GETATTR i4 {Fh 0} p5874 + // tx 6: OK, {tA=1s {M0100644 SZ=33 L=1 1000:1000 B0*0 i0:4 A 0.000000 M 0.000000 C 0.000000}} + // rx 7: FLUSH i4 {Fh 0} p5874 + // tx 7: OK + // rx 8: LOOKUP i1 ["head"] 5b p5874 + // tx 8: OK, {i5 g4 tE=1s tA=1s {M040755 SZ=0 L=0 1000:1000 B0*0 i0:5 A 0.000000 M 0.000000 C 0.000000}} + // rx 9: LOOKUP i5 ["bigfile"] 8b p5874 + // tx 9: OK, {i6 g5 tE=1s tA=1s {M040755 SZ=0 L=0 1000:1000 B0*0 i0:6 A 0.000000 M 0.000000 C 0.000000}} + // rx 10: FLUSH i4 {Fh 0} p5874 + // tx 10: OK + // rx 11: GETATTR i1 {Fh 0} p5874 + // tx 11: OK, {tA=1s {M040755 SZ=0 L=1 1000:1000 B0*0 i0:1 A 0.000000 M 0.000000 C 0.000000}} + Logger *log.Logger + + // EnableLocks, if set, asks the kernel to forward file locks to FUSE + // When used, you must implement the GetLk/SetLk/SetLkw methods. + EnableLocks bool + + // EnableSymlinkCaching, if set, makes the kernel cache all Readlink return values. + // The filesystem must use content notification to force the + // kernel to issue a new Readlink call. + EnableSymlinkCaching bool + + // ExplicitDataCacheControl, if set, asks the kernel not to do automatic + // data cache invalidation. The filesystem is fully responsible for + // invalidating data cache. + ExplicitDataCacheControl bool + + // SyncRead disables the CAP_ASYNC_READ capability. The + // kernel then only sends one read request per file handle at + // a time, and orders the requests by offset. This is useful + // if reading out of order or concurrently is expensive for + // (example: Amazon Cloud Drive). + // + // If unset, multiple concurrent reads may be issued to + // service userspace requests and kernel readahead. + // + // See the comment to FUSE_CAP_ASYNC_READ in + // https://github.com/libfuse/libfuse/blob/master/include/fuse_common.h + // for more details. + SyncRead bool + + // DirectMount, if set, makes go-fuse first attempt to use syscall.Mount instead of + // fusermount to mount the filesystem. This will not update /etc/mtab + // but might be needed if fusermount is not available. + // Also, Server.Unmount will attempt syscall.Unmount before calling + // fusermount. + DirectMount bool + + // DirectMountStrict, if set, is like DirectMount but no fallback to fusermount is + // performed. If both DirectMount and DirectMountStrict are set, + // DirectMountStrict wins. + DirectMountStrict bool + + // DirectMountFlags are the mountflags passed to syscall.Mount. If zero, the + // default value used by fusermount are used: syscall.MS_NOSUID|syscall.MS_NODEV. + // + // If you actually *want* zero flags, pass syscall.MS_MGC_VAL, which is ignored + // by the kernel. See `man 2 mount` for details about MS_MGC_VAL. + DirectMountFlags uintptr + + // EnableAcl, if set, enables kernel ACL support. + // + // See the comments to FUSE_CAP_POSIX_ACL + // in https://github.com/libfuse/libfuse/blob/master/include/fuse_common.h + // for details. + EnableAcl bool + + // DisableReadDirPlus, if set, disables the ReadDirPlus capability so + // ReadDir is used instead. Simple directory queries (i.e. 'ls' without + // '-l') can be faster with ReadDir, as no per-file stat calls are needed. + DisableReadDirPlus bool + + // DisableSplice, if set, disables splicing from files to the FUSE device. + DisableSplice bool + + // PanicHandler is called if an FS routine panics. The handler + // should return a nonzero status. If not set, the default is + // to print a stack trace and return EIO. + PanicHandler func(any) Status + + // MaxStackDepth is the maximum stacking depth for passthrough files. + // If unset, the default is 1. + MaxStackDepth int + + // IDMappedMount, if set, enables an ID-mapped mount if the Kernel supports + // it. + // + // An ID-mapped mount allows the device to be mounted on the system with the + // IDs remapped (via mount_setattr, move_mount syscalls) to those of the + // user on the local system. + // + // Enabling this flag automatically sets the "default_permissions" mount + // option. This is required by FUSE to delegate the UID/GID-based permission + // checks to the kernel. For requests that create new inodes, FUSE will send + // the mapped UID/GIDs. For all other requests, FUSE will send "-1". + IDMappedMount bool + + // DisabledCapabilities is a bitmask, containing capablities + // (the CAP_* bitmasks) that must be disabled for the entire + // mount. + DisabledCapabilities uint64 + + // ExtraCapabilities is a bitmask of capabilities which + // must be enabled in addition to the defaults. + ExtraCapabilities uint64 +} + +// RawFileSystem is an interface close to the FUSE wire protocol. +// +// Unless you really know what you are doing, you should not implement +// this, but rather the interfaces associated with +// fs.InodeEmbedder. The details of getting interactions with open +// files, renames, and threading right etc. are somewhat tricky and +// not very interesting. +// +// Each FUSE request results in a corresponding method called by Server. +// Several calls may be made simultaneously, because the server typically calls +// each method in separate goroutine. +// +// A null implementation is provided by NewDefaultRawFileSystem. +// +// After a successful FUSE API call returns, you may not read input or +// write output data: for performance reasons, memory is reused for +// following requests, and reading/writing the request data will lead +// to race conditions. If you spawn a background routine from a FUSE +// API call, any incoming request data it wants to reference should be +// copied over. +// +// If a FS operation is interrupted, the `cancel` channel is +// closed. The fileystem can honor this request by returning EINTR. In +// this case, the outstanding request data is not reused. Interrupts +// occur if the process accessing the file system receives any signal +// that is not ignored. In particular, the Go runtime uses signals to +// manage goroutine preemption, so Go programs under load naturally +// generate interupt opcodes when they access a FUSE filesystem. +// ReplyAttemptObserver is called after the server has attempted to write a +// request's reply to the FUSE device. A failed write is still an attempt. +// Requests whose replies are suppressed do not trigger this callback. +type ReplyAttemptObserver interface { + OnReplyAttempt(unique uint64, opcode uint32, replyStatus Status, writeStatus Status) +} + +type RawFileSystem interface { + String() string + + // If called, provide debug output through the log package. + SetDebug(debug bool) + + // Lookup is called by the kernel when the VFS wants to know + // about a file inside a directory. Many lookup calls can + // occur in parallel, but only one call happens for each (dir, + // name) pair. + Lookup(cancel <-chan struct{}, header *InHeader, name string, out *EntryOut) (status Status) + + // Forget is called when the kernel discards entries from its + // dentry cache. This happens on unmount, and when the kernel + // is short on memory. Since it is not guaranteed to occur at + // any moment, and since there is no return value, Forget + // should not do I/O, as there is no channel to report back + // I/O errors. + Forget(nodeid, nlookup uint64) + + // Attributes. + GetAttr(cancel <-chan struct{}, input *GetAttrIn, out *AttrOut) (code Status) + SetAttr(cancel <-chan struct{}, input *SetAttrIn, out *AttrOut) (code Status) + + // Modifying structure. + Mknod(cancel <-chan struct{}, input *MknodIn, name string, out *EntryOut) (code Status) + Mkdir(cancel <-chan struct{}, input *MkdirIn, name string, out *EntryOut) (code Status) + Unlink(cancel <-chan struct{}, header *InHeader, name string) (code Status) + Rmdir(cancel <-chan struct{}, header *InHeader, name string) (code Status) + Rename(cancel <-chan struct{}, input *RenameIn, oldName string, newName string) (code Status) + Link(cancel <-chan struct{}, input *LinkIn, filename string, out *EntryOut) (code Status) + + Symlink(cancel <-chan struct{}, header *InHeader, pointedTo string, linkName string, out *EntryOut) (code Status) + Readlink(cancel <-chan struct{}, header *InHeader) (out []byte, code Status) + Access(cancel <-chan struct{}, input *AccessIn) (code Status) + + // Extended attributes. + + // GetXAttr reads an extended attribute, and should return the + // number of bytes. If the buffer is too small, return ERANGE, + // with the required buffer size. + GetXAttr(cancel <-chan struct{}, header *InHeader, attr string, dest []byte) (sz uint32, code Status) + + // ListXAttr lists extended attributes as '\0' delimited byte + // slice, and return the number of bytes. If the buffer is too + // small, return ERANGE, with the required buffer size. + ListXAttr(cancel <-chan struct{}, header *InHeader, dest []byte) (uint32, Status) + + // SetAttr writes an extended attribute. + SetXAttr(cancel <-chan struct{}, input *SetXAttrIn, attr string, data []byte) Status + + // RemoveXAttr removes an extended attribute. + RemoveXAttr(cancel <-chan struct{}, header *InHeader, attr string) (code Status) + + // File handling. + Create(cancel <-chan struct{}, input *CreateIn, name string, out *CreateOut) (code Status) + Tmpfile(cancel <-chan struct{}, input *CreateIn, out *CreateOut) (code Status) + Open(cancel <-chan struct{}, input *OpenIn, out *OpenOut) (status Status) + Read(cancel <-chan struct{}, input *ReadIn, buf []byte) (ReadResult, Status) + Lseek(cancel <-chan struct{}, in *LseekIn, out *LseekOut) Status + + // File locking + GetLk(cancel <-chan struct{}, input *LkIn, out *LkOut) (code Status) + SetLk(cancel <-chan struct{}, input *LkIn) (code Status) + SetLkw(cancel <-chan struct{}, input *LkIn) (code Status) + + Release(cancel <-chan struct{}, input *ReleaseIn) + Write(cancel <-chan struct{}, input *WriteIn, data []byte) (written uint32, code Status) + + // Writev is called for WRITE. On ENOSYS, Write is called. + Writev(cancel <-chan struct{}, input *WriteIn, data [][]byte) (written uint32, code Status) + CopyFileRange(cancel <-chan struct{}, input *CopyFileRangeIn) (written uint32, code Status) + Ioctl(cancel <-chan struct{}, input *IoctlIn, inbuf []byte, output *IoctlOut, outbuf []byte) (code Status) + + Flush(cancel <-chan struct{}, input *FlushIn) Status + Fsync(cancel <-chan struct{}, input *FsyncIn) (code Status) + Fallocate(cancel <-chan struct{}, input *FallocateIn) (code Status) + + // Directory handling + OpenDir(cancel <-chan struct{}, input *OpenIn, out *OpenOut) (status Status) + ReadDir(cancel <-chan struct{}, input *ReadIn, out *DirEntryList) Status + ReadDirPlus(cancel <-chan struct{}, input *ReadIn, out *DirEntryList) Status + ReleaseDir(input *ReleaseIn) + FsyncDir(cancel <-chan struct{}, input *FsyncIn) (code Status) + + StatFs(cancel <-chan struct{}, input *InHeader, out *StatfsOut) (code Status) + + Statx(cancel <-chan struct{}, input *StatxIn, out *StatxOut) (code Status) + // This is called on processing the first request. The + // filesystem implementation can use the server argument to + // talk back to the kernel (through notify methods). + Init(*Server) + + // Called after processing the last request. + OnUnmount() +} diff --git a/third_party/go-fuse/fuse/attr.go b/third_party/go-fuse/fuse/attr.go new file mode 100644 index 000000000..99d171593 --- /dev/null +++ b/third_party/go-fuse/fuse/attr.go @@ -0,0 +1,79 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "os" + "syscall" + "time" +) + +func (a *Attr) IsFifo() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFIFO } + +// IsChar reports whether the FileInfo describes a character special file. +func (a *Attr) IsChar() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFCHR } + +// IsDir reports whether the FileInfo describes a directory. +func (a *Attr) IsDir() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFDIR } + +// IsBlock reports whether the FileInfo describes a block special file. +func (a *Attr) IsBlock() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFBLK } + +// IsRegular reports whether the FileInfo describes a regular file. +func (a *Attr) IsRegular() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFREG } + +// IsSymlink reports whether the FileInfo describes a symbolic link. +func (a *Attr) IsSymlink() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFLNK } + +// IsSocket reports whether the FileInfo describes a socket. +func (a *Attr) IsSocket() bool { return (uint32(a.Mode) & syscall.S_IFMT) == syscall.S_IFSOCK } + +func (a *Attr) SetTimes(access *time.Time, mod *time.Time, chstatus *time.Time) { + if access != nil { + a.Atime = uint64(access.Unix()) + a.Atimensec = uint32(access.Nanosecond()) + } + if mod != nil { + a.Mtime = uint64(mod.Unix()) + a.Mtimensec = uint32(mod.Nanosecond()) + } + if chstatus != nil { + a.Ctime = uint64(chstatus.Unix()) + a.Ctimensec = uint32(chstatus.Nanosecond()) + } +} + +func (a *Attr) ChangeTime() time.Time { + return time.Unix(int64(a.Ctime), int64(a.Ctimensec)) +} + +func (a *Attr) AccessTime() time.Time { + return time.Unix(int64(a.Atime), int64(a.Atimensec)) +} + +func (a *Attr) ModTime() time.Time { + return time.Unix(int64(a.Mtime), int64(a.Mtimensec)) +} + +func ToStatT(f os.FileInfo) *syscall.Stat_t { + s, _ := f.Sys().(*syscall.Stat_t) + if s != nil { + return s + } + return nil +} + +func ToAttr(f os.FileInfo) *Attr { + if f == nil { + return nil + } + s := ToStatT(f) + if s != nil { + a := &Attr{} + a.FromStat(s) + return a + } + return nil +} diff --git a/third_party/go-fuse/fuse/attr_linux.go b/third_party/go-fuse/fuse/attr_linux.go new file mode 100644 index 000000000..afcf21720 --- /dev/null +++ b/third_party/go-fuse/fuse/attr_linux.go @@ -0,0 +1,51 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +func (a *Attr) FromStat(s *syscall.Stat_t) { + a.Ino = uint64(s.Ino) + a.Size = uint64(s.Size) + a.Blocks = uint64(s.Blocks) + a.Atime = uint64(s.Atim.Sec) + a.Atimensec = uint32(s.Atim.Nsec) + a.Mtime = uint64(s.Mtim.Sec) + a.Mtimensec = uint32(s.Mtim.Nsec) + a.Ctime = uint64(s.Ctim.Sec) + a.Ctimensec = uint32(s.Ctim.Nsec) + a.Mode = s.Mode + a.Nlink = uint32(s.Nlink) + a.Uid = uint32(s.Uid) + a.Gid = uint32(s.Gid) + a.Rdev = uint32(s.Rdev) + a.Blksize = uint32(s.Blksize) +} + +func (a *Statx) FromStatx(s *unix.Statx_t) { + a.Ino = uint64(s.Ino) + a.Size = uint64(s.Size) + a.Blocks = uint64(s.Blocks) + a.Atime.FromStatxTimestamp(&s.Atime) + a.Btime.FromStatxTimestamp(&s.Btime) + a.Ctime.FromStatxTimestamp(&s.Ctime) + a.Mtime.FromStatxTimestamp(&s.Mtime) + a.Mode = s.Mode + a.Nlink = uint32(s.Nlink) + a.Uid = uint32(s.Uid) + a.Gid = uint32(s.Gid) + a.Blksize = uint32(s.Blksize) + a.AttributesMask = s.Attributes_mask + a.Mask = s.Mask + a.Attributes = s.Attributes + a.RdevMinor = s.Rdev_minor + a.RdevMajor = s.Rdev_major + a.DevMajor = s.Dev_major + a.DevMinor = s.Dev_minor +} diff --git a/third_party/go-fuse/fuse/attr_unix.go b/third_party/go-fuse/fuse/attr_unix.go new file mode 100644 index 000000000..4b479fd6a --- /dev/null +++ b/third_party/go-fuse/fuse/attr_unix.go @@ -0,0 +1,29 @@ +//go:build !linux + +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" +) + +func (a *Attr) FromStat(s *syscall.Stat_t) { + a.Ino = uint64(s.Ino) + a.Size = uint64(s.Size) + a.Blocks = uint64(s.Blocks) + a.Atime = uint64(s.Atimespec.Sec) + a.Atimensec = uint32(s.Atimespec.Nsec) + a.Mtime = uint64(s.Mtimespec.Sec) + a.Mtimensec = uint32(s.Mtimespec.Nsec) + a.Ctime = uint64(s.Ctimespec.Sec) + a.Ctimensec = uint32(s.Ctimespec.Nsec) + a.Mode = uint32(s.Mode) + a.Nlink = uint32(s.Nlink) + a.Uid = uint32(s.Uid) + a.Gid = uint32(s.Gid) + a.Rdev = uint32(s.Rdev) + a.Blksize = uint32(s.Blksize) +} diff --git a/third_party/go-fuse/fuse/bufferpool.go b/third_party/go-fuse/fuse/bufferpool.go new file mode 100644 index 000000000..f3350d97b --- /dev/null +++ b/third_party/go-fuse/fuse/bufferpool.go @@ -0,0 +1,79 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "os" + "sync" +) + +// bufferPool implements explicit memory management. It is used for +// minimizing the GC overhead of communicating with the kernel. +type bufferPool struct { + lock sync.Mutex + + // For each page size multiple a list of slice pointers. + buffersBySize []*sync.Pool + + // Number of outstanding allocations. Used for testing. + countersBySize []int +} + +var pageSize = os.Getpagesize() + +func (p *bufferPool) counters() []int { + p.lock.Lock() + defer p.lock.Unlock() + + d := make([]int, len(p.countersBySize)) + copy(d, p.countersBySize) + return d +} + +func (p *bufferPool) getPool(pageCount int, delta int) *sync.Pool { + p.lock.Lock() + defer p.lock.Unlock() + for len(p.buffersBySize) <= pageCount { + p.buffersBySize = append(p.buffersBySize, nil) + p.countersBySize = append(p.countersBySize, 0) + } + if p.buffersBySize[pageCount] == nil { + p.buffersBySize[pageCount] = &sync.Pool{ + New: func() any { return make([]byte, pageSize*pageCount) }, + } + } + p.countersBySize[pageCount] += delta + return p.buffersBySize[pageCount] +} + +// AllocBuffer creates a buffer of at least the given size. After use, +// it should be deallocated with FreeBuffer(). +func (p *bufferPool) AllocBuffer(size uint32) []byte { + sz := max(int(size), pageSize) + + if sz%pageSize != 0 { + sz += pageSize + } + pages := sz / pageSize + + b := p.getPool(pages, 1).Get().([]byte) + return b[:size] +} + +// FreeBuffer takes back a buffer if it was allocated through +// AllocBuffer. It is not an error to call FreeBuffer() on a slice +// obtained elsewhere. +func (p *bufferPool) FreeBuffer(slice []byte) { + if slice == nil { + return + } + if cap(slice)%pageSize != 0 || cap(slice) == 0 { + return + } + pages := cap(slice) / pageSize + slice = slice[:cap(slice)] + + p.getPool(pages, -1).Put(slice) +} diff --git a/third_party/go-fuse/fuse/constants.go b/third_party/go-fuse/fuse/constants.go new file mode 100644 index 000000000..06d040231 --- /dev/null +++ b/third_party/go-fuse/fuse/constants.go @@ -0,0 +1,44 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "os" + "syscall" +) + +const ( + FUSE_ROOT_ID = 1 + + FUSE_UNKNOWN_INO = 0xffffffff + + CUSE_UNRESTRICTED_IOCTL = (1 << 0) + + FUSE_LK_FLOCK = (1 << 0) + + FUSE_RELEASE_FLUSH = (1 << 0) + FUSE_RELEASE_FLOCK_UNLOCK = (1 << 1) + + FUSE_IOCTL_MAX_IOV = 256 + + FUSE_POLL_SCHEDULE_NOTIFY = (1 << 0) + + CUSE_INIT_INFO_MAX = 4096 + + S_IFDIR = syscall.S_IFDIR + S_IFREG = syscall.S_IFREG + S_IFLNK = syscall.S_IFLNK + S_IFIFO = syscall.S_IFIFO + + CUSE_INIT = 4096 + + O_ANYWRITE = uint32(os.O_WRONLY | os.O_RDWR | os.O_APPEND | os.O_CREATE | os.O_TRUNC) + + // FMODE_EXEC is a passed to OPEN requests if the file is + // being executed. + FMODE_EXEC = 0x20 + + logicalBlockSize = 4096 +) diff --git a/third_party/go-fuse/fuse/constants_freebsd.go b/third_party/go-fuse/fuse/constants_freebsd.go new file mode 100644 index 000000000..5fb606919 --- /dev/null +++ b/third_party/go-fuse/fuse/constants_freebsd.go @@ -0,0 +1,9 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +// arbitrary values +const syscall_O_LARGEFILE = 1 << 29 +const syscall_O_NOATIME = 1 << 30 diff --git a/third_party/go-fuse/fuse/constants_linux.go b/third_party/go-fuse/fuse/constants_linux.go new file mode 100644 index 000000000..29455e4f6 --- /dev/null +++ b/third_party/go-fuse/fuse/constants_linux.go @@ -0,0 +1,12 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" +) + +const syscall_O_LARGEFILE = syscall.O_LARGEFILE +const syscall_O_NOATIME = syscall.O_NOATIME diff --git a/third_party/go-fuse/fuse/context.go b/third_party/go-fuse/fuse/context.go new file mode 100644 index 000000000..f8e6edf4e --- /dev/null +++ b/third_party/go-fuse/fuse/context.go @@ -0,0 +1,61 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "context" + "time" +) + +// Context passes along cancelation signal and request data (PID, GID, +// UID). The name of this class predates the standard "context" +// package from Go, but it does implement the context.Context +// interface. +// +// When a FUSE request is canceled, and the file system chooses to honor +// the cancellation, the response should be EINTR. +type Context struct { + Caller + Cancel <-chan struct{} +} + +func (c *Context) Deadline() (time.Time, bool) { + return time.Time{}, false +} + +func (c *Context) Done() <-chan struct{} { + return c.Cancel +} + +func (c *Context) Err() error { + select { + case <-c.Cancel: + return context.Canceled + default: + return nil + } +} + +type callerKeyType struct{} + +var callerKey callerKeyType + +func FromContext(ctx context.Context) (*Caller, bool) { + v, ok := ctx.Value(callerKey).(*Caller) + return v, ok +} + +func NewContext(ctx context.Context, caller *Caller) context.Context { + return context.WithValue(ctx, callerKey, caller) +} + +func (c *Context) Value(key any) any { + if key == callerKey { + return &c.Caller + } + return nil +} + +var _ = context.Context((*Context)(nil)) diff --git a/third_party/go-fuse/fuse/defaultraw.go b/third_party/go-fuse/fuse/defaultraw.go new file mode 100644 index 000000000..8d5882cc3 --- /dev/null +++ b/third_party/go-fuse/fuse/defaultraw.go @@ -0,0 +1,188 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "os" +) + +// NewDefaultRawFileSystem returns ENOSYS (not implemented) for all +// operations. +func NewDefaultRawFileSystem() RawFileSystem { + return (*defaultRawFileSystem)(nil) +} + +type defaultRawFileSystem struct{} + +func (fs *defaultRawFileSystem) Init(*Server) { +} + +func (fs *defaultRawFileSystem) OnUnmount() { + +} + +func (fs *defaultRawFileSystem) String() string { + return os.Args[0] +} + +func (fs *defaultRawFileSystem) SetDebug(dbg bool) { +} + +func (fs *defaultRawFileSystem) StatFs(cancel <-chan struct{}, header *InHeader, out *StatfsOut) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Lookup(cancel <-chan struct{}, header *InHeader, name string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Forget(nodeID, nlookup uint64) { +} + +func (fs *defaultRawFileSystem) GetAttr(cancel <-chan struct{}, input *GetAttrIn, out *AttrOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Open(cancel <-chan struct{}, input *OpenIn, out *OpenOut) (status Status) { + return OK +} + +func (fs *defaultRawFileSystem) SetAttr(cancel <-chan struct{}, input *SetAttrIn, out *AttrOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Readlink(cancel <-chan struct{}, header *InHeader) (out []byte, code Status) { + return nil, ENOSYS +} + +func (fs *defaultRawFileSystem) Mknod(cancel <-chan struct{}, input *MknodIn, name string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Mkdir(cancel <-chan struct{}, input *MkdirIn, name string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Unlink(cancel <-chan struct{}, header *InHeader, name string) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Rmdir(cancel <-chan struct{}, header *InHeader, name string) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Symlink(cancel <-chan struct{}, header *InHeader, pointedTo string, linkName string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Rename(cancel <-chan struct{}, input *RenameIn, oldName string, newName string) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Link(cancel <-chan struct{}, input *LinkIn, name string, out *EntryOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) GetXAttr(cancel <-chan struct{}, header *InHeader, attr string, dest []byte) (size uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) SetXAttr(cancel <-chan struct{}, input *SetXAttrIn, attr string, data []byte) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) ListXAttr(cancel <-chan struct{}, header *InHeader, dest []byte) (n uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) RemoveXAttr(cancel <-chan struct{}, header *InHeader, attr string) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Access(cancel <-chan struct{}, input *AccessIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Create(cancel <-chan struct{}, input *CreateIn, name string, out *CreateOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Tmpfile(cancel <-chan struct{}, input *CreateIn, out *CreateOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) OpenDir(cancel <-chan struct{}, input *OpenIn, out *OpenOut) (status Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Read(cancel <-chan struct{}, input *ReadIn, buf []byte) (ReadResult, Status) { + return nil, ENOSYS +} + +func (fs *defaultRawFileSystem) GetLk(cancel <-chan struct{}, in *LkIn, out *LkOut) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) SetLk(cancel <-chan struct{}, in *LkIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) SetLkw(cancel <-chan struct{}, in *LkIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Release(cancel <-chan struct{}, input *ReleaseIn) { +} + +func (fs *defaultRawFileSystem) Write(cancel <-chan struct{}, input *WriteIn, data []byte) (written uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) Writev(cancel <-chan struct{}, input *WriteIn, data [][]byte) (written uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) Flush(cancel <-chan struct{}, input *FlushIn) Status { + return OK +} + +func (fs *defaultRawFileSystem) Fsync(cancel <-chan struct{}, input *FsyncIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) ReadDir(cancel <-chan struct{}, input *ReadIn, l *DirEntryList) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) ReadDirPlus(cancel <-chan struct{}, input *ReadIn, l *DirEntryList) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) ReleaseDir(input *ReleaseIn) { +} + +func (fs *defaultRawFileSystem) FsyncDir(cancel <-chan struct{}, input *FsyncIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Fallocate(cancel <-chan struct{}, in *FallocateIn) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) CopyFileRange(cancel <-chan struct{}, input *CopyFileRangeIn) (written uint32, code Status) { + return 0, ENOSYS +} + +func (fs *defaultRawFileSystem) Ioctl(cancel <-chan struct{}, input *IoctlIn, inbuf []byte, output *IoctlOut, outbuf []byte) (code Status) { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Lseek(cancel <-chan struct{}, in *LseekIn, out *LseekOut) Status { + return ENOSYS +} + +func (fs *defaultRawFileSystem) Statx(cancel <-chan struct{}, input *StatxIn, out *StatxOut) (code Status) { + return ENOSYS +} diff --git a/third_party/go-fuse/fuse/direntry.go b/third_party/go-fuse/fuse/direntry.go new file mode 100644 index 000000000..50052ff22 --- /dev/null +++ b/third_party/go-fuse/fuse/direntry.go @@ -0,0 +1,264 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "unsafe" +) + +const direntSize = int(unsafe.Sizeof(_Dirent{})) + +// DirEntry is a type for PathFileSystem and NodeFileSystem to return +// directory contents in. +type DirEntry struct { + // Mode is the file's mode. Only the high bits (eg. S_IFDIR) + // are considered. + Mode uint32 + + // Name is the basename of the file in the directory. + Name string + + // Ino is the inode number. + Ino uint64 + + // Off is the offset in the directory stream. The offset is + // thought to be after the entry. + Off uint64 +} + +func (d *DirEntry) String() string { + return fmt.Sprintf("%d: %q ino=%d (%o)", d.Off, d.Name, d.Ino, d.Mode) +} + +// Parse reads an entry from getdents(2) buffer. It returns the number +// of bytes consumed. +func (d *DirEntry) Parse(buf []byte) int { + // We can't use syscall.Dirent here, because it declares a + // [256]byte name, which may run beyond the end of ds.todo. + // when that happens in the race detector, it causes a panic + // "converted pointer straddles multiple allocations" + de := (*dirent)(unsafe.Pointer(&buf[0])) + off := unsafe.Offsetof(dirent{}.Name) + nameBytes := buf[off : off+uintptr(de.nameLength())] + n := de.Reclen + + l := bytes.IndexByte(nameBytes, 0) + if l >= 0 { + nameBytes = nameBytes[:l] + } + *d = DirEntry{ + Ino: de.Ino, + Mode: (uint32(de.Type) << 12), + Name: string(nameBytes), + Off: uint64(de.Off), + } + return int(n) +} + +// DirEntryList holds the return value for READDIR and READDIRPLUS +// opcodes. +type DirEntryList struct { + buf []byte + // capacity of the underlying buffer + size int + + // Offset holds the offset for the next entry to be added. It + // is the offset supplied at construction time, or the Offset + // of the last DirEntry that was added. + Offset uint64 + + // pointer to the last serialized _Dirent. Used by FixMode(). + lastDirent *_Dirent + + readdirPlus bool +} + +func (l *DirEntryList) setReaddirPlus(plus bool) { + if len(l.buf) > 0 && l.readdirPlus != plus { + panic("DirEntryList: cannot mix AddDirEntry and AddDirLookupEntry") + } + l.readdirPlus = plus +} + +// NewDirEntryList creates a DirEntryList with the given data buffer +// and offset. +func NewDirEntryList(data []byte, off uint64) *DirEntryList { + return &DirEntryList{ + buf: data[:0], + size: len(data), + Offset: off, + } +} + +// AddDirEntry tries to add an entry, and reports whether it +// succeeded. If adding a 0 offset entry, the offset is taken to be +// the last offset + 1. +func (l *DirEntryList) AddDirEntry(e DirEntry) bool { + // TODO: take pointer arg, merge with AddDirLookupEntry. + l.setReaddirPlus(false) + return l.addDirEntry(&e, 0) +} + +func (l *DirEntryList) addDirEntry(e *DirEntry, prefix int) bool { + if e.Ino == 0 { + e.Ino = FUSE_UNKNOWN_INO + } + if e.Off == 0 { + e.Off = l.Offset + 1 + } + padding := (8 - len(e.Name)&7) & 7 + delta := padding + direntSize + len(e.Name) + prefix + oldLen := len(l.buf) + newLen := delta + oldLen + + if newLen > l.size { + return false + } + l.buf = l.buf[:newLen] + oldLen += prefix + dirent := (*_Dirent)(unsafe.Pointer(&l.buf[oldLen])) + dirent.Off = e.Off + dirent.Ino = e.Ino + dirent.NameLen = uint32(len(e.Name)) + dirent.Typ = modeToType(e.Mode) + oldLen += direntSize + copy(l.buf[oldLen:], e.Name) + oldLen += len(e.Name) + + // The buffer is recycled, so the padding may hold data from + // a previous request. + clear(l.buf[oldLen : oldLen+padding]) + l.Offset = dirent.Off + return true +} + +// Add adds a direntry to the DirEntryList, returning wheither it +// succeeded. Prefix is the amount of padding to add before the DirEntry. +// +// Deprecated: use AddDirLookupEntry or AddDirEntry. +func (l *DirEntryList) Add(prefix int, name string, inode uint64, mode uint32) bool { + // TODO: remove. + e := DirEntry{ + Name: name, + Mode: mode, + Off: l.Offset + 1, + Ino: inode, + } + l.setReaddirPlus(prefix != 0) + return l.addDirEntry(&e, prefix) +} + +// AddDirLookupEntry is used for ReadDirPlus. If reserves and zeroes +// space for an EntryOut struct and serializes the DirEntry. If adding +// a 0 offset entry, the offset is taken to be the last offset + 1. +// If the entry does not fit, it returns nil. +func (l *DirEntryList) AddDirLookupEntry(e DirEntry) *EntryOut { + // The resulting READDIRPLUS output buffer looks like this in memory: + // 1) EntryOut{} + // 2) _Dirent{} + // 3) Name (null-terminated) + // 4) Padding to align to 8 bytes + // [repeat] + + // TODO: should take pointer as argument. + l.setReaddirPlus(true) + oldLen := len(l.buf) + ok := l.addDirEntry(&e, entryOutSize) + if !ok { + return nil + } + l.lastDirent = (*_Dirent)(unsafe.Pointer(&l.buf[oldLen+entryOutSize])) + entryOut := (*EntryOut)(unsafe.Pointer(&l.buf[oldLen])) + *entryOut = EntryOut{} + return entryOut +} + +// modeToType converts a file *mode* (as used in syscall.Stat_t.Mode) +// to a file *type* (as used in _Dirent.Typ). +// Equivalent to IFTODT() in libc (see man 5 dirent). +func modeToType(mode uint32) uint32 { + return (mode & 0170000) >> 12 +} + +// FixMode overrides the file mode of the last direntry that was added. This can +// be needed when a directory changes while READDIRPLUS is running. +// Only the file type bits of mode are considered, the rest is masked out. +func (l *DirEntryList) FixMode(mode uint32) { + if l.lastDirent == nil { + return + } + l.lastDirent.Typ = modeToType(mode) +} + +// Bytes returns the serialized entries. +func (l *DirEntryList) Bytes() []byte { + return l.buf +} + +const entryOutSize = int(unsafe.Sizeof(EntryOut{})) + +// ParseDirEntries parses READDIR output. +func ParseDirEntries(buf []byte) ([]DirEntry, error) { + var result []DirEntry + for len(buf) > 0 { + var e DirEntry + n, err := parseDirent(buf, &e) + if err != nil { + return nil, err + } + result = append(result, e) + buf = buf[n:] + } + return result, nil +} + +// ParseDirLookupEntries parses READDIRPLUS output. +func ParseDirLookupEntries(buf []byte) ([]DirEntry, []EntryOut, error) { + var entries []DirEntry + var outs []EntryOut + for len(buf) > 0 { + if len(buf) < entryOutSize { + return nil, nil, fmt.Errorf("short EntryOut: %d bytes", len(buf)) + } + var out EntryOut + copy(unsafe.Slice((*byte)(unsafe.Pointer(&out)), entryOutSize), buf) + buf = buf[entryOutSize:] + + var e DirEntry + n, err := parseDirent(buf, &e) + if err != nil { + return nil, nil, err + } + entries = append(entries, e) + outs = append(outs, out) + buf = buf[n:] + } + return entries, outs, nil +} + +func parseDirent(buf []byte, e *DirEntry) (int, error) { + if len(buf) < direntSize { + return 0, fmt.Errorf("short dirent: %d bytes", len(buf)) + } + var d _Dirent + copy(unsafe.Slice((*byte)(unsafe.Pointer(&d)), direntSize), buf) + nameEnd := direntSize + int(d.NameLen) + if nameEnd > len(buf) { + return 0, fmt.Errorf("dirent name length %d exceeds buffer (%d bytes)", d.NameLen, len(buf)) + } + n := (nameEnd + 7) &^ 7 + if n > len(buf) { + return 0, fmt.Errorf("dirent padding exceeds buffer: need %d, have %d bytes", n, len(buf)) + } + *e = DirEntry{ + Ino: d.Ino, + Off: d.Off, + Mode: d.Typ << 12, + Name: string(buf[direntSize:nameEnd]), + } + return n, nil +} diff --git a/third_party/go-fuse/fuse/direntry_darwin.go b/third_party/go-fuse/fuse/direntry_darwin.go new file mode 100644 index 000000000..1da5b004c --- /dev/null +++ b/third_party/go-fuse/fuse/direntry_darwin.go @@ -0,0 +1,19 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +// Like syscall.Dirent, but without the [256]byte name. +type dirent struct { + Ino uint64 + Off int64 + Reclen uint16 + Namlen uint16 + Type uint8 + Name [1]uint8 // align to 4 bytes for 32 bits. +} + +func (de *dirent) nameLength() int { + return int(de.Namlen) +} diff --git a/third_party/go-fuse/fuse/direntry_freebsd.go b/third_party/go-fuse/fuse/direntry_freebsd.go new file mode 100644 index 000000000..25951e1a8 --- /dev/null +++ b/third_party/go-fuse/fuse/direntry_freebsd.go @@ -0,0 +1,20 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +type dirent struct { + Ino uint64 + Off int64 + Reclen uint16 + Type uint8 + Pad0 uint8 + Namlen uint16 + Pad1 uint16 + Name [1]int8 +} + +func (de *dirent) nameLength() int { + return int(de.Namlen) +} diff --git a/third_party/go-fuse/fuse/direntry_linux.go b/third_party/go-fuse/fuse/direntry_linux.go new file mode 100644 index 000000000..82d5a17c1 --- /dev/null +++ b/third_party/go-fuse/fuse/direntry_linux.go @@ -0,0 +1,22 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import "unsafe" + +// Like syscall.Dirent, but without the [256]byte name. This is +// equivalent to the linux_dirent64 struct, returned from the +// getdents64 syscall. +type dirent struct { + Ino uint64 + Off int64 + Reclen uint16 + Type uint8 + Name [1]uint8 // align to 4 bytes for 32 bits. +} + +func (de *dirent) nameLength() int { + return int(de.Reclen) - int(unsafe.Offsetof(dirent{}.Name)) +} diff --git a/third_party/go-fuse/fuse/ext.go b/third_party/go-fuse/fuse/ext.go new file mode 100644 index 000000000..9b199e14d --- /dev/null +++ b/third_party/go-fuse/fuse/ext.go @@ -0,0 +1,114 @@ +// Copyright 2026 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "encoding/binary" + "fmt" + "strings" +) + +// Request extensions; see struct fuse_ext_header in linux/fuse.h. +const ( + _MAX_NR_SECCTX = 31 + _EXT_GROUPS = 32 + + sizeOfExtHeader = 8 + sizeOfSecCtx = 8 +) + +type secCtx struct { + name []byte + value []byte +} + +type requestExt struct { + suppGroups []uint32 + secctx []secCtx +} + +func (e *requestExt) String() string { + var parts []string + if len(e.suppGroups) > 0 { + parts = append(parts, fmt.Sprintf("groups=%v", e.suppGroups)) + } + if len(e.secctx) > 0 { + var ctxs []string + for _, c := range e.secctx { + ctxs = append(ctxs, fmt.Sprintf("%s:%q", c.name, bytes.TrimRight(c.value, "\x00"))) + } + parts = append(parts, "secctx=["+strings.Join(ctxs, " ")+"]") + } + return "ext{" + strings.Join(parts, " ") + "}" +} + +func align8(n int) int { + return (n + 7) &^ 7 +} + +// parse parses b into e, reusing e's slices. +func (e *requestExt) parse(b []byte) Status { + clear(e.secctx) + e.secctx = e.secctx[:0] + e.suppGroups = e.suppGroups[:0] + for len(b) > 0 { + if len(b) < sizeOfExtHeader { + return EIO + } + size := binary.NativeEndian.Uint32(b) + typ := binary.NativeEndian.Uint32(b[4:]) + if size < sizeOfExtHeader || size%8 != 0 || uint64(size) > uint64(len(b)) { + return EIO + } + rec := b[sizeOfExtHeader:size] + b = b[size:] + + switch { + case typ <= _MAX_NR_SECCTX: + if !e.parseSecCtx(rec, int(typ)) { + return EIO + } + case typ == _EXT_GROUPS: + if len(rec) < 4 { + return EIO + } + nr := binary.NativeEndian.Uint32(rec) + rec = rec[4:] + if uint64(nr) > uint64(len(rec)/4) { + return EIO + } + for i := 0; i < int(nr); i++ { + e.suppGroups = append(e.suppGroups, binary.NativeEndian.Uint32(rec[4*i:])) + } + } + } + return OK +} + +// Each context is fuse_secctx, name\0, value, padded to 8 bytes. +func (e *requestExt) parseSecCtx(rec []byte, nr int) bool { + for i := 0; i < nr; i++ { + if len(rec) < sizeOfSecCtx { + return false + } + valSize := binary.NativeEndian.Uint32(rec) + rest := rec[sizeOfSecCtx:] + nul := bytes.IndexByte(rest, 0) + if nul < 0 { + return false + } + name := rest[:nul] + rest = rest[nul+1:] + if uint64(valSize) > uint64(len(rest)) { + return false + } + e.secctx = append(e.secctx, secCtx{name: name, value: rest[:valSize]}) + + used := sizeOfSecCtx + nul + 1 + int(valSize) + rec = rec[min(align8(used), len(rec)):] + } + return true +} diff --git a/third_party/go-fuse/fuse/fusefd.go b/third_party/go-fuse/fuse/fusefd.go new file mode 100644 index 000000000..bb11b2ca5 --- /dev/null +++ b/third_party/go-fuse/fuse/fusefd.go @@ -0,0 +1,253 @@ +// Copyright 2026 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "log" + "os" + "sync" + "syscall" + "unsafe" +) + +// fuseFD owns the per-FUSE-fd state: the fd itself, the read-side +// bookkeeping (reqReaders, inflightRequestBytes), and the WaitGroup +// tracking reader goroutines on this fd. The pools are shared with the +// owning Server via pointers, and a back pointer to the Server gives the +// per-fd methods access to shared configuration and callbacks. +// +// Today a Server has a single fuseFD; this struct exists so that adding +// support for FUSE_DEV_IOC_CLONE'd fds is a matter of widening the field +// to a slice. +type fuseFD struct { + server *Server + + // I/O with kernel and daemon. file owns the FUSE fd; conn is its + // RawConn. Running syscalls through conn holds a reference on the + // fd, so concurrent writers and close() are safe against the fd + // number being reused without any explicit locking. + file *os.File + conn syscall.RawConn + + reqMu sync.Mutex + reqReaders int + inflightRequestBytes int + + // loops tracks reader goroutines servicing fd. + loops sync.WaitGroup + + // Shared pools owned by Server. + reqPool *sync.Pool + readPool *sync.Pool + buffers *bufferPool + + // Accounting constants, set once at server construction. + reqAllocBytes int + readBufBytes int +} + +// newFuseFD returns a fuseFD bound to ms and ready to read from fd. +// Ownership of fd passes to the returned fuseFD's *os.File. All shared +// state (pools, buffer pool, accounting constants) is wired up here. +func (ms *Server) newFuseFD(fd int) (*fuseFD, error) { + file := os.NewFile(uintptr(fd), "/dev/fuse") + conn, err := file.SyscallConn() + if err != nil { + file.Close() + return nil, err + } + _, readBufBytes, reqAllocBytes := requestAccountingSizes(ms.opts.MaxWrite) + return &fuseFD{ + server: ms, + file: file, + conn: conn, + reqPool: &ms.reqPool, + readPool: &ms.readPool, + buffers: &ms.buffers, + reqAllocBytes: reqAllocBytes, + readBufBytes: readBufBytes, + }, nil +} + +// withFD runs f with the underlying FUSE file descriptor, holding a +// reference on it for the duration so the fd cannot be closed (and its +// number reused) while f runs. This is what lets concurrent writers and +// close() run without a serializing mutex. The error is non-nil only +// when the fd is already closed, in which case f is not called. +func (r *fuseFD) withFD(f func(fd int)) error { + return r.conn.Control(func(fd uintptr) { + f(int(fd)) + }) +} + +// writevFD writes iov to the FUSE fd, holding a reference against +// concurrent close. The error is the underlying syscall error, or a +// non-syscall error if the fd is already closed. +func (r *fuseFD) writevFD(iov [][]byte) (int, error) { + var n int + var err error + if cerr := r.withFD(func(fd int) { + n, err = writev(fd, iov) + }); cerr != nil { + return 0, cerr + } + return n, err +} + +// readRequest reads one request from the kernel. Returns nil, OK if +// there are too many concurrent readers or insufficient request-bytes +// budget. +func (r *fuseFD) readRequest() (req *requestAlloc, code Status) { + ms := r.server + r.reqMu.Lock() + if r.reqReaders > ms.maxReaders || !r.reserveRequestBytes() { + r.reqMu.Unlock() + return nil, OK + } + r.reqReaders++ + r.reqMu.Unlock() + + req = r.reqPool.Get().(*requestAlloc) + dest := r.readPool.Get().([]byte) + + var n int + err := handleEINTR(func() error { + var err error + if cerr := r.withFD(func(fd int) { + n, err = syscall.Read(fd, dest) + }); cerr != nil { + return cerr + } + return err + }) + if err != nil { + r.reqMu.Lock() + r.putReadBuf(dest) + r.putReq(req) + r.reqReaders-- + r.reqMu.Unlock() + return nil, ToStatus(err) + } + + r.reqMu.Lock() + defer r.reqMu.Unlock() + gobbled := req.setInput(dest[:n]) + if len(req.inputBuf) < int(unsafe.Sizeof(InHeader{})) { + log.Printf("Short read for input header: %v", req.inputBuf) + r.putReadBuf(dest) + r.putReq(req) + r.reqReaders-- + return nil, EINVAL + } + opCode := ((*InHeader)(unsafe.Pointer(&req.inputBuf[0]))).Opcode + /* These messages don't expect reply, so they cost nothing for + the kernel to send. Make sure we're not overwhelmed by not + spawning a new reader. + */ + needsBackPressure := (opCode == _OP_FORGET || opCode == _OP_BATCH_FORGET) + + if !gobbled { + r.putReadBuf(dest) + } + r.reqReaders-- + if !ms.singleReader && r.reqReaders <= 0 && !needsBackPressure { + r.loops.Add(1) + go ms.loop() + } + + return req, OK +} + +// returnRequest returns a request to the pool of unused requests. +func (r *fuseFD) returnRequest(req *requestAlloc) { + if req.bufferPoolOutputBuf != nil { + r.buffers.FreeBuffer(req.bufferPoolOutputBuf) + req.bufferPoolOutputBuf = nil + } + if req.interrupted { + req.interrupted = false + req.cancel = make(chan struct{}, 0) + } + req.clear() + + r.reqMu.Lock() + if p := req.bufferPoolInputBuf; p != nil { + req.bufferPoolInputBuf = nil + r.putReadBuf(p) + } + r.putReq(req) + r.reqMu.Unlock() +} + +func (r *fuseFD) reserveRequestBytes() bool { + if !r.canReserveRequestBytes() { + return false + } + r.inflightRequestBytes += r.requestBytes() + return true +} + +func (r *fuseFD) canReserveRequestBytes() bool { + return r.inflightRequestBytes == 0 || + r.requestBytes() <= r.server.opts.MaxInflightRequestBytes-r.inflightRequestBytes +} + +// canAcceptAnother wraps canReserveRequestBytes with reqMu, for callers +// that don't already hold the lock. +func (r *fuseFD) canAcceptAnother() bool { + r.reqMu.Lock() + defer r.reqMu.Unlock() + return r.canReserveRequestBytes() +} + +func (r *fuseFD) requestBytes() int { + return r.reqAllocBytes + r.readBufBytes +} + +func (r *fuseFD) putReadBuf(buf []byte) { + r.readPool.Put(buf) + r.inflightRequestBytes -= r.readBufBytes +} + +func (r *fuseFD) putReq(req *requestAlloc) { + r.reqPool.Put(req) + r.inflightRequestBytes -= r.reqAllocBytes +} + +// close closes the underlying FUSE fd. The *os.File waits for in-flight +// RawConn operations (reads, writes, ioctls) before releasing the fd. +func (r *fuseFD) close() error { + return r.file.Close() +} + +func (r *fuseFD) writev(iov [][]byte) (int, syscall.Errno) { + n, err := r.writevFD(iov) + if err == nil { + return n, 0 + } + errno, ok := err.(syscall.Errno) + if !ok { + // The fd is closed; report it as such. + return n, syscall.EBADF + } + if errno == syscall.EINVAL { + // Detail: the kernel returns EINVAL for unsupported + // notify methods. + errno = syscall.ENOSYS + } + return n, errno +} + +func (r *fuseFD) writeWithSlice(req *request, ws withSlice) Status { + slices, st := ws.Slices() + if !st.Ok() { + return st + } + bufs := make([][]byte, 0, 2+len(slices)) + bufs = append(bufs, req.outHeaderBuf, req.outDataBuf) + bufs = append(bufs, slices...) + _, err := r.writevFD(bufs) + return ToStatus(err) +} diff --git a/third_party/go-fuse/fuse/fusefd_linux.go b/third_party/go-fuse/fuse/fusefd_linux.go new file mode 100644 index 000000000..a7004ebb8 --- /dev/null +++ b/third_party/go-fuse/fuse/fusefd_linux.go @@ -0,0 +1,43 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +const useSingleReader = false + +func (r *fuseFD) write(req *request) Status { + if req.outPayloadSize() == 0 { + err := handleEINTR(func() error { + _, err := r.writevFD([][]byte{req.outHeaderBuf, req.outDataBuf}) + return err + }) + return ToStatus(err) + } + + if req.readResult != nil { + defer func() { + req.readResult.Done() + req.readResult = nil + }() + if ws, ok := req.readResult.(withSlice); ok { + return r.writeWithSlice(req, ws) + } + + if r.server.canSplice && !r.server.opts.DisableSplice { + err := r.trySplice(req, req.readResult) + if err == nil { + return OK + } + if err != errRecoverSplice { + r.server.opts.Logger.Println("trySplice:", err) + } + } + + req.outPayload, req.status = req.readResult.Bytes(req.outPayload) + req.serializeHeader(len(req.outPayload)) + } + + _, err := r.writevFD([][]byte{req.outHeaderBuf, req.outDataBuf, req.outPayload}) + return ToStatus(err) +} diff --git a/third_party/go-fuse/fuse/fusefd_other.go b/third_party/go-fuse/fuse/fusefd_other.go new file mode 100644 index 000000000..c4f90a15f --- /dev/null +++ b/third_party/go-fuse/fuse/fusefd_other.go @@ -0,0 +1,33 @@ +//go:build !linux + +package fuse + +// OSX and FreeBSD has races when multiple routines read +// from the FUSE device: on unmount, sometime some reads +// do not error-out, meaning that unmount will hang. +const useSingleReader = true + +func (r *fuseFD) write(req *request) Status { + if req.outPayloadSize() == 0 { + err := handleEINTR(func() error { + _, err := r.writevFD([][]byte{req.outHeaderBuf, req.outDataBuf}) + return err + }) + return ToStatus(err) + } + if req.readResult != nil { + defer func() { + req.readResult.Done() + req.readResult = nil + }() + if ws, ok := req.readResult.(withSlice); ok { + return r.writeWithSlice(req, ws) + } + + req.outPayload, req.status = req.readResult.Bytes(req.outPayload) + req.serializeHeader(len(req.outPayload)) + } + + _, err := r.writevFD([][]byte{req.outHeaderBuf, req.outDataBuf, req.outPayload}) + return ToStatus(err) +} diff --git a/third_party/go-fuse/fuse/misc.go b/third_party/go-fuse/fuse/misc.go new file mode 100644 index 000000000..8b7b63805 --- /dev/null +++ b/third_party/go-fuse/fuse/misc.go @@ -0,0 +1,96 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Random odds and ends. + +package fuse + +import ( + "errors" + "fmt" + "log" + "os" + "syscall" + "time" +) + +var notifyNames = []string{ + "OK", + "NOTIFY_POLL", + "NOTIFY_INVAL_INODE", + "NOTIFY_INVAL_ENTRY", + "NOTIFY_STORE_CACHE", + "NOTIFY_RETRIEVE_CACHE", + "NOTIFY_DELETE", + "NOTIFY_RESEND", + "NOTIFY_INC_EPOCH", + "NOTIFY_PRUNE", +} + +func (code Status) String() string { + if code <= 0 { + idx := int(-code) + if idx < len(notifyNames) { + return notifyNames[idx] + } + + return fmt.Sprintf("negative(%d)", code) + } + return fmt.Sprintf("%d=%v", int(code), syscall.Errno(code)) +} + +func (code Status) Ok() bool { + return code == OK +} + +// ToStatus extracts an errno number from Go error objects. If it +// fails, it logs an error and returns ENOSYS. +func ToStatus(err error) Status { + if err == nil { + return OK + } + + switch err { + case os.ErrPermission: + return EPERM + case os.ErrExist: + return Status(syscall.EEXIST) + case os.ErrNotExist: + return ENOENT + case os.ErrInvalid: + return EINVAL + } + + var errno syscall.Errno + if errors.As(err, &errno) { + return Status(errno) + } + log.Println("can't convert error type:", err) + return ENOTSUP +} + +func CurrentOwner() *Owner { + return &Owner{ + Uid: uint32(os.Getuid()), + Gid: uint32(os.Getgid()), + } +} + +// UtimeToTimespec converts a "Time" pointer as passed to Utimens to a +// "Timespec" that can be passed to the utimensat syscall. +// A nil pointer is converted to the special UTIME_OMIT value. +// +// Deprecated: use unix.TimeToTimespec from the x/sys/unix package instead. +func UtimeToTimespec(t *time.Time) (ts syscall.Timespec) { + if t == nil { + ts.Nsec = _UTIME_OMIT + } else { + ts = syscall.NsecToTimespec(t.UnixNano()) + // Go bug https://github.com/golang/go/issues/12777 + if ts.Nsec < 0 { + ts.Nsec = 0 + } + } + return ts +} diff --git a/third_party/go-fuse/fuse/misc_darwin.go b/third_party/go-fuse/fuse/misc_darwin.go new file mode 100644 index 000000000..9e60b809a --- /dev/null +++ b/third_party/go-fuse/fuse/misc_darwin.go @@ -0,0 +1,4 @@ +package fuse + +// Ref: https://github.com/apple/darwin-xnu/blob/main/bsd/sys/stat.h#L576 +const _UTIME_OMIT = -2 diff --git a/third_party/go-fuse/fuse/misc_unix.go b/third_party/go-fuse/fuse/misc_unix.go new file mode 100644 index 000000000..55f832adc --- /dev/null +++ b/third_party/go-fuse/fuse/misc_unix.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package fuse + +import "golang.org/x/sys/unix" + +const _UTIME_OMIT = unix.UTIME_OMIT diff --git a/third_party/go-fuse/fuse/mount.go b/third_party/go-fuse/fuse/mount.go new file mode 100644 index 000000000..43399399b --- /dev/null +++ b/third_party/go-fuse/fuse/mount.go @@ -0,0 +1,72 @@ +package fuse + +import ( + "fmt" + "net" + "os" + "syscall" +) + +var reservedFDs []*os.File + +func init() { + // Both Darwin and Linux invoke a subprocess with one + // inherited file descriptor to create the mount. To protect + // against deadlock, we must ensure that file descriptor 3 + // never points to a FUSE filesystem. We do this by simply + // grabbing fd 3 and never releasing it. (This is not + // completely foolproof: a preceding init routine could grab fd 3, + // and then release it later.) + for { + r, w, err := os.Pipe() + if err != nil { + panic(fmt.Sprintf("os.Pipe(): %v", err)) + } + w.Close() + if r.Fd() > 3 { + r.Close() + break + } + reservedFDs = append(reservedFDs, r) + } +} + +func getConnection(local *os.File) (int, error) { + conn, err := net.FileConn(local) + if err != nil { + return 0, err + } + defer conn.Close() + unixConn := conn.(*net.UnixConn) + + var data [4]byte + control := make([]byte, 4*256) + + _, oobn, _, _, err := unixConn.ReadMsgUnix(data[:], control[:]) + if err != nil { + return 0, err + } + + messages, err := syscall.ParseSocketControlMessage(control[:oobn]) + if err != nil { + return 0, err + } + if len(messages) != 1 { + return 0, fmt.Errorf("getConnection: expect 1 control message, got %#v", messages) + } + message := messages[0] + + fds, err := syscall.ParseUnixRights(&message) + if err != nil { + return 0, err + } + if len(fds) != 1 { + return 0, fmt.Errorf("getConnection: expect 1 fd, got %#v", fds) + } + fd := fds[0] + + if fd < 0 { + return 0, fmt.Errorf("getConnection: fd < 0: %d", fd) + } + return fd, nil +} diff --git a/third_party/go-fuse/fuse/mount_darwin.go b/third_party/go-fuse/fuse/mount_darwin.go new file mode 100644 index 000000000..1e733aede --- /dev/null +++ b/third_party/go-fuse/fuse/mount_darwin.go @@ -0,0 +1,114 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + "syscall" +) + +func getMaxWrite() int { + return 1 << 20 +} + +func unixgramSocketpair() (l, r *os.File, err error) { + fd, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + return nil, nil, os.NewSyscallError("socketpair", + err.(syscall.Errno)) + } + l = os.NewFile(uintptr(fd[0]), "socketpair-half1") + r = os.NewFile(uintptr(fd[1]), "socketpair-half2") + return +} + +// Create a FUSE FS on the specified mount point. The returned +// mount point is always absolute. +func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + local, remote, err := unixgramSocketpair() + if err != nil { + return + } + + defer local.Close() + defer remote.Close() + + bin, err := fusermountBinary() + if err != nil { + return 0, err + } + + cmd := exec.Command(bin, + "-o", strings.Join(opts.optionsStrings(), ","), + "-o", fmt.Sprintf("iosize=%d", opts.MaxWrite), + mountPoint) + cmd.ExtraFiles = []*os.File{remote} // fd would be (index + 3) + cmd.Env = append(os.Environ(), + "_FUSE_CALL_BY_LIB=", + "_FUSE_DAEMON_PATH="+os.Args[0], + "_FUSE_COMMFD=3", + "_FUSE_COMMVERS=2", + "MOUNT_OSXFUSE_CALL_BY_LIB=", + "MOUNT_OSXFUSE_DAEMON_PATH="+os.Args[0]) + + var out, errOut bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errOut + + if err = cmd.Start(); err != nil { + return + } + + fd, err = getConnection(local) + if err != nil { + return -1, err + } + + go func() { + // On macos, mount_osxfuse is not just a suid + // wrapper. It interacts with the FS setup, and will + // not exit until the filesystem has successfully + // responded to INIT and STATFS. This means we can + // only wait on the mount_osxfuse process after the + // server has fully started + if err := cmd.Wait(); err != nil { + err = fmt.Errorf("mount_osxfusefs failed: %v. Stderr: %s, Stdout: %s", + err, errOut.String(), out.String()) + } + + ready <- err + close(ready) + }() + + // golang sets CLOEXEC on file descriptors when they are + // acquired through normal operations (e.g. open). + // Buf for fd, we have to set CLOEXEC manually + syscall.CloseOnExec(fd) + + return fd, err +} + +func unmount(dir string, opts *MountOptions) error { + return syscall.Unmount(dir, 0) +} + +func fusermountBinary() (string, error) { + binPaths := []string{ + "/Library/Filesystems/macfuse.fs/Contents/Resources/mount_macfuse", + "/Library/Filesystems/osxfuse.fs/Contents/Resources/mount_osxfuse", + } + + for _, path := range binPaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("no FUSE mount utility found") +} diff --git a/third_party/go-fuse/fuse/mount_freebsd.go b/third_party/go-fuse/fuse/mount_freebsd.go new file mode 100644 index 000000000..694287eca --- /dev/null +++ b/third_party/go-fuse/fuse/mount_freebsd.go @@ -0,0 +1,146 @@ +package fuse + +import ( + "fmt" + "os" + "strings" + "syscall" +) + +func getMaxWrite() int { + return 1 << 20 +} + +func callMountFuseFs(mountPoint string, opts *MountOptions) (devFuseFd int, err error) { + bin, err := fusermountBinary() + if err != nil { + return -1, err + } + + // Convenience for cleaning up open FDs in the event of an error. + closeFd := func(fd int) { + if err != nil { + syscall.Close(fd) + } + } + + // Use syscall.Open instead of os.OpenFile to avoid Go garbage collecting an + // [os.File], which will close the FD later, but we need to keep it open for + // the life of the FUSE mount. + const devFuse = "/dev/fuse" + devFuseFd, err = syscall.Open(devFuse, syscall.O_RDWR, 0) + if err != nil { + return -1, fmt.Errorf( + "failed to get a RDWR file descriptor for %s: %w", + devFuse, err) + } + defer closeFd(devFuseFd) + // We will also defensively direct the forked process's stdin to the null + // device to prevent any unintended side effects. + devNullFd, err := syscall.Open(os.DevNull, syscall.O_RDONLY, 0) + if err != nil { + return -1, fmt.Errorf( + "failed to get a RDONLY file descriptor for %s: %w", + os.DevNull, err) + } + defer closeFd(devNullFd) + + // Use syscall.ForkExec directly instead of exec.Command because we need to + // pass raw file descriptors to the child, rather than a garbage collected + // os.File. + env := []string{"MOUNT_FUSEFS_CALL_BY_LIB=1"} + argv := []string{ + bin, + "--safe", + "-o", strings.Join(opts.optionsStrings(), ","), + "3", + mountPoint, + } + fds := []uintptr{ + uintptr(devNullFd), + uintptr(os.Stdout.Fd()), + uintptr(os.Stderr.Fd()), + uintptr(devFuseFd), + } + pid, err := syscall.ForkExec(bin, argv, &syscall.ProcAttr{ + Env: env, + Files: fds, + }) + if err != nil { + return -1, fmt.Errorf("failed to fork mount_fusefs: %w", err) + } + + var ws syscall.WaitStatus + _, err = syscall.Wait4(pid, &ws, 0, nil) + if err != nil { + return -1, fmt.Errorf( + "failed to wait for exit status of mount_fusefs: %w", err) + } + if ws.ExitStatus() != 0 { + return -1, fmt.Errorf( + "mount_fusefs: exited with status %d", ws.ExitStatus()) + } + + return devFuseFd, nil +} + +func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + // Note: opts.DirectMount is not supported in FreeBSD, but the intended + // behavior is to *attempt* a direct mount when it's set, not to return an + // error. So in this case, we just ignore it and use the binary from + // fusermountBinary(). + + // Using the same logic from libfuse to prevent chaos + for { + f, err := os.OpenFile("/dev/null", os.O_RDWR, 0o000) + if err != nil { + return -1, err + } + if f.Fd() > 2 { + f.Close() + break + } + } + + // Magic `/dev/fd/N` mountpoint. See the docs for NewServer() for how this + // works. + + fd = parseFuseFd(mountPoint) + if fd >= 0 { + if opts.Debug { + opts.Logger.Printf("mount: magic mountpoint %q, using fd %d", mountPoint, fd) + } + } else { + // Usual case: mount via the `fusermount` suid helper + fd, err = callMountFuseFs(mountPoint, opts) + if err != nil { + return + } + } + // golang sets CLOEXEC on file descriptors when they are + // acquired through normal operations (e.g. open). + // However, for raw FDs, we have to set CLOEXEC manually. + syscall.CloseOnExec(fd) + + close(ready) + return fd, err +} + +func unmount(mountPoint string, opts *MountOptions) (err error) { + _ = opts + return syscall.Unmount(mountPoint, 0) +} + +func fusermountBinary() (string, error) { + binPaths := []string{ + "/sbin/mount_fusefs", + } + + for _, path := range binPaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("no FUSE mount utility found") +} diff --git a/third_party/go-fuse/fuse/mount_linux.go b/third_party/go-fuse/fuse/mount_linux.go new file mode 100644 index 000000000..7fddae27e --- /dev/null +++ b/third_party/go-fuse/fuse/mount_linux.go @@ -0,0 +1,279 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path" + "strconv" + "strings" + "syscall" +) + +func unixgramSocketpair() (l, r *os.File, err error) { + fd, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_SEQPACKET, 0) + if err != nil { + return nil, nil, os.NewSyscallError("socketpair", + err.(syscall.Errno)) + } + l = os.NewFile(uintptr(fd[0]), "socketpair-half1") + r = os.NewFile(uintptr(fd[1]), "socketpair-half2") + return +} + +// Create a FUSE FS on the specified mount point without using +// fusermount. +func mountDirect(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + // managed to open dev/fuse, attempt to mount + source := opts.FsName + if source == "" { + source = opts.Name + } + + var flags uintptr = syscall.MS_NOSUID | syscall.MS_NODEV + if opts.DirectMountFlags != 0 { + flags = opts.DirectMountFlags + } + + var st syscall.Stat_t + err = syscall.Stat(mountPoint, &st) + if err != nil { + return + } + + fd, err = syscall.Open("/dev/fuse", os.O_RDWR, 0) // use syscall.Open since we want an int fd + if err != nil { + return + } + + // some values we need to pass to mount - we do as fusermount does. + // override possible since opts.Options comes after. + // + // Only the options listed below are understood by the kernel: + // https://elixir.bootlin.com/linux/v6.14.2/source/fs/fuse/inode.c#L772 + // https://elixir.bootlin.com/linux/v6.14.2/source/fs/fs_context.c#L41 + // https://elixir.bootlin.com/linux/v6.14.2/source/fs/fs_context.c#L50 + // Everything else will cause an EINVAL error from syscall.Mount() and + // a corresponding error message in the kernel logs. + var r = []string{ + fmt.Sprintf("fd=%d", fd), + fmt.Sprintf("rootmode=%o", st.Mode&syscall.S_IFMT), + fmt.Sprintf("user_id=%d", os.Geteuid()), + fmt.Sprintf("group_id=%d", os.Getegid()), + // match what we do with fusermount + fmt.Sprintf("max_read=%d", opts.MaxWrite), + } + + // In syscall.Mount(), [no]dev/suid/exec must be + // passed as bits in the flags parameter, not as strings. + for _, o := range opts.Options { + switch o { + case "nodev": + flags |= syscall.MS_NODEV + case "dev": + flags &^= syscall.MS_NODEV + case "nosuid": + flags |= syscall.MS_NOSUID + case "suid": + flags &^= syscall.MS_NOSUID + case "noexec": + flags |= syscall.MS_NOEXEC + case "exec": + flags &^= syscall.MS_NOEXEC + default: + r = append(r, o) + } + } + + if opts.AllowOther { + r = append(r, "allow_other") + } + if opts.IDMappedMount && !opts.containsOption("default_permissions") { + r = append(r, "default_permissions") + } + + if opts.Debug { + opts.Logger.Printf("mountDirect: calling syscall.Mount(%q, %q, %q, %#x, %q)", + source, mountPoint, "fuse."+opts.Name, flags, strings.Join(r, ",")) + } + err = syscall.Mount(source, mountPoint, "fuse."+opts.Name, flags, strings.Join(r, ",")) + if err != nil { + syscall.Close(fd) + return + } + + // success + close(ready) + return +} + +// callFusermount calls the `fusermount` suid helper with the right options so +// that it: +// * opens `/dev/fuse` +// * mount()s this file descriptor to `mountPoint` +// * passes this file descriptor back to us via a unix domain socket +// This file descriptor is returned as `fd`. +func callFusermount(mountPoint string, opts *MountOptions) (fd int, err error) { + local, remote, err := unixgramSocketpair() + if err != nil { + return + } + + defer local.Close() + defer remote.Close() + + bin, err := fusermountBinary() + if err != nil { + return 0, err + } + + cmd := []string{bin, mountPoint} + if s := opts.optionsStrings(); len(s) > 0 { + cmd = append(cmd, "-o", strings.Join(s, ",")) + } + if opts.Debug { + opts.Logger.Printf("callFusermount: executing %q", cmd) + } + proc, err := os.StartProcess(bin, + cmd, + &os.ProcAttr{ + Env: []string{"_FUSE_COMMFD=3"}, + Files: []*os.File{os.Stdin, os.Stdout, os.Stderr, remote}}) + + if err != nil { + return + } + + w, err := proc.Wait() + if err != nil { + return + } + if !w.Success() { + err = fmt.Errorf("fusermount exited with code %v\n", w.Sys()) + return + } + + fd, err = getConnection(local) + if err != nil { + return -1, err + } + + return +} + +// Create a FUSE FS on the specified mount point. The returned +// mount point is always absolute. +func mount(mountPoint string, opts *MountOptions, ready chan<- error) (fd int, err error) { + if opts.DirectMount || opts.DirectMountStrict { + fd, err := mountDirect(mountPoint, opts, ready) + if err == nil { + return fd, nil + } else if opts.Debug { + opts.Logger.Printf("mount: failed to do direct mount: %s", err) + } + if opts.DirectMountStrict { + return -1, err + } + } + + // Magic `/dev/fd/N` mountpoint. See the docs for NewServer() for how this + // works. + fd = parseFuseFd(mountPoint) + if fd >= 0 { + if opts.Debug { + opts.Logger.Printf("mount: magic mountpoint %q, using fd %d", mountPoint, fd) + } + } else { + // Usual case: mount via the `fusermount` suid helper + fd, err = callFusermount(mountPoint, opts) + if err != nil { + return + } + } + // golang sets CLOEXEC on file descriptors when they are + // acquired through normal operations (e.g. open). + // Buf for fd, we have to set CLOEXEC manually + syscall.CloseOnExec(fd) + close(ready) + return fd, err +} + +func unmount(mountPoint string, opts *MountOptions) (err error) { + if opts.DirectMount || opts.DirectMountStrict { + // Attempt to directly unmount, if fails fallback to fusermount method + err := syscall.Unmount(mountPoint, 0) + if err == nil { + return nil + } + if opts.DirectMountStrict { + return err + } + } + + bin, err := fusermountBinary() + if err != nil { + return err + } + errBuf := bytes.Buffer{} + cmd := exec.Command(bin, "-u", mountPoint) + cmd.Stderr = &errBuf + if opts.Debug { + opts.Logger.Printf("unmount: executing %q", cmd.Args) + } + err = cmd.Run() + if errBuf.Len() > 0 { + return fmt.Errorf("%s (code %v)\n", + errBuf.String(), err) + } + return err +} + +// lookPathFallback - search binary in PATH and, if that fails, +// in fallbackDir. This is useful if PATH is possible empty. +func lookPathFallback(file string, fallbackDir string) (string, error) { + binPath, err := exec.LookPath(file) + if err == nil { + return binPath, nil + } + + abs := path.Join(fallbackDir, file) + return exec.LookPath(abs) +} + +// fusermountBinary returns the path to the `fusermount3` binary, or, if not +// found, the `fusermount` binary. +func fusermountBinary() (string, error) { + if path, err := lookPathFallback("fusermount3", "/bin"); err == nil { + return path, nil + } + return lookPathFallback("fusermount", "/bin") +} + +func umountBinary() (string, error) { + return lookPathFallback("umount", "/bin") +} + +func getMaxWrite() int { + return maxPageLimit() * syscall.Getpagesize() +} + +func maxPageLimit() (lim int) { + lim = 256 + + d, err := os.ReadFile("/proc/sys/fs/fuse/max_pages_limit") + if err != nil { + return lim + } + + newLim, err := strconv.Atoi(strings.TrimSpace(string(d))) + if err != nil { + return lim + } + + return newLim +} diff --git a/third_party/go-fuse/fuse/opcode.go b/third_party/go-fuse/fuse/opcode.go new file mode 100644 index 000000000..c6014c89a --- /dev/null +++ b/third_party/go-fuse/fuse/opcode.go @@ -0,0 +1,806 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "log" + "runtime" + "syscall" + "unsafe" +) + +const ( + _OP_LOOKUP = uint32(1) + _OP_FORGET = uint32(2) + _OP_GETATTR = uint32(3) + _OP_SETATTR = uint32(4) + _OP_READLINK = uint32(5) + _OP_SYMLINK = uint32(6) + _OP_MKNOD = uint32(8) + _OP_MKDIR = uint32(9) + _OP_UNLINK = uint32(10) + _OP_RMDIR = uint32(11) + _OP_RENAME = uint32(12) + _OP_LINK = uint32(13) + _OP_OPEN = uint32(14) + _OP_READ = uint32(15) + _OP_WRITE = uint32(16) + _OP_STATFS = uint32(17) + _OP_RELEASE = uint32(18) + _OP_FSYNC = uint32(20) + _OP_SETXATTR = uint32(21) + _OP_GETXATTR = uint32(22) + _OP_LISTXATTR = uint32(23) + _OP_REMOVEXATTR = uint32(24) + _OP_FLUSH = uint32(25) + _OP_INIT = uint32(26) + _OP_OPENDIR = uint32(27) + _OP_READDIR = uint32(28) + _OP_RELEASEDIR = uint32(29) + _OP_FSYNCDIR = uint32(30) + _OP_GETLK = uint32(31) + _OP_SETLK = uint32(32) + _OP_SETLKW = uint32(33) + _OP_ACCESS = uint32(34) + _OP_CREATE = uint32(35) + _OP_INTERRUPT = uint32(36) + _OP_BMAP = uint32(37) + _OP_DESTROY = uint32(38) + _OP_IOCTL = uint32(39) + _OP_POLL = uint32(40) + _OP_NOTIFY_REPLY = uint32(41) + _OP_BATCH_FORGET = uint32(42) + _OP_FALLOCATE = uint32(43) // protocol version 19. + _OP_READDIRPLUS = uint32(44) // protocol version 21. + _OP_RENAME2 = uint32(45) // protocol version 23. + _OP_LSEEK = uint32(46) // protocol version 24 + _OP_COPY_FILE_RANGE = uint32(47) // protocol version 28. + + _OP_SETUPMAPPING = 48 + _OP_REMOVEMAPPING = 49 + _OP_SYNCFS = 50 + _OP_TMPFILE = 51 + _OP_STATX = 52 + _OP_COPY_FILE_RANGE_64 = 53 + + // The following entries don't have to be compatible across Go-FUSE versions. + _OP_NOTIFY_INVAL_ENTRY = uint32(100) + _OP_NOTIFY_INVAL_INODE = uint32(101) + _OP_NOTIFY_STORE_CACHE = uint32(102) + _OP_NOTIFY_RETRIEVE_CACHE = uint32(103) + _OP_NOTIFY_DELETE = uint32(104) // protocol version 18 + _OP_NOTIFY_PRUNE = uint32(105) // protocol version 45 + + _OPCODE_COUNT = uint32(106) + + // Constants from Linux kernel fs/fuse/fuse_i.h + // Default MaxPages value in all kernel versions + _FUSE_DEFAULT_MAX_PAGES_PER_REQ = 32 + // Upper MaxPages limit in Linux v4.20+ (v4.19 and older: 32) + _FUSE_MAX_MAX_PAGES = 256 +) + +//////////////////////////////////////////////////////////////// + +func doInit(server *protocolServer, req *request) { + input := (*InitIn)(req.inData()) + if input.Major != _FUSE_KERNEL_VERSION { + log.Printf("Major versions does not match. Given %d, want %d\n", input.Major, _FUSE_KERNEL_VERSION) + req.status = EIO + return + } + if input.Minor < _MINIMUM_MINOR_VERSION { + log.Printf("Minor version is less than we support. Given %d, want at least %d\n", input.Minor, _MINIMUM_MINOR_VERSION) + req.status = EIO + return + } + + kernelFlags := input.Flags64() + server.kernelSettings = *input + kernelFlags &= (CAP_ASYNC_READ | CAP_BIG_WRITES | CAP_FILE_OPS | + CAP_READDIRPLUS | CAP_NO_OPEN_SUPPORT | CAP_PARALLEL_DIROPS | CAP_MAX_PAGES | CAP_RENAME_SWAP | CAP_PASSTHROUGH | CAP_ALLOW_IDMAP | + server.opts.ExtraCapabilities) + + if server.opts.EnableLocks { + kernelFlags |= input.Flags64() & (CAP_FLOCK_LOCKS | CAP_POSIX_LOCKS) + } + if server.opts.EnableSymlinkCaching { + kernelFlags |= input.Flags64() & CAP_CACHE_SYMLINKS + } + if server.opts.EnableAcl { + kernelFlags |= input.Flags64() & CAP_POSIX_ACL + } + + if server.opts.ExplicitDataCacheControl { + // we don't want CAP_AUTO_INVAL_DATA even if we cannot go into fully explicit mode + kernelFlags |= input.Flags64() & CAP_EXPLICIT_INVAL_DATA + } else { + kernelFlags |= input.Flags64() & CAP_AUTO_INVAL_DATA + } + + kernelFlags = kernelFlags &^ server.opts.DisabledCapabilities + + // maxPages is the maximum request size we want the kernel to use, in units of + // memory pages (usually 4kiB). Linux v4.19 and older ignore this and always use + // 128kiB. + maxPages := (server.opts.MaxWrite-1)/syscall.Getpagesize() + 1 // Round up + + congestionThreshold := server.opts.CongestionThreshold + if congestionThreshold <= 0 { + congestionThreshold = server.opts.MaxBackground * 3 / 4 + } + + out := (*InitOut)(req.outData()) + *out = InitOut{ + Major: _FUSE_KERNEL_VERSION, + Minor: _OUR_MINOR_VERSION, + MaxReadAhead: input.MaxReadAhead, + MaxWrite: uint32(server.opts.MaxWrite), + CongestionThreshold: uint16(congestionThreshold), + MaxBackground: uint16(server.opts.MaxBackground), + MaxPages: uint16(maxPages), + MaxStackDepth: uint32(server.opts.MaxStackDepth), + } + out.setFlags(kernelFlags) + server.negotiatedFlags = out.Flags64() + if server.opts.MaxReadAhead != 0 && uint32(server.opts.MaxReadAhead) < out.MaxReadAhead { + out.MaxReadAhead = uint32(server.opts.MaxReadAhead) + } + if out.Minor > input.Minor { + out.Minor = input.Minor + } + + req.status = OK +} + +func doOpen(server *protocolServer, req *request) { + out := (*OpenOut)(req.outData()) + status := server.fileSystem.Open(req.cancel, (*OpenIn)(req.inData()), out) + req.status = status + if status != OK { + return + } +} + +func doCreate(server *protocolServer, req *request) { + out := (*CreateOut)(req.outData()) + status := server.fileSystem.Create(req.cancel, (*CreateIn)(req.inData()), req.filename(), out) + req.status = status +} + +// doTmpfile answers a request for a file with no name. The kernel sends the name of the anonymous dentry it made +// for it, which is "/" and means nothing, so it is not passed on. +func doTmpfile(server *protocolServer, req *request) { + out := (*CreateOut)(req.outData()) + status := server.fileSystem.Tmpfile(req.cancel, (*CreateIn)(req.inData()), out) + req.status = status +} + +func doReadDir(server *protocolServer, req *request) { + in := (*ReadIn)(req.inData()) + out := NewDirEntryList(req.outPayload, uint64(in.Offset)) + code := server.fileSystem.ReadDir(req.cancel, in, out) + req.outPayload = out.Bytes() + req.status = code +} + +func doReadDirPlus(server *protocolServer, req *request) { + in := (*ReadIn)(req.inData()) + out := NewDirEntryList(req.outPayload, uint64(in.Offset)) + + code := server.fileSystem.ReadDirPlus(req.cancel, in, out) + req.outPayload = out.Bytes() + req.status = code +} + +func doOpenDir(server *protocolServer, req *request) { + out := (*OpenOut)(req.outData()) + status := server.fileSystem.OpenDir(req.cancel, (*OpenIn)(req.inData()), out) + req.status = status +} + +func doSetattr(server *protocolServer, req *request) { + out := (*AttrOut)(req.outData()) + req.status = server.fileSystem.SetAttr(req.cancel, (*SetAttrIn)(req.inData()), out) +} + +func doWrite(server *protocolServer, req *request) { + in := (*WriteIn)(req.inData()) + o := (*WriteOut)(req.outData()) + data := req.inPayloadIov + if data == nil { + req.inPayloadOne[0] = req.inPayload + data = req.inPayloadOne[:] + } + o.Size, req.status = server.fileSystem.Writev(req.cancel, in, data) + if req.status != ENOSYS { + return + } + if len(data) == 1 { + o.Size, req.status = server.fileSystem.Write(req.cancel, in, data[0]) + return + } + server.writevCopyOnce.Do(func() { + server.opts.Logger.Printf("Writev not implemented; copying split WRITE data") + }) + buf := server.buffers.AllocBuffer(uint32(iovLen(data))) + defer server.buffers.FreeBuffer(buf) + copyFromIov(buf, data, 0) + o.Size, req.status = server.fileSystem.Write(req.cancel, in, buf) +} + +func doNotifyReply(server *protocolServer, req *request) { + reply := (*NotifyRetrieveIn)(req.inData()) + server.retrieveMu.Lock() + reading := server.retrieveTab[reply.Unique] + delete(server.retrieveTab, reply.Unique) + server.retrieveMu.Unlock() + + badf := func(format string, argv ...any) { + server.opts.Logger.Printf("notify reply: "+format, argv...) + } + + if reading == nil { + badf("unexpected unique - ignoring") + return + } + + reading.n = 0 + reading.st = EIO + defer close(reading.ready) + + if reading.nodeid != reply.NodeId { + badf("inode mismatch: expected %s, got %s", reading.nodeid, reply.NodeId) + return + } + + if reading.offset != reply.Offset { + badf("offset mismatch: expected @%d, got @%d", reading.offset, reply.Offset) + return + } + + if len(reading.dest) < len(req.inPayload) { + badf("too much data: requested %db, got %db (will use only %db)", len(reading.dest), len(req.inPayload), len(reading.dest)) + } + + reading.n = copy(reading.dest, req.inPayload) + reading.st = OK +} + +const _SECURITY_CAPABILITY = "security.capability" +const _SECURITY_ACL = "system.posix_acl_access" +const _SECURITY_ACL_DEFAULT = "system.posix_acl_default" + +func doGetXAttr(server *protocolServer, req *request) { + if server.opts.DisableXAttrs { + req.status = ENOSYS + return + } + + if server.opts.IgnoreSecurityLabels && req.inHeader().Opcode == _OP_GETXATTR { + fn := req.filename() + if fn == _SECURITY_CAPABILITY || fn == _SECURITY_ACL_DEFAULT || + fn == _SECURITY_ACL { + req.status = ENOATTR + return + } + } + + input := (*GetXAttrIn)(req.inData()) + var n uint32 + switch req.inHeader().Opcode { + case _OP_GETXATTR: + n, req.status = server.fileSystem.GetXAttr(req.cancel, req.inHeader(), req.filename(), req.outPayload) + case _OP_LISTXATTR: + n, req.status = server.fileSystem.ListXAttr(req.cancel, req.inHeader(), req.outPayload) + default: + req.status = ENOSYS + } + + if input.Size == 0 { + if req.status == ERANGE { + // For input.size==0, returning ERANGE is an error. + req.status = OK + } + + out := (*GetXAttrOut)(req.outData()) + out.Size = n + } else if req.status.Ok() { + // ListXAttr called with an empty buffer returns the current size of + // the list but does not touch the buffer (see man 2 listxattr). + if len(req.outPayload) > 0 { + req.outPayload = req.outPayload[:n] + } + } else { + req.outPayload = req.outPayload[:0] + } +} + +func doGetAttr(server *protocolServer, req *request) { + out := (*AttrOut)(req.outData()) + s := server.fileSystem.GetAttr(req.cancel, (*GetAttrIn)(req.inData()), out) + req.status = s +} + +// doForget - forget one NodeId +func doForget(server *protocolServer, req *request) { + if !server.opts.RememberInodes { + server.fileSystem.Forget(req.inHeader().NodeId, (*ForgetIn)(req.inData()).Nlookup) + } +} + +// doBatchForget - forget a list of NodeIds +func doBatchForget(server *protocolServer, req *request) { + in := (*_BatchForgetIn)(req.inData()) + gotCount := len(req.inPayload) / int(unsafe.Sizeof(_ForgetOne{})) + if int(in.Count) > gotCount { + // We have no return value to complain, so log an error. + server.opts.Logger.Printf("Too few bytes for batch forget. Got %d bytes enough for %d entries (want %d entries)", + len(req.inPayload), gotCount, in.Count) + } + in.Count = uint32(gotCount) + if in.Count == 0 { + return + } + + forgets := unsafe.Slice((*_ForgetOne)(unsafe.Pointer(&req.inPayload[0])), in.Count) + for i, f := range forgets { + if server.opts.Debug { + server.opts.Logger.Printf("doBatchForget: rx %d %d/%d: FORGET n%d {Nlookup=%d}", + req.inHeader().Unique, i+1, len(forgets), f.NodeId, f.Nlookup) + } + if f.NodeId == pollHackInode { + continue + } + server.fileSystem.Forget(f.NodeId, f.Nlookup) + } +} + +func doReadlink(server *protocolServer, req *request) { + req.outPayload, req.status = server.fileSystem.Readlink(req.cancel, req.inHeader()) +} + +func doLookup(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + req.status = server.fileSystem.Lookup(req.cancel, req.inHeader(), req.filename(), out) +} + +func doMknod(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + + req.status = server.fileSystem.Mknod(req.cancel, (*MknodIn)(req.inData()), req.filename(), out) +} + +func doMkdir(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + req.status = server.fileSystem.Mkdir(req.cancel, (*MkdirIn)(req.inData()), req.filename(), out) +} + +func doUnlink(server *protocolServer, req *request) { + req.status = server.fileSystem.Unlink(req.cancel, req.inHeader(), req.filename()) +} + +func doRmdir(server *protocolServer, req *request) { + req.status = server.fileSystem.Rmdir(req.cancel, req.inHeader(), req.filename()) +} + +func doLink(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + req.status = server.fileSystem.Link(req.cancel, (*LinkIn)(req.inData()), req.filename(), out) +} + +func doRead(server *protocolServer, req *request) { + in := (*ReadIn)(req.inData()) + req.readResult, req.status = server.fileSystem.Read(req.cancel, in, req.outPayload) +} + +func doFlush(server *protocolServer, req *request) { + req.status = server.fileSystem.Flush(req.cancel, (*FlushIn)(req.inData())) +} + +func doRelease(server *protocolServer, req *request) { + server.fileSystem.Release(req.cancel, (*ReleaseIn)(req.inData())) +} + +func doFsync(server *protocolServer, req *request) { + req.status = server.fileSystem.Fsync(req.cancel, (*FsyncIn)(req.inData())) +} + +func doReleaseDir(server *protocolServer, req *request) { + server.fileSystem.ReleaseDir((*ReleaseIn)(req.inData())) +} + +func doFsyncDir(server *protocolServer, req *request) { + req.status = server.fileSystem.FsyncDir(req.cancel, (*FsyncIn)(req.inData())) +} + +func doSetXAttr(server *protocolServer, req *request) { + if server.opts.DisableXAttrs { + req.status = ENOSYS + return + } + i := bytes.IndexByte(req.inPayload, 0) + if i < 0 { + req.status = EINVAL + return + } + req.status = server.fileSystem.SetXAttr(req.cancel, (*SetXAttrIn)(req.inData()), string(req.inPayload[:i]), req.inPayload[i+1:]) +} + +func doRemoveXAttr(server *protocolServer, req *request) { + if server.opts.DisableXAttrs { + req.status = ENOSYS + return + } + req.status = server.fileSystem.RemoveXAttr(req.cancel, req.inHeader(), req.filename()) +} + +func doAccess(server *protocolServer, req *request) { + req.status = server.fileSystem.Access(req.cancel, (*AccessIn)(req.inData())) +} + +func doSymlink(server *protocolServer, req *request) { + out := (*EntryOut)(req.outData()) + n1, n2, code := req.filenames() + if !code.Ok() { + req.status = code + return + } + req.status = server.fileSystem.Symlink(req.cancel, req.inHeader(), n2, n1, out) +} + +func doRename(server *protocolServer, req *request) { + n1, n2, code := req.filenames() + if !code.Ok() { + req.status = code + return + } + req.status = server.fileSystem.Rename(req.cancel, (*RenameIn)(req.inData()), n1, n2) +} + +func doStatFs(server *protocolServer, req *request) { + out := (*StatfsOut)(req.outData()) + req.status = server.fileSystem.StatFs(req.cancel, req.inHeader(), out) + if req.status == ENOSYS && runtime.GOOS == "darwin" { + // OSX FUSE requires Statfs to be implemented for the + // mount to succeed. + *out = StatfsOut{} + req.status = OK + } +} + +func doIoctl(server *protocolServer, req *request) { + req.status = server.fileSystem.Ioctl(req.cancel, (*IoctlIn)(req.inData()), req.inPayload, (*IoctlOut)(req.outData()), + req.outPayload) +} + +func doPoll(server *protocolServer, req *request) { + req.status = ENOSYS +} + +func doDestroy(server *protocolServer, req *request) { + req.status = OK +} + +func doFallocate(server *protocolServer, req *request) { + req.status = server.fileSystem.Fallocate(req.cancel, (*FallocateIn)(req.inData())) +} + +func doGetLk(server *protocolServer, req *request) { + req.status = server.fileSystem.GetLk(req.cancel, (*LkIn)(req.inData()), (*LkOut)(req.outData())) +} + +func doSetLk(server *protocolServer, req *request) { + req.status = server.fileSystem.SetLk(req.cancel, (*LkIn)(req.inData())) +} + +func doSetLkw(server *protocolServer, req *request) { + req.status = server.fileSystem.SetLkw(req.cancel, (*LkIn)(req.inData())) +} + +func doLseek(server *protocolServer, req *request) { + in := (*LseekIn)(req.inData()) + out := (*LseekOut)(req.outData()) + req.status = server.fileSystem.Lseek(req.cancel, in, out) +} + +func doCopyFileRange(server *protocolServer, req *request) { + in := (*CopyFileRangeIn)(req.inData()) + out := (*WriteOut)(req.outData()) + + out.Size, req.status = server.fileSystem.CopyFileRange(req.cancel, in) +} + +func doInterrupt(server *protocolServer, req *request) { + input := (*InterruptIn)(req.inData()) + req.status = server.interruptRequest(input.Unique) +} + +//////////////////////////////////////////////////////////////// + +type operationFunc func(*protocolServer, *request) +type castPointerFunc func(unsafe.Pointer) any + +type operationHandler struct { + OpCode int + Name string + Func operationFunc + InputSize uintptr + OutputSize uintptr + + InType any + OutType any + FileNames int + FileNameOut bool + SuppressReply bool +} + +var operationHandlers []*operationHandler + +func operationName(op uint32) string { + h := getHandler(op) + if h == nil { + return fmt.Sprintf("opcode %d", op) + } + return h.Name +} + +func getHandler(o uint32) *operationHandler { + if o >= _OPCODE_COUNT { + return nil + } + return operationHandlers[o] +} + +// Maximum size of all input headers. +var maxInputSize uintptr + +func init() { + operationHandlers = make([]*operationHandler, _OPCODE_COUNT) + for i := range operationHandlers { + operationHandlers[i] = &operationHandler{ + OpCode: i, + Name: fmt.Sprintf("OPCODE-%d", i), + } + } + + fileOps := []uint32{_OP_READLINK, _OP_NOTIFY_INVAL_ENTRY, _OP_NOTIFY_DELETE} + for _, op := range fileOps { + operationHandlers[op].FileNameOut = true + } + for _, op := range []uint32{_OP_FORGET, _OP_BATCH_FORGET, _OP_NOTIFY_REPLY} { + operationHandlers[op].SuppressReply = true + } + + for op, v := range map[uint32]string{ + _OP_LOOKUP: "LOOKUP", + _OP_FORGET: "FORGET", + _OP_BATCH_FORGET: "BATCH_FORGET", + _OP_GETATTR: "GETATTR", + _OP_SETATTR: "SETATTR", + _OP_READLINK: "READLINK", + _OP_SYMLINK: "SYMLINK", + _OP_MKNOD: "MKNOD", + _OP_MKDIR: "MKDIR", + _OP_UNLINK: "UNLINK", + _OP_RMDIR: "RMDIR", + _OP_RENAME: "RENAME", + _OP_LINK: "LINK", + _OP_OPEN: "OPEN", + _OP_READ: "READ", + _OP_WRITE: "WRITE", + _OP_STATFS: "STATFS", + _OP_RELEASE: "RELEASE", + _OP_FSYNC: "FSYNC", + _OP_SETXATTR: "SETXATTR", + _OP_GETXATTR: "GETXATTR", + _OP_LISTXATTR: "LISTXATTR", + _OP_REMOVEXATTR: "REMOVEXATTR", + _OP_FLUSH: "FLUSH", + _OP_INIT: "INIT", + _OP_OPENDIR: "OPENDIR", + _OP_READDIR: "READDIR", + _OP_RELEASEDIR: "RELEASEDIR", + _OP_FSYNCDIR: "FSYNCDIR", + _OP_GETLK: "GETLK", + _OP_SETLK: "SETLK", + _OP_SETLKW: "SETLKW", + _OP_ACCESS: "ACCESS", + _OP_CREATE: "CREATE", + _OP_INTERRUPT: "INTERRUPT", + _OP_BMAP: "BMAP", + _OP_DESTROY: "DESTROY", + _OP_IOCTL: "IOCTL", + _OP_POLL: "POLL", + _OP_NOTIFY_REPLY: "NOTIFY_REPLY", + _OP_NOTIFY_INVAL_ENTRY: "NOTIFY_INVAL_ENTRY", + _OP_NOTIFY_INVAL_INODE: "NOTIFY_INVAL_INODE", + _OP_NOTIFY_STORE_CACHE: "NOTIFY_STORE", + _OP_NOTIFY_RETRIEVE_CACHE: "NOTIFY_RETRIEVE", + _OP_NOTIFY_DELETE: "NOTIFY_DELETE", + _OP_FALLOCATE: "FALLOCATE", + _OP_READDIRPLUS: "READDIRPLUS", + _OP_RENAME2: "RENAME2", + _OP_LSEEK: "LSEEK", + _OP_COPY_FILE_RANGE: "COPY_FILE_RANGE", + _OP_SETUPMAPPING: "SETUPMAPPING", + _OP_REMOVEMAPPING: "REMOVEMAPPING", + _OP_SYNCFS: "SYNCFS", + _OP_TMPFILE: "TMPFILE", + _OP_COPY_FILE_RANGE_64: "COPY_FILE_RANGE_64", + } { + operationHandlers[op].Name = v + } + + for op, v := range map[uint32]operationFunc{ + _OP_OPEN: doOpen, + _OP_READDIR: doReadDir, + _OP_WRITE: doWrite, + _OP_OPENDIR: doOpenDir, + _OP_CREATE: doCreate, + _OP_TMPFILE: doTmpfile, + _OP_SETATTR: doSetattr, + _OP_GETXATTR: doGetXAttr, + _OP_LISTXATTR: doGetXAttr, + _OP_GETATTR: doGetAttr, + _OP_FORGET: doForget, + _OP_BATCH_FORGET: doBatchForget, + _OP_READLINK: doReadlink, + _OP_INIT: doInit, + _OP_LOOKUP: doLookup, + _OP_MKNOD: doMknod, + _OP_MKDIR: doMkdir, + _OP_UNLINK: doUnlink, + _OP_RMDIR: doRmdir, + _OP_LINK: doLink, + _OP_READ: doRead, + _OP_FLUSH: doFlush, + _OP_RELEASE: doRelease, + _OP_FSYNC: doFsync, + _OP_RELEASEDIR: doReleaseDir, + _OP_FSYNCDIR: doFsyncDir, + _OP_SETXATTR: doSetXAttr, + _OP_REMOVEXATTR: doRemoveXAttr, + _OP_GETLK: doGetLk, + _OP_SETLK: doSetLk, + _OP_SETLKW: doSetLkw, + _OP_ACCESS: doAccess, + _OP_SYMLINK: doSymlink, + _OP_RENAME: doRename, + _OP_STATFS: doStatFs, + _OP_IOCTL: doIoctl, + _OP_POLL: doPoll, + _OP_DESTROY: doDestroy, + _OP_NOTIFY_REPLY: doNotifyReply, + _OP_FALLOCATE: doFallocate, + _OP_READDIRPLUS: doReadDirPlus, + _OP_RENAME2: doRename, + _OP_INTERRUPT: doInterrupt, + _OP_COPY_FILE_RANGE: doCopyFileRange, + _OP_LSEEK: doLseek, + } { + operationHandlers[op].Func = v + } + + // Outputs. + for op, f := range map[uint32]any{ + _OP_BMAP: _BmapOut{}, + _OP_COPY_FILE_RANGE: WriteOut{}, + _OP_CREATE: CreateOut{}, + _OP_TMPFILE: CreateOut{}, + _OP_GETATTR: AttrOut{}, + _OP_GETLK: LkOut{}, + _OP_GETXATTR: GetXAttrOut{}, + _OP_INIT: InitOut{}, + _OP_IOCTL: IoctlOut{}, + _OP_LINK: EntryOut{}, + _OP_LISTXATTR: GetXAttrOut{}, + _OP_LOOKUP: EntryOut{}, + _OP_LSEEK: LseekOut{}, + _OP_MKDIR: EntryOut{}, + _OP_MKNOD: EntryOut{}, + _OP_NOTIFY_DELETE: NotifyInvalDeleteOut{}, + _OP_NOTIFY_INVAL_ENTRY: NotifyInvalEntryOut{}, + _OP_NOTIFY_INVAL_INODE: NotifyInvalInodeOut{}, + _OP_NOTIFY_RETRIEVE_CACHE: NotifyRetrieveOut{}, + _OP_NOTIFY_STORE_CACHE: NotifyStoreOut{}, + _OP_NOTIFY_PRUNE: NotifyPruneOut{}, + _OP_OPEN: OpenOut{}, + _OP_OPENDIR: OpenOut{}, + _OP_POLL: _PollOut{}, + _OP_SETATTR: AttrOut{}, + _OP_STATFS: StatfsOut{}, + _OP_SYMLINK: EntryOut{}, + _OP_WRITE: WriteOut{}, + _OP_COPY_FILE_RANGE_64: CopyFileRangeOut{}, + } { + operationHandlers[op].OutType = f + operationHandlers[op].OutputSize = typSize(f) + } + + // Inputs. + for op, f := range map[uint32]any{ + _OP_ACCESS: AccessIn{}, + _OP_BATCH_FORGET: _BatchForgetIn{}, + _OP_BMAP: _BmapIn{}, + _OP_COPY_FILE_RANGE: CopyFileRangeIn{}, + _OP_CREATE: CreateIn{}, + _OP_TMPFILE: CreateIn{}, + _OP_FALLOCATE: FallocateIn{}, + _OP_FLUSH: FlushIn{}, + _OP_FORGET: ForgetIn{}, + _OP_FSYNC: FsyncIn{}, + _OP_FSYNCDIR: FsyncIn{}, + _OP_GETATTR: GetAttrIn{}, + _OP_GETLK: LkIn{}, + _OP_GETXATTR: GetXAttrIn{}, + _OP_INIT: InitIn{}, + _OP_INTERRUPT: InterruptIn{}, + _OP_IOCTL: IoctlIn{}, + _OP_LINK: LinkIn{}, + _OP_LISTXATTR: GetXAttrIn{}, + _OP_LSEEK: LseekIn{}, + _OP_MKDIR: MkdirIn{}, + _OP_MKNOD: MknodIn{}, + _OP_NOTIFY_REPLY: NotifyRetrieveIn{}, + _OP_OPEN: OpenIn{}, + _OP_OPENDIR: OpenIn{}, + _OP_POLL: _PollIn{}, + _OP_READ: ReadIn{}, + _OP_READDIR: ReadIn{}, + _OP_READDIRPLUS: ReadIn{}, + _OP_RELEASE: ReleaseIn{}, + _OP_RELEASEDIR: ReleaseIn{}, + _OP_RENAME2: RenameIn{}, + _OP_RENAME: RenameIn{}, + _OP_SETATTR: SetAttrIn{}, + _OP_SETLK: LkIn{}, + _OP_SETLKW: LkIn{}, + _OP_SETXATTR: SetXAttrIn{}, + _OP_WRITE: WriteIn{}, + _OP_COPY_FILE_RANGE_64: CopyFileRangeIn{}, + } { + operationHandlers[op].InType = f + operationHandlers[op].InputSize = typSize(f) + } + + // File name args. + for op, count := range map[uint32]int{ + _OP_CREATE: 1, + // TMPFILE goes through the same kernel code as CREATE, so it carries a name as well: the name of the + // anonymous dentry the kernel makes for the file, which is always "/" and names nothing. + _OP_TMPFILE: 1, + _OP_SETXATTR: 1, + _OP_GETXATTR: 1, + _OP_LINK: 1, + _OP_LOOKUP: 1, + _OP_MKDIR: 1, + _OP_MKNOD: 1, + _OP_REMOVEXATTR: 1, + _OP_RENAME: 2, + _OP_RENAME2: 2, + _OP_RMDIR: 1, + _OP_SYMLINK: 2, + _OP_UNLINK: 1, + } { + operationHandlers[op].FileNames = count + } + + checkFixedBufferSize() +} + +func checkFixedBufferSize() { + var r requestAlloc + for code, h := range operationHandlers { + if h.OutputSize > unsafe.Sizeof(r.outDataInline) { + log.Panicf("request output buffer too small: code %v, sz %d %v", code, h.OutputSize, h) + } + if maxInputSize < h.InputSize { + maxInputSize = h.InputSize + } + } +} diff --git a/third_party/go-fuse/fuse/opcode_darwin.go b/third_party/go-fuse/fuse/opcode_darwin.go new file mode 100644 index 000000000..a3677108c --- /dev/null +++ b/third_party/go-fuse/fuse/opcode_darwin.go @@ -0,0 +1,27 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import "unsafe" + +const _OP_MONITOR = uint32(60) + +func doMonitor(_ *protocolServer, req *request) { + // macFUSE sends watcher-monitor notifications for files and directories. + // They are advisory and do not require a reply. + req.status = OK +} + +func init() { + operationHandlers[_OP_MONITOR] = &operationHandler{ + Name: "MONITOR", + Func: doMonitor, + InputSize: unsafe.Sizeof(MonitorIn{}), + InType: MonitorIn{}, + SuppressReply: true, + } + + checkFixedBufferSize() +} diff --git a/third_party/go-fuse/fuse/opcode_linux.go b/third_party/go-fuse/fuse/opcode_linux.go new file mode 100644 index 000000000..204faffd7 --- /dev/null +++ b/third_party/go-fuse/fuse/opcode_linux.go @@ -0,0 +1,26 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import "unsafe" + +func doStatx(server *protocolServer, req *request) { + in := (*StatxIn)(req.inData()) + out := (*StatxOut)(req.outData()) + + req.status = server.fileSystem.Statx(req.cancel, in, out) +} + +func init() { + operationHandlers[_OP_STATX] = &operationHandler{ + Name: "STATX", + Func: doStatx, + InType: StatxIn{}, + OutType: StatxOut{}, + InputSize: unsafe.Sizeof(StatxIn{}), + OutputSize: unsafe.Sizeof(StatxOut{}), + } + checkFixedBufferSize() +} diff --git a/third_party/go-fuse/fuse/passthrough_linux.go b/third_party/go-fuse/fuse/passthrough_linux.go new file mode 100644 index 000000000..5efa5d03a --- /dev/null +++ b/third_party/go-fuse/fuse/passthrough_linux.go @@ -0,0 +1,61 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" + "unsafe" +) + +const ( + _DEV_IOC_BACKING_OPEN = 0x4010e501 + _DEV_IOC_BACKING_CLOSE = 0x4004e502 +) + +func (r *fuseFD) registerBackingFd(m *BackingMap) (int32, syscall.Errno) { + var id uintptr + var errno syscall.Errno + if cerr := r.withFD(func(fd int) { + id, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(_DEV_IOC_BACKING_OPEN), uintptr(unsafe.Pointer(m))) + }); cerr != nil { + errno = syscall.EBADF + } + if r.server.opts.Debug { + r.server.opts.Logger.Printf("ioctl: BACKING_OPEN %v: id %d (%v)", m.string(), id, errno) + } + return int32(id), errno +} + +func (r *fuseFD) unregisterBackingFd(id int32) syscall.Errno { + var errno syscall.Errno + if cerr := r.withFD(func(fd int) { + _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(_DEV_IOC_BACKING_CLOSE), uintptr(unsafe.Pointer(&id))) + }); cerr != nil { + errno = syscall.EBADF + } + + if r.server.opts.Debug { + r.server.opts.Logger.Printf("ioctl: BACKING_CLOSE id %d: %v", id, errno) + } + return errno +} + +// RegisterBackingFd registers the given file descriptor in the +// kernel, so the kernel can bypass FUSE and access the backing file +// directly for read and write calls. On success a backing ID is +// returned. The backing ID should unregistered using +// UnregisterBackingFd() once the file is released. Within the +// kernel, an inode can only have a single backing file, so multiple +// Open/Create calls should coordinate to return a consistent backing +// ID. +func (ms *Server) RegisterBackingFd(m *BackingMap) (int32, syscall.Errno) { + return ms.fuseFD.registerBackingFd(m) +} + +// UnregisterBackingFd unregisters the given ID in the kernel. The ID +// should have been acquired before using RegisterBackingFd. +func (ms *Server) UnregisterBackingFd(id int32) syscall.Errno { + return ms.fuseFD.unregisterBackingFd(id) +} diff --git a/third_party/go-fuse/fuse/poll.go b/third_party/go-fuse/fuse/poll.go new file mode 100644 index 000000000..937d1abbb --- /dev/null +++ b/third_party/go-fuse/fuse/poll.go @@ -0,0 +1,52 @@ +package fuse + +// Go 1.9 introduces polling for file I/O. The implementation causes +// the runtime's epoll to take up the last GOMAXPROCS slot, and if +// that happens, we won't have any threads left to service FUSE's +// _OP_POLL request. Prevent this by forcing _OP_POLL to happen, so we +// can say ENOSYS and prevent further _OP_POLL requests. +const pollHackName = ".go-fuse-epoll-hack" +const pollHackInode = ^uint64(0) + +func doPollHackLookup(ms *protocolServer, req *request) { + attr := Attr{ + Ino: pollHackInode, + Mode: S_IFREG | 0644, + Nlink: 1, + } + switch req.inHeader().Opcode { + case _OP_LOOKUP: + out := (*EntryOut)(req.outData()) + *out = EntryOut{ + NodeId: pollHackInode, + Attr: attr, + } + req.status = OK + case _OP_OPEN: + out := (*OpenOut)(req.outData()) + *out = OpenOut{ + Fh: pollHackInode, + } + req.status = OK + case _OP_GETATTR, _OP_SETATTR: + out := (*AttrOut)(req.outData()) + out.Attr = attr + req.status = OK + case _OP_GETXATTR: + // Kernel will try to read acl xattrs. Pretend we don't have any. + req.status = ENODATA + case _OP_POLL: + req.status = ENOSYS + + case _OP_ACCESS, _OP_FLUSH, _OP_RELEASE: + // Avoid upsetting the OSX mount process. + req.status = OK + default: + // We want to avoid switching off features through our + // poll hack, so don't use ENOSYS. It would be nice if + // we could transmit no error code at all, but for + // some opcodes, we'd have to invent credible data to + // return as well. + req.status = ERANGE + } +} diff --git a/third_party/go-fuse/fuse/poll_darwin.go b/third_party/go-fuse/fuse/poll_darwin.go new file mode 100644 index 000000000..802190002 --- /dev/null +++ b/third_party/go-fuse/fuse/poll_darwin.go @@ -0,0 +1,56 @@ +package fuse + +import ( + "path/filepath" + "syscall" + "unsafe" +) + +type pollFd struct { + Fd int32 + Events int16 + Revents int16 +} + +func sysPoll(fds []pollFd, timeout int) (n int, err error) { + r0, _, e1 := syscall.Syscall(syscall.SYS_POLL, uintptr(unsafe.Pointer(&fds[0])), + uintptr(len(fds)), uintptr(timeout)) + n = int(r0) + if e1 != 0 { + err = syscall.Errno(e1) + } + return n, err +} + +func pollHack(mountPoint string) error { + const ( + POLLIN = 0x1 + POLLPRI = 0x2 + POLLOUT = 0x4 + POLLRDHUP = 0x2000 + POLLERR = 0x8 + POLLHUP = 0x10 + ) + + fd, err := syscall.Open(filepath.Join(mountPoint, pollHackName), syscall.O_RDONLY, 0) + if err == syscall.EPERM { + // This can happen due to macos sandboxing, see + // https://github.com/hanwen/go-fuse/issues/572#issuecomment-2904052441 + // If we don't have access to access our own mount + // point, we also can't deadlock ourselves with poll, so this is fine. + return nil + } + if err != nil { + return err + } + pollData := []pollFd{{ + Fd: int32(fd), + Events: POLLIN | POLLPRI | POLLOUT, + }} + + // Trigger _OP_POLL, so we can say ENOSYS. We don't care about + // the return value. + sysPoll(pollData, 0) + syscall.Close(fd) + return nil +} diff --git a/third_party/go-fuse/fuse/poll_unix.go b/third_party/go-fuse/fuse/poll_unix.go new file mode 100644 index 000000000..17734e722 --- /dev/null +++ b/third_party/go-fuse/fuse/poll_unix.go @@ -0,0 +1,27 @@ +//go:build !darwin + +package fuse + +import ( + "path/filepath" + "syscall" + + "golang.org/x/sys/unix" +) + +func pollHack(mountPoint string) error { + fd, err := syscall.Open(filepath.Join(mountPoint, pollHackName), syscall.O_RDONLY, 0) + if err != nil { + return err + } + pollData := []unix.PollFd{{ + Fd: int32(fd), + Events: unix.POLLIN | unix.POLLPRI | unix.POLLOUT, + }} + + // Trigger _OP_POLL, so we can say ENOSYS. We don't care about + // the return value. + unix.Poll(pollData, 0) + syscall.Close(fd) + return nil +} diff --git a/third_party/go-fuse/fuse/print.go b/third_party/go-fuse/fuse/print.go new file mode 100644 index 000000000..96c6b4ee9 --- /dev/null +++ b/third_party/go-fuse/fuse/print.go @@ -0,0 +1,446 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "fmt" + "os" + "strings" + "syscall" +) + +var ( + isTest bool + writeFlagNames = newFlagNames([]flagNameEntry{ + {WRITE_CACHE, "CACHE"}, + {WRITE_LOCKOWNER, "LOCKOWNER"}, + {WRITE_KILL_SUIDGID, "KILL_SUIDGID"}, + }) + readFlagNames = newFlagNames([]flagNameEntry{ + {READ_LOCKOWNER, "LOCKOWNER"}, + }) + initFlagNames = newFlagNames([]flagNameEntry{ + {CAP_ASYNC_READ, "ASYNC_READ"}, + {CAP_POSIX_LOCKS, "POSIX_LOCKS"}, + {CAP_FILE_OPS, "FILE_OPS"}, + {CAP_ATOMIC_O_TRUNC, "ATOMIC_O_TRUNC"}, + {CAP_EXPORT_SUPPORT, "EXPORT_SUPPORT"}, + {CAP_BIG_WRITES, "BIG_WRITES"}, + {CAP_DONT_MASK, "DONT_MASK"}, + {CAP_SPLICE_WRITE, "SPLICE_WRITE"}, + {CAP_SPLICE_MOVE, "SPLICE_MOVE"}, + {CAP_SPLICE_READ, "SPLICE_READ"}, + {CAP_FLOCK_LOCKS, "FLOCK_LOCKS"}, + {CAP_IOCTL_DIR, "IOCTL_DIR"}, + {CAP_AUTO_INVAL_DATA, "AUTO_INVAL_DATA"}, + {CAP_READDIRPLUS, "READDIRPLUS"}, + {CAP_READDIRPLUS_AUTO, "READDIRPLUS_AUTO"}, + {CAP_ASYNC_DIO, "ASYNC_DIO"}, + {CAP_WRITEBACK_CACHE, "WRITEBACK_CACHE"}, + {CAP_NO_OPEN_SUPPORT, "NO_OPEN_SUPPORT"}, + {CAP_PARALLEL_DIROPS, "PARALLEL_DIROPS"}, + {CAP_POSIX_ACL, "POSIX_ACL"}, + {CAP_HANDLE_KILLPRIV, "HANDLE_KILLPRIV"}, + {CAP_ABORT_ERROR, "ABORT_ERROR"}, + {CAP_MAX_PAGES, "MAX_PAGES"}, + {CAP_CACHE_SYMLINKS, "CACHE_SYMLINKS"}, + {CAP_SECURITY_CTX, "SECURITY_CTX"}, + {CAP_HAS_INODE_DAX, "HAS_INODE_DAX"}, + {CAP_CREATE_SUPP_GROUP, "CREATE_SUPP_GROUP"}, + {CAP_HAS_EXPIRE_ONLY, "HAS_EXPIRE_ONLY"}, + {CAP_DIRECT_IO_ALLOW_MMAP, "DIRECT_IO_ALLOW_MMAP"}, + {CAP_PASSTHROUGH, "PASSTHROUGH"}, + {CAP_NO_EXPORT_SUPPORT, "NO_EXPORT_SUPPORT"}, + {CAP_HAS_RESEND, "HAS_RESEND"}, + {CAP_ALLOW_IDMAP, "ALLOW_IDMAP"}, + {CAP_OVER_IO_URING, "IO_URING"}, + {CAP_REQUEST_TIMEOUT, "REQUEST_TIMEOUT"}, + }) + releaseFlagNames = newFlagNames([]flagNameEntry{ + {RELEASE_FLUSH, "FLUSH"}, + }) + openFlagNames = newFlagNames([]flagNameEntry{ + {int64(os.O_WRONLY), "WRONLY"}, + {int64(os.O_RDWR), "RDWR"}, + {int64(os.O_APPEND), "APPEND"}, + {int64(syscall.O_ASYNC), "ASYNC"}, + {int64(os.O_CREATE), "CREAT"}, + {int64(os.O_EXCL), "EXCL"}, + {int64(syscall.O_NOCTTY), "NOCTTY"}, + {int64(syscall.O_NONBLOCK), "NONBLOCK"}, + {int64(os.O_SYNC), "SYNC"}, + {int64(os.O_TRUNC), "TRUNC"}, + {int64(syscall.O_CLOEXEC), "CLOEXEC"}, + {int64(syscall.O_DIRECTORY), "DIRECTORY"}, + }) + fuseOpenFlagNames = newFlagNames([]flagNameEntry{ + {FOPEN_DIRECT_IO, "DIRECT"}, + {FOPEN_KEEP_CACHE, "CACHE"}, + {FOPEN_NONSEEKABLE, "NONSEEK"}, + {FOPEN_CACHE_DIR, "CACHE_DIR"}, + {FOPEN_STREAM, "STREAM"}, + {FOPEN_NOFLUSH, "NOFLUSH"}, + {FOPEN_PARALLEL_DIRECT_WRITES, "PARALLEL_DIRECT_WRITES"}, + {FOPEN_PASSTHROUGH, "PASSTHROUGH"}, + }) + fuseOpenInFlagNames = newFlagNames([]flagNameEntry{ + {OPEN_KILL_SUIDGID, "KILL_SUIDGID"}, + }) + expireFlagNames = newFlagNames([]flagNameEntry{ + {EXPIRE_ONLY, "EXPIRE_ONLY"}, + }) + ioctlFlagNames = newFlagNames([]flagNameEntry{ + {IOCTL_COMPAT, "COMPAT"}, + {IOCTL_UNRESTRICTED, "UNRESTRICTED"}, + {IOCTL_RETRY, "RETRY"}, + {IOCTL_DIR, "DIR"}, + }) + accessFlagName = newFlagNames([]flagNameEntry{ + {X_OK, "x"}, + {W_OK, "w"}, + {R_OK, "r"}, + }) + getAttrFlagNames = newFlagNames([]flagNameEntry{ + {FUSE_GETATTR_FH, "FH"}, + }) + renameFlagNames = newFlagNames([]flagNameEntry{ + {1, "NOREPLACE"}, + {2, "EXCHANGE"}, + {4, "WHITEOUT"}, + }) + lockFlagNames = newFlagNames([]flagNameEntry{ + {(1 << 0), "FLOCK"}, + }) +) + +// flagNames associate flag bits to their names. +type flagNames [64]flagNameEntry + +// flagNameEntry describes one flag value. +// +// Usually a flag constitues only one bit, but, for example at least O_SYNC and +// O_TMPFILE are represented by a value with two bits set. To handle such +// situations we map all bits of a flag to the same flagNameEntry. +type flagNameEntry struct { + bits int64 + name string +} + +// newFlagNames creates flagNames from flag->name map. +func newFlagNames(names []flagNameEntry) *flagNames { + var v flagNames + for _, e := range names { + v.set(e.bits, e.name) + } + return &v +} + +// set associates flag value with name. +func (names *flagNames) set(flag int64, name string) { + entry := flagNameEntry{bits: flag, name: name} + for i := 0; i < 64; i++ { + if flag&(1< float(seconds) +func ft(tsec uint64, tnsec uint32) float64 { + return float64(tsec) + float64(tnsec)*1e-9 +} + +// Returned by LOOKUP +func (o *EntryOut) string() string { + return fmt.Sprintf("{n%d g%d tE=%gs tA=%gs %v}", + o.NodeId, o.Generation, ft(o.EntryValid, o.EntryValidNsec), + ft(o.AttrValid, o.AttrValidNsec), &o.Attr) +} + +func (o *CreateOut) string() string { + return fmt.Sprintf("{n%d g%d %v %v}", o.NodeId, o.Generation, &o.EntryOut, o.OpenOut.string()) +} + +func (o *StatfsOut) string() string { + return fmt.Sprintf( + "{blocks (%d,%d)/%d files %d/%d bs%d nl%d frs%d}", + o.Bfree, o.Bavail, o.Blocks, o.Ffree, o.Files, + o.Bsize, o.NameLen, o.Frsize) +} + +func (o *NotifyInvalEntryOut) string() string { + return fmt.Sprintf("{parent i%d sz %d%s}", o.Parent, o.NameLen, + optFlagString(expireFlagNames, int64(o.Flags))) +} + +func (o *NotifyInvalInodeOut) string() string { + return fmt.Sprintf("{i%d [%d +%d)}", o.Ino, o.Off, o.Length) +} + +func (o *NotifyInvalDeleteOut) string() string { + return fmt.Sprintf("{parent i%d ch i%d sz %d}", o.Parent, o.Child, o.NameLen) +} + +func (o *NotifyStoreOut) string() string { + return fmt.Sprintf("{n%d [%d +%d)}", o.Nodeid, o.Offset, o.Size) +} + +func (o *NotifyRetrieveOut) string() string { + return fmt.Sprintf("{> %d: n%d [%d +%d)}", o.NotifyUnique, o.Nodeid, o.Offset, o.Size) +} + +func (i *NotifyRetrieveIn) string() string { + return fmt.Sprintf("{[%d +%d)}", i.Offset, i.Size) +} + +func (o *NotifyPruneOut) string() string { + return fmt.Sprintf("{%d}", o.Count) +} + +func (f *FallocateIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) mod 0%o}", + f.Fh, f.Offset, f.Length, f.Mode) +} + +func (f *LinkIn) string() string { + return fmt.Sprintf("{Oldnodeid: n%d}", f.Oldnodeid) +} + +func (o *WriteOut) string() string { + return fmt.Sprintf("{%db }", o.Size) + +} +func (i *CopyFileRangeIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) => n%d Fh %d [%d, %d)}", + i.FhIn, i.OffIn, i.Len, i.NodeIdOut, i.FhOut, i.OffOut, i.Len) +} + +func (in *InterruptIn) string() string { + return fmt.Sprintf("{ix %d}", in.Unique) +} + +var seekNames = map[uint32]string{ + 0: "SET", + 1: "CUR", + 2: "END", + 3: "DATA", + 4: "HOLE", +} + +func (in *LseekIn) string() string { + return fmt.Sprintf("{Fh %d [%s +%d)}", in.Fh, + seekNames[in.Whence], in.Offset) +} + +func (o *LseekOut) string() string { + return fmt.Sprintf("{%d}", o.Offset) +} + +func (p *_PollIn) string() string { + return fmt.Sprintf("Fh %d Kh %d Flags 0x%x", p.Fh, p.Kh, p.Flags) +} + +// Print pretty prints FUSE data types for kernel communication +func Print(obj any) string { + t, ok := obj.(interface { + string() string + }) + if ok { + return t.string() + } + return fmt.Sprintf("%T: %v", obj, obj) +} + +func (a *Attr) string() string { + return fmt.Sprintf( + "{M0%o SZ=%d L=%d "+ + "%d:%d "+ + "B%d*%d i%d:%d "+ + "A %f "+ + "M %f "+ + "C %f%s}", + a.Mode, a.Size, a.Nlink, + a.Uid, a.Gid, + a.Blocks, a.Blksize, + a.Rdev, a.Ino, ft(a.Atime, a.Atimensec), ft(a.Mtime, a.Mtimensec), + ft(a.Ctime, a.Ctimensec), a.flagsString()) +} + +func (m *BackingMap) string() string { + return fmt.Sprintf("{fd %d, flags 0x%x}", m.Fd, m.Flags) +} + +func (o *IoctlIn) string() string { + return fmt.Sprintf("{Fh %d Flags %s Cmd 0x%x Arg 0x%x, insz %d outsz %d}", + o.Fh, + flagString(ioctlFlagNames, int64(o.Flags), ""), + o.Cmd, o.Arg, o.InSize, o.OutSize) +} + +func (o *IoctlOut) string() string { + return fmt.Sprintf("{Result %d Flags %s Iovs %d/%d", + o.Result, + flagString(ioctlFlagNames, int64(o.Flags), ""), + o.InIovs, o.OutIovs) +} + +func (l *LkIn) string() string { + return fmt.Sprintf("{Fh %d Owner %d %s %s}", l.Owner, l.Owner, l.Lk.string(), flagString(lockFlagNames, int64(l.LkFlags), "")) +} + +func (lk *FileLock) string() string { + return fmt.Sprintf("{[%d,%d) t%d p%d}", lk.Start, lk.End, lk.Typ, lk.Pid) +} + +func (l *LkOut) string() string { + return l.Lk.string() +} diff --git a/third_party/go-fuse/fuse/print_darwin.go b/third_party/go-fuse/fuse/print_darwin.go new file mode 100644 index 000000000..7f90cd0e9 --- /dev/null +++ b/third_party/go-fuse/fuse/print_darwin.go @@ -0,0 +1,50 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "fmt" +) + +func init() { + initFlagNames.set(CAP_NODE_RWLOCK, "NODE_RWLOCK") + initFlagNames.set(CAP_RENAME_SWAP, "RENAME_SWAP") + initFlagNames.set(CAP_RENAME_EXCL, "RENAME_EXCL") + initFlagNames.set(CAP_ALLOCATE, "ALLOCATE") + initFlagNames.set(CAP_EXCHANGE_DATA, "EXCHANGE_DATA") + initFlagNames.set(CAP_XTIMES, "XTIMES") + initFlagNames.set(CAP_VOL_RENAME, "VOL_RENAME") + initFlagNames.set(CAP_CASE_INSENSITIVE, "CASE_INSENSITIVE") +} + +func (me *CreateIn) string() string { + return fmt.Sprintf( + "{0%o [%s]}", me.Mode, + flagString(openFlagNames, int64(me.Flags), "O_RDONLY")) +} + +func (me *GetAttrIn) string() string { return "" } + +func (a *Attr) flagsString() string { return "" } + +func (in *SetXAttrIn) string() string { + return fmt.Sprintf("{sz %d f%o}", in.Size, in.Flags) +} + +func (me *MknodIn) string() string { + return fmt.Sprintf("{0%o, %d}", me.Mode, me.Rdev) +} + +func (me *ReadIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) %s}", + me.Fh, me.Offset, me.Size, + flagString(readFlagNames, int64(me.ReadFlags), "")) +} + +func (me *WriteIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) %s}", + me.Fh, me.Offset, me.Size, + flagString(writeFlagNames, int64(me.WriteFlags), "")) +} diff --git a/third_party/go-fuse/fuse/print_freebsd.go b/third_party/go-fuse/fuse/print_freebsd.go new file mode 100644 index 000000000..61a6c1367 --- /dev/null +++ b/third_party/go-fuse/fuse/print_freebsd.go @@ -0,0 +1,5 @@ +package fuse + +func init() { + initFlagNames.set(CAP_NO_OPENDIR_SUPPORT, "NO_OPENDIR_SUPPORT") +} diff --git a/third_party/go-fuse/fuse/print_linux.go b/third_party/go-fuse/fuse/print_linux.go new file mode 100644 index 000000000..2d896c5e1 --- /dev/null +++ b/third_party/go-fuse/fuse/print_linux.go @@ -0,0 +1,92 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "fmt" + "runtime" + "strings" + "syscall" + + "golang.org/x/sys/unix" +) + +var statxFieldFlags = newFlagNames([]flagNameEntry{ + {unix.STATX_ATIME, "Atime"}, + {unix.STATX_BLOCKS, "blocks"}, + {unix.STATX_BTIME, "Btime"}, + {unix.STATX_CTIME, "Ctime"}, + {unix.STATX_GID, "Gid"}, + {unix.STATX_INO, "Ino"}, + {unix.STATX_MNT_ID, "Mntid"}, + {unix.STATX_MODE, "Mode"}, + {unix.STATX_MTIME, "Mtime"}, + {unix.STATX_NLINK, "Nlink"}, + {unix.STATX_SIZE, "Size"}, + {unix.STATX_TYPE, "Type"}, + {unix.STATX_UID, "Uid"}, +}) + +func init() { + // syscall.O_LARGEFILE is 0x0 on x86_64, but the kernel + // supplies 0x8000 anyway, except on mips64el, where 0x8000 is + // used for O_DIRECT. + if !strings.Contains(runtime.GOARCH, "mips64") { + openFlagNames.set(0x8000, "LARGEFILE") + } + + openFlagNames.set(syscall.O_DIRECT, "DIRECT") + openFlagNames.set(syscall_O_NOATIME, "NOATIME") + openFlagNames.set(FMODE_EXEC, "EXEC") + initFlagNames.set(CAP_NO_OPENDIR_SUPPORT, "NO_OPENDIR_SUPPORT") + initFlagNames.set(CAP_EXPLICIT_INVAL_DATA, "EXPLICIT_INVAL_DATA") + initFlagNames.set(CAP_MAP_ALIGNMENT, "MAP_ALIGNMENT") + initFlagNames.set(CAP_SUBMOUNTS, "SUBMOUNTS") + initFlagNames.set(CAP_HANDLE_KILLPRIV_V2, "HANDLE_KILLPRIV_V2") + initFlagNames.set(CAP_SETXATTR_EXT, "SETXATTR_EXT") + initFlagNames.set(CAP_INIT_EXT, "INIT_EXT") + initFlagNames.set(CAP_INIT_RESERVED, "INIT_RESERVED") +} + +func (a *Statx) string() string { + var ss []string + if a.Mask&unix.STATX_MODE != 0 || a.Mask&unix.STATX_TYPE != 0 { + ss = append(ss, fmt.Sprintf("M0%o", a.Mode)) + } + if a.Mask&unix.STATX_SIZE != 0 { + ss = append(ss, fmt.Sprintf("SZ=%d", a.Size)) + } + if a.Mask&unix.STATX_NLINK != 0 { + ss = append(ss, fmt.Sprintf("L=%d", a.Nlink)) + } + if a.Mask&unix.STATX_UID != 0 || a.Mask&unix.STATX_GID != 0 { + ss = append(ss, fmt.Sprintf("%d:%d", a.Uid, a.Gid)) + } + if a.Mask&unix.STATX_INO != 0 { + ss = append(ss, fmt.Sprintf("i%d", a.Ino)) + } + if a.Mask&unix.STATX_ATIME != 0 { + ss = append(ss, fmt.Sprintf("A %f", a.Atime.Seconds())) + } + if a.Mask&unix.STATX_BTIME != 0 { + ss = append(ss, fmt.Sprintf("B %f", a.Btime.Seconds())) + } + if a.Mask&unix.STATX_CTIME != 0 { + ss = append(ss, fmt.Sprintf("C %f", a.Ctime.Seconds())) + } + if a.Mask&unix.STATX_MTIME != 0 { + ss = append(ss, fmt.Sprintf("M %f", a.Mtime.Seconds())) + } + if a.Mask&unix.STATX_BLOCKS != 0 { + ss = append(ss, fmt.Sprintf("%d*%d", a.Blocks, a.Blksize)) + } + + return "{" + strings.Join(ss, " ") + "}" +} + +func (in *StatxIn) string() string { + return fmt.Sprintf("{Fh %d %s 0x%x %s}", in.Fh, flagString(getAttrFlagNames, int64(in.GetattrFlags), ""), + in.SxFlags, flagString(statxFieldFlags, int64(in.SxMask), "")) +} diff --git a/third_party/go-fuse/fuse/print_unix.go b/third_party/go-fuse/fuse/print_unix.go new file mode 100644 index 000000000..36150e1f9 --- /dev/null +++ b/third_party/go-fuse/fuse/print_unix.go @@ -0,0 +1,55 @@ +//go:build !darwin + +package fuse + +import "fmt" + +var ( + attrFlagNames = newFlagNames([]flagNameEntry{ + {ATTR_SUBMOUNT, "SUBMOUNT"}, + {ATTR_DAX, "DAX"}, + }) + setXAttrFlagNames = newFlagNames([]flagNameEntry{ + {SETXATTR_ACL_KILL_SGID, "ACL_KILL_SGID"}, + }) +) + +func (in *CreateIn) string() string { + return fmt.Sprintf( + "{0%o [%s] (0%o)%s}", in.Mode, + flagString(openFlagNames, int64(in.Flags), "O_RDONLY"), in.Umask, + optFlagString(fuseOpenInFlagNames, int64(in.OpenFlags))) +} + +func (a *Attr) flagsString() string { + return optFlagString(attrFlagNames, int64(a.Flags)) +} + +func (in *SetXAttrIn) string() string { + return fmt.Sprintf("{sz %d f%o%s}", in.Size, in.Flags, + optFlagString(setXAttrFlagNames, int64(in.SetXAttrFlags))) +} + +func (in *GetAttrIn) string() string { + return fmt.Sprintf("{Fh %d %s}", in.Fh_, flagString(getAttrFlagNames, int64(in.Flags_), "")) +} + +func (in *MknodIn) string() string { + return fmt.Sprintf("{0%o (0%o), %d}", in.Mode, in.Umask, in.Rdev) +} + +func (in *ReadIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) %s L %d %s}", + in.Fh, in.Offset, in.Size, + flagString(readFlagNames, int64(in.ReadFlags), ""), + in.LockOwner, + flagString(openFlagNames, int64(in.Flags), "RDONLY")) +} + +func (in *WriteIn) string() string { + return fmt.Sprintf("{Fh %d [%d +%d) %s L %d %s}", + in.Fh, in.Offset, in.Size, + flagString(writeFlagNames, int64(in.WriteFlags), ""), + in.LockOwner, + flagString(openFlagNames, int64(in.Flags), "RDONLY")) +} diff --git a/third_party/go-fuse/fuse/protocol-server.go b/third_party/go-fuse/fuse/protocol-server.go new file mode 100644 index 000000000..96f5b0733 --- /dev/null +++ b/third_party/go-fuse/fuse/protocol-server.go @@ -0,0 +1,437 @@ +// Copyright 2024 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "sync" + "syscall" + "unsafe" +) + +// protocolServer bridges from the FUSE datatypes to a RawFileSystem +type protocolServer struct { + fileSystem RawFileSystem + + writev func([][]byte) (int, syscall.Errno) + + interruptMu sync.Mutex + reqInflight []*request + connectionDead bool + + kernelSettings InitIn + + // If set, handleRequest does not flatten withReadv results + // with Bytes(), but leaves them in req.readResult, so + // ProtocolServer.HandleRequest can Readv them straight into + // the reply iov. + useReadv bool + + // Capabilities sent in InitOut. + negotiatedFlags uint64 + + opts *MountOptions + + // Pools for []byte + buffers bufferPool + + writevCopyOnce sync.Once + + // in-flight notify-retrieve queries + retrieveMu sync.Mutex + retrieveNext uint64 + retrieveTab map[uint64]*retrieveCacheRequest // notifyUnique -> retrieve request +} + +func (ms *protocolServer) handleRequest(h *operationHandler, req *request) { + ms.addInflight(req) + defer ms.dropInflight(req) + + if req.status.Ok() && ms.opts.Debug { + ms.opts.Logger.Println(req.InputDebug()) + } + + if h == nil || h.Func == nil { + c := req.inHeader().Opcode + if c != _OP_COPY_FILE_RANGE_64 { // _OP_COPY_FILE_RANGE_64 is intentionally not supported. + ms.opts.Logger.Printf("Unimplemented opcode %v", operationName(c)) + } + req.status = ENOSYS + } else if !req.status.Ok() { + // Reply with the error from parsing the request. + } else if req.inHeader().NodeId == pollHackInode || + req.inHeader().NodeId == FUSE_ROOT_ID && h.FileNames > 0 && req.filename() == pollHackName { + doPollHackLookup(ms, req) + } else { + func() { + defer func() { + if r := recover(); r != nil { + req.status = ms.opts.PanicHandler(r) + if req.status == 0 { + req.status = EIO + } + } + }() + h.Func(ms, req) + }() + } + + // Forget/NotifyReply do not wait for reply from filesystem server. + switch req.inHeader().Opcode { + case _OP_INTERRUPT: + // ? what other status can interrupt generate? + if req.status.Ok() { + req.suppressReply = true + } + default: + req.suppressReply = h != nil && h.SuppressReply + } + if req.status == EINTR { + ms.interruptMu.Lock() + dead := ms.connectionDead + ms.interruptMu.Unlock() + if dead { + req.suppressReply = true + } + } + if req.suppressReply { + return + } + if req.readResult != nil && ms.opts.DisableSplice { + _, vectored := req.readResult.(withSlice) + _, readv := req.readResult.(withReadv) + if !vectored && !(readv && ms.useReadv) { + req.outPayload, req.status = req.readResult.Bytes(req.outPayload) + req.readResult.Done() + req.readResult = nil + } + } + if req.status > OK && req.readResult != nil { + req.readResult.Done() + req.readResult = nil + } + + req.serializeHeader(req.outPayloadSize()) + + if ms.opts.Debug { + ms.opts.Logger.Println(req.OutputDebug()) + } +} + +func (ms *protocolServer) addInflight(req *request) { + ms.interruptMu.Lock() + defer ms.interruptMu.Unlock() + req.inflightIndex = len(ms.reqInflight) + ms.reqInflight = append(ms.reqInflight, req) +} + +func (ms *protocolServer) dropInflight(req *request) { + ms.interruptMu.Lock() + defer ms.interruptMu.Unlock() + this := req.inflightIndex + last := len(ms.reqInflight) - 1 + if last != this { + ms.reqInflight[this] = ms.reqInflight[last] + ms.reqInflight[this].inflightIndex = this + } + ms.reqInflight = ms.reqInflight[:last] +} + +func (ms *protocolServer) interruptRequest(unique uint64) Status { + ms.interruptMu.Lock() + defer ms.interruptMu.Unlock() + + // This is slow, but this operation is rare. + for _, inflight := range ms.reqInflight { + if unique == inflight.inHeader().Unique && !inflight.interrupted { + close(inflight.cancel) + inflight.interrupted = true + return OK + } + } + + return EAGAIN +} + +func (ms *protocolServer) cancelAll() { + ms.interruptMu.Lock() + defer ms.interruptMu.Unlock() + ms.connectionDead = true + for _, req := range ms.reqInflight { + if !req.interrupted { + close(req.cancel) + req.interrupted = true + } + } + // Leave ms.reqInflight alone, or dropInflight will barf. +} + +// ProtocolServer bridges from FUSE request/response types to the +// Go-FUSE RawFileSystem API calls. +// +// EXPERIMENTAL: not subject to API stability. +type ProtocolServer struct { + protocolServer +} + +// NewProtocolServer creates a ProtocolServer for the RawFileSystem. +// +// EXPERIMENTAL: not subject to API stability. +func NewProtocolServer(fs RawFileSystem, opts *MountOptions) *ProtocolServer { + // ProtocolServer has no pipe, so splicing READ results to the + // caller is not possible; force the in-process READ path. + optsCopy := *opts + optsCopy.setDefaults(fs) + optsCopy.DisableSplice = true + + return &ProtocolServer{ + protocolServer: protocolServer{ + fileSystem: fs, + retrieveTab: make(map[uint64]*retrieveCacheRequest), + opts: &optsCopy, + useReadv: true, + }, + } +} + +func iovLen(iov [][]byte) int { + var r int + for _, e := range iov { + r += len(e) + } + return r +} + +// iovSlice returns iov[off:off+n] if it lies within one element. +func iovSlice(iov [][]byte, off, n int) ([]byte, bool) { + for _, e := range iov { + if off < len(e) { + if off+n <= len(e) { + return e[off : off+n], true + } + return nil, false + } + off -= len(e) + } + return nil, n == 0 +} + +func copyFromIov(dst []byte, iov [][]byte, off int) int { + var n int + for _, e := range iov { + if len(dst) == n { + break + } + if off >= len(e) { + off -= len(e) + continue + } + n += copy(dst[n:], e[off:]) + off = 0 + } + return n +} + +type iovWriter struct { + dst [][]byte + i, off int + written int +} + +func (w *iovWriter) next(n int) []byte { + for w.i < len(w.dst) && w.off == len(w.dst[w.i]) { + w.i++ + w.off = 0 + } + if w.i == len(w.dst) { + return nil + } + chunk := w.dst[w.i][w.off:] + chunk = chunk[:min(len(chunk), n)] + w.off += len(chunk) + w.written += len(chunk) + return chunk +} + +func (w *iovWriter) write(src []byte) int { + var n int + for n < len(src) { + chunk := w.next(len(src) - n) + if chunk == nil { + break + } + n += copy(chunk, src[n:]) + } + return n +} + +func (w *iovWriter) skip(n int) { + for n > 0 { + chunk := w.next(n) + if chunk == nil { + break + } + n -= len(chunk) + } +} + +// sliceIov returns the iov for bytes [off, off+n). +func sliceIov(iov [][]byte, off, n int) [][]byte { + var r [][]byte + for _, e := range iov { + if n == 0 { + break + } + if off >= len(e) { + off -= len(e) + continue + } + e = e[off:] + off = 0 + e = e[:min(len(e), n)] + n -= len(e) + r = append(r, e) + } + return r +} + +func copyToIov(dst [][]byte, src ...[]byte) int { + w := iovWriter{dst: dst} + for _, s := range src { + w.write(s) + } + return w.written +} + +// HandleRequest parses the iov in `in`, calls into the raw +// filesystem, and puts the result in `out`. The iovs are treated +// as byte streams. The return value is the number of bytes written. +// +// EXPERIMENTAL: not subject to API stability. +func (ps *ProtocolServer) HandleRequest(in [][]byte, out [][]byte) (int, Status) { + req := &requestAlloc{ + request: request{ + cancel: make(chan struct{}), + }, + } + + total := iovLen(in) + n := copyFromIov(req.smallInputBuf[:min(total, len(req.smallInputBuf))], in, 0) + if n < int(unsafe.Sizeof(InHeader{})) { + ps.opts.Logger.Printf("request too short: %v", iovLens(in)) + return 0, EIO + } + h, inSize, outSize, outPayloadSize, code := parseRequest(req.smallInputBuf[:n], &ps.kernelSettings, ps.negotiatedFlags) + if !code.Ok() && code != ENOSYS { + ps.opts.Logger.Printf("parseRequest: %v", code) + } + req.inputBuf = req.smallInputBuf[:n] + req.status = code + if code.Ok() { + req.inputBuf = req.inputBuf[:inSize] + payloadLen := total - inSize + if b, ok := iovSlice(in, inSize, payloadLen); ok { + req.inPayload = b + } else if req.inHeader().Opcode == _OP_WRITE { + req.inPayloadIov = sliceIov(in, inSize, payloadLen) + } else { + // IOCTL data comes in one element per page. Other + // payloads (names, xattr values, BATCH_FORGET + // entries, extensions) are only split if they + // cross a guest memory region boundary. + req.inPayload = make([]byte, payloadLen) + copyFromIov(req.inPayload, in, inSize) + } + req.extendInput(int(h.InputSize)) + req.status = req.splitExt(&req.extInline) + if !req.status.Ok() { + ps.opts.Logger.Printf("op %s: bad request extension: %v", h.Name, req.status) + } + } + + req.outHeaderBuf = req.outHeaderInline[:] + req.outDataBuf = req.outDataInline[:outSize] + clear(req.outHeaderBuf) + clear(req.outDataBuf) + + var direct []byte + suppressReply := h != nil && h.SuppressReply + if !suppressReply && req.status.Ok() { + need := int(sizeOfOutHeader) + outSize + outPayloadSize + if iovLen(out) < need { + ps.opts.Logger.Printf("op %v: got out iov %v, need %d bytes", h.Name, iovLens(out), need) + return 0, EIO + } + if outPayloadSize > 0 { + if b, ok := iovSlice(out, int(sizeOfOutHeader)+outSize, outPayloadSize); ok { + direct = b + req.outPayload = b + } else { + req.outPayload = ps.buffers.AllocBuffer(uint32(outPayloadSize)) + defer ps.buffers.FreeBuffer(req.outPayload) + } + } + } + + ps.protocolServer.handleRequest(h, &req.request) + if req.suppressReply { + return 0, OK + } + + // Per the virtio spec, vring_used_elem.len should hold the + // number of bytes the device wrote to the descriptor + // chain. Returning more inflates the live-migration dirty-page + // log and violates the spec. + w := iovWriter{dst: out} + if req.readResult != nil { + // The size is only known after the read, so redo the header. + payloadOff := int(sizeOfOutHeader) + len(req.outDataBuf) + dst := sliceIov(out, payloadOff, req.readResult.Size()) + n, code := readIntoIov(req.readResult, dst) + req.readResult.Done() + req.readResult = nil + if !code.Ok() { + req.status = code + n = 0 + } + req.serializeHeader(n) + w.write(req.outHeaderBuf) + w.write(req.outDataBuf) + w.skip(n) + return w.written, OK + } + + w.write(req.outHeaderBuf) + w.write(req.outDataBuf) + if len(req.outPayload) > 0 && len(direct) > 0 && &req.outPayload[0] == &direct[0] { + // The filesystem filled the out iov in place; count + // the bytes without copying. + w.skip(len(req.outPayload)) + } else { + w.write(req.outPayload) + } + return w.written, OK +} + +func readIntoIov(rr ReadResult, dst [][]byte) (int, Status) { + switch r := rr.(type) { + case withReadv: + return r.Readv(dst) + case withSlice: + slices, code := r.Slices() + if !code.Ok() { + return 0, code + } + return copyToIov(dst, slices...), OK + } + return 0, EIO +} + +func iovLens(in [][]byte) []int { + var lens []int + for _, b := range in { + lens = append(lens, len(b)) + } + return lens +} diff --git a/third_party/go-fuse/fuse/read.go b/third_party/go-fuse/fuse/read.go new file mode 100644 index 000000000..2f99df75d --- /dev/null +++ b/third_party/go-fuse/fuse/read.go @@ -0,0 +1,142 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "io" + "syscall" +) + +// ReadResultData is the read return for returning bytes directly. +type readResultData struct { + // Raw bytes for the read. + Data []byte +} + +func (r *readResultData) Size() int { + return len(r.Data) +} + +func (r *readResultData) Done() { +} + +func (r *readResultData) Bytes(buf []byte) ([]byte, Status) { + return r.Data, OK +} + +func (r *readResultData) Readv(dst [][]byte) (int, Status) { + return copyToIov(dst, r.Data), OK +} + +func ReadResultData(b []byte) ReadResult { + return &readResultData{b} +} + +func ReadResultFd(fd uintptr, off int64, sz int) ReadResult { + return &readResultFd{fd, off, sz} +} + +type seekableResult interface { + Seekable() (fd uintptr, off int64, sz int) +} + +type statefulResult interface { + Stateful() (fd uintptr, sz int) +} + +type withSlice interface { + // Slices may be called more than once and must return the same data each time. + Slices() ([][]byte, Status) +} + +// withReadv is a ReadResult that can write directly into a scatter +// list, e.g. virtiofs guest memory. +type withReadv interface { + Readv(dst [][]byte) (int, Status) +} + +// withSpliceFlags is a ReadResult carrying splice(2) flags for the write to +// /dev/fuse. +type withSpliceFlags interface { + SpliceFlags() int +} + +// ReadResultFd is the read return for zero-copy file data. +type readResultFd struct { + // Splice from the following file. + Fd uintptr + + // Offset within Fd, or -1 to use current offset. + Off int64 + + // Size of data to be loaded. Actual data available may be + // less at the EOF. + Sz int +} + +func (r *readResultFd) Seekable() (fd uintptr, off int64, sz int) { + return r.Fd, r.Off, r.Sz +} + +// Reads raw bytes from file descriptor if necessary, using the passed +// buffer as storage. +func (r *readResultFd) Bytes(buf []byte) ([]byte, Status) { + sz := min(len(buf), r.Sz) + + n, err := syscall.Pread(int(r.Fd), buf[:sz], r.Off) + if err == io.EOF { + err = nil + } + + if n < 0 { + n = 0 + } + + return buf[:n], ToStatus(err) +} + +func (r *readResultFd) Size() int { + return r.Sz +} + +func (r *readResultFd) Done() { +} + +// readResultVector is the read return for scatter-gather I/O. It implements +// the withSlice interface so the kernel write uses writev(2) directly, +// avoiding a copy into a single contiguous buffer. +type readResultVector struct { + vecs [][]byte +} + +func (r *readResultVector) Size() int { + return iovLen(r.vecs) +} + +func (r *readResultVector) Done() {} + +// Bytes concatenates the vector into buf, reusing its capacity. +func (r *readResultVector) Bytes(buf []byte) ([]byte, Status) { + buf = buf[:0] + for _, v := range r.vecs { + buf = append(buf, v...) + } + return buf, OK +} + +func (r *readResultVector) Readv(dst [][]byte) (int, Status) { + return copyToIov(dst, r.vecs...), OK +} + +func (r *readResultVector) Slices() ([][]byte, Status) { + return r.vecs, OK +} + +// ReadResultVector returns a ReadResult for scatter-gather I/O. +// When the kernel write path supports it, the buffers are sent via +// writev(2) without being copied into a single contiguous region. +func ReadResultVector(vecs [][]byte) ReadResult { + return &readResultVector{vecs} +} diff --git a/third_party/go-fuse/fuse/read_linux.go b/third_party/go-fuse/fuse/read_linux.go new file mode 100644 index 000000000..de95b98a5 --- /dev/null +++ b/third_party/go-fuse/fuse/read_linux.go @@ -0,0 +1,22 @@ +// Copyright 2026 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import "golang.org/x/sys/unix" + +func (r *readResultFd) Readv(dst [][]byte) (int, Status) { + dst = sliceIov(dst, 0, r.Sz) + var n int + var err error + if r.Off == -1 { + n, err = unix.Readv(int(r.Fd), dst) + } else { + n, err = unix.Preadv(int(r.Fd), dst, r.Off) + } + if n < 0 { + n = 0 + } + return n, ToStatus(err) +} diff --git a/third_party/go-fuse/fuse/request.go b/third_party/go-fuse/fuse/request.go new file mode 100644 index 000000000..1037d5f7c --- /dev/null +++ b/third_party/go-fuse/fuse/request.go @@ -0,0 +1,390 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "fmt" + "log" + "reflect" + "strings" + "time" + "unsafe" +) + +const sizeOfOutHeader = unsafe.Sizeof(OutHeader{}) + +type request struct { + inflightIndex int + + cancel chan struct{} + + suppressReply bool + + // written under Server.interruptMu + interrupted bool + + // inHeader + opcode specific data + inputBuf []byte + + // Output header (OutHeader). + outHeaderBuf []byte + + // Opcode-specific structured output data. + outDataBuf []byte + + // Unstructured input (filenames, data for WRITE call) + inPayload []byte + + // WRITE data, if not contiguous. + inPayloadIov [][]byte + inPayloadOne [1][]byte + + // nil if the request has no extension. + ext *requestExt + + // Output data. + status Status + + // Unstructured output. Only one of outPayload and readResult is non-nil. + outPayload []byte + readResult ReadResult + + // Start timestamp for timing info. + startTime time.Time +} + +// requestAlloc holds the request, plus I/O buffers, which are +// reused across requests. +type requestAlloc struct { + request + + // Request storage. For large inputs and outputs, use data + // obtained through bufferpool. + bufferPoolInputBuf []byte + bufferPoolOutputBuf []byte + + // For small pieces of data, we use the following inline arrays: + + // Fixed-size output header storage. + outHeaderInline [unsafe.Sizeof(OutHeader{})]byte + + // Opcode-specific structured output data storage. + outDataInline [uintptr(outputDataSize)]byte + + // Input, if small enough to fit here. + smallInputBuf [128]byte + + extInline requestExt +} + +func (r *request) inHeader() *InHeader { + return (*InHeader)(r.inData()) +} + +func (r *request) outHeader() *OutHeader { + return (*OutHeader)(unsafe.Pointer(&r.outHeaderBuf[0])) +} + +// TODO - benchmark to see if this is necessary? +func (r *request) clear() { + r.suppressReply = false + r.inputBuf = nil + r.outHeaderBuf = nil + r.outDataBuf = nil + r.inPayload = nil + r.inPayloadIov = nil + r.inPayloadOne[0] = nil + r.ext = nil + r.status = OK + r.outPayload = nil + r.startTime = time.Time{} + r.readResult = nil +} + +func asType(ptr unsafe.Pointer, typ any) any { + return reflect.NewAt(reflect.ValueOf(typ).Type(), ptr).Interface() +} + +func typSize(typ any) uintptr { + return reflect.ValueOf(typ).Type().Size() +} + +func (r *request) InputDebug() string { + val := "" + + hdr := r.inHeader() + h := getHandler(hdr.Opcode) + if h != nil && h.InType != nil { + val = Print(asType(r.inData(), h.InType)) + } + + names := "" + if h.FileNames == 1 { + name := r.filename() + + rest := r.inPayload[min(len(name)+1, len(r.inPayload)):] + names = fmt.Sprintf(" %q %s", name, summarizePayload(rest)) + } else if h.FileNames == 2 { + n1, n2, _ := r.filenames() + names = fmt.Sprintf(" %q %q", n1, n2) + } else if r.inPayloadIov != nil { + names = fmt.Sprintf(" %db in %d segments", iovLen(r.inPayloadIov), len(r.inPayloadIov)) + } else { + names = summarizePayload(r.inPayload) + } + + ext := "" + if r.ext != nil { + ext = " " + r.ext.String() + } + + return fmt.Sprintf("rx %d: %s n%d %s%s%s p%d", + hdr.Unique, operationName(hdr.Opcode), hdr.NodeId, + val, names, ext, hdr.Caller.Pid) +} + +func summarizePayload(p []byte) string { + l := len(p) + if l > 0 { + dots := "" + if l > 8 { + l = 8 + dots = "..." + } + + return fmt.Sprintf("%q%s %db", p[:l], dots, len(p)) + } + return "" +} + +func (r *request) OutputDebug() string { + var dataStr string + h := getHandler(r.inHeader().Opcode) + if h != nil && h.OutType != nil && len(r.outDataBuf) > 0 { + dataStr = Print(asType(r.outData(), h.OutType)) + } + + max := 1024 + if len(dataStr) > max { + dataStr = dataStr[:max] + " ...trimmed" + } + + flatStr := "" + if r.outPayloadSize() > 0 { + if h != nil && h.FileNameOut { + s := strings.TrimRight(string(r.outPayload), "\x00") + flatStr = fmt.Sprintf(" %q", s) + } else { + spl := "" + + if ws, ok := r.readResult.(withSlice); ok { + slices, _ := ws.Slices() + spl = fmt.Sprintf(" (%d slices)", len(slices)) + } else if r.readResult != nil { + _, pipeOK := r.readResult.(statefulResult) + _, fdOK := r.readResult.(seekableResult) + if fdOK || pipeOK { + spl = fmt.Sprintf(" (fd %db data)", r.readResult.Size()) + } + } else { + l := len(r.outPayload) + s := "" + if l > 8 { + l = 8 + s = "..." + } + spl = fmt.Sprintf(" %q%s", r.outPayload[:l], s) + } + flatStr = fmt.Sprintf(" %db data%s", r.outPayloadSize(), spl) + } + } + + extraStr := dataStr + flatStr + if extraStr != "" { + extraStr = ", " + extraStr + } + return fmt.Sprintf("tx %d: %v%s", + r.inHeader().Unique, r.status, extraStr) +} + +// setInput returns true if it takes ownership of the argument, false if not. +func (r *requestAlloc) setInput(input []byte) bool { + if len(input) < len(r.smallInputBuf) { + copy(r.smallInputBuf[:], input) + r.inputBuf = r.smallInputBuf[:len(input)] + return false + } + r.inputBuf = input + r.bufferPoolInputBuf = input[:cap(input)] + + return true +} + +func (r *requestAlloc) splitPayload(inSize, structSize int) Status { + r.inPayload = r.inputBuf[inSize:] + r.inputBuf = r.inputBuf[:inSize] + r.extendInput(structSize) + return r.splitExt(&r.extInline) +} + +func (r *requestAlloc) extendInput(size int) { + n := len(r.inputBuf) + if n >= size { + return + } + payloadInline := len(r.inPayload) > 0 && unsafe.SliceData(r.inPayload) == &r.smallInputBuf[n] + if payloadInline && size+len(r.inPayload) > len(r.smallInputBuf) { + buf := make([]byte, size) + copy(buf, r.inputBuf) + r.inputBuf = buf + return + } + if payloadInline { + r.inPayload = r.smallInputBuf[size : size+copy(r.smallInputBuf[size:], r.inPayload)] + } else if unsafe.SliceData(r.inputBuf) != &r.smallInputBuf[0] { + copy(r.smallInputBuf[:], r.inputBuf) + } + clear(r.smallInputBuf[n:size]) + r.inputBuf = r.smallInputBuf[:size] +} + +// splitExt parses the extension, if any, into storage. +func (r *request) splitExt(storage *requestExt) Status { + extLen := int(r.inHeader().TotalExtlen) * 8 + if extLen == 0 { + return OK + } + if extLen > len(r.inPayload) { + return EIO + } + split := len(r.inPayload) - extLen + b := r.inPayload[split:] + r.inPayload = r.inPayload[:split] + r.ext = storage + return storage.parse(b) +} + +func (r *request) inData() unsafe.Pointer { + return unsafe.Pointer(&r.inputBuf[0]) +} + +// note: outSize is without OutHeader +func parseRequest(in []byte, kernelSettings *InitIn, negotiated uint64) (h *operationHandler, inSize, outSize, outPayloadSize int, errno Status) { + inSize = int(unsafe.Sizeof(InHeader{})) + if len(in) < inSize { + errno = EIO + return + } + inData := unsafe.Pointer(&in[0]) + hdr := (*InHeader)(inData) + h = getHandler(hdr.Opcode) + if h == nil { + errno = ENOSYS + return + } + if h.InputSize > 0 { + inSize = int(h.InputSize) + } + if hdr.Opcode == _OP_RENAME { + inSize = renameInSize(kernelSettings) + } + if hdr.Opcode == _OP_SETXATTR { + inSize = setXAttrInSize(negotiated) + } + if hdr.Opcode == _OP_INIT && inSize > len(in) { + // Minor version 36 extended the size of InitIn struct + inSize = len(in) + } + if len(in) < inSize { + log.Printf("Short read for %v: %q", h.Name, in) + errno = EIO + return + } + + outSize = int(h.OutputSize) + + switch hdr.Opcode { + case _OP_READDIR, _OP_READDIRPLUS, _OP_READ: + outPayloadSize = int(((*ReadIn)(inData)).Size) + case _OP_GETXATTR, _OP_LISTXATTR: + // [GET|LIST]XATTR is two opcodes in one: get/list xattr size (return + // structured GetXAttrOut, no flat data) and get/list xattr data + // (return no structured data, but only flat data) + outPayloadSize = int(((*GetXAttrIn)(inData)).Size) + if outPayloadSize > 0 { + outSize = 0 + } + case _OP_IOCTL: + outPayloadSize = int(((*IoctlIn)(inData)).OutSize) + } + + return +} + +func (r *request) outData() unsafe.Pointer { + return unsafe.Pointer(&r.outDataBuf[0]) +} + +func (r *request) filename() string { + idx := bytes.IndexByte(r.inPayload, 0) + + name := r.inPayload + if idx >= 0 { + name = name[:idx] + } + return string(name) +} + +func (r *request) filenames() (string, string, Status) { + i1 := bytes.IndexByte(r.inPayload, 0) + if i1 < 0 { + return "", "", EIO + } + rest := r.inPayload[i1+1:] + i2 := bytes.IndexByte(rest, 0) + if i2 < 0 { + return string(r.inPayload[:i1]), "", EIO + } + return string(r.inPayload[:i1]), string(rest[:i2]), OK +} + +// serializeHeader serializes the response header. The header points +// to an internal buffer of the receiver. +func (r *request) serializeHeader(outPayloadSize int) { + if r.status > OK { + // only do this for positive status; negative status + // is used for notification. + r.outDataBuf = nil + outPayloadSize = 0 + } + + // The InitOut structure has 24 bytes (ie. TimeGran and + // further fields not available) in fuse version <= 22. + // https://john-millikin.com/the-fuse-protocol#FUSE_INIT + if r.status.Ok() && r.inHeader().Opcode == _OP_INIT { + out := (*InitOut)(r.outData()) + if out.Minor <= 22 { + r.outDataBuf = r.outDataBuf[:24] + } + } + + o := r.outHeader() + o.Unique = r.inHeader().Unique + o.Status = int32(-r.status) + o.Length = uint32( + int(sizeOfOutHeader) + len(r.outDataBuf) + outPayloadSize) + + if r.outPayload != nil { + r.outPayload = r.outPayload[:outPayloadSize] + } +} + +func (r *request) outPayloadSize() int { + if r.readResult != nil { + return r.readResult.Size() + } + return len(r.outPayload) +} diff --git a/third_party/go-fuse/fuse/request_darwin.go b/third_party/go-fuse/fuse/request_darwin.go new file mode 100644 index 000000000..7c96819d3 --- /dev/null +++ b/third_party/go-fuse/fuse/request_darwin.go @@ -0,0 +1,13 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +const outputDataSize = 184 + +const ( + _FUSE_KERNEL_VERSION = 7 + _MINIMUM_MINOR_VERSION = 12 + _OUR_MINOR_VERSION = 19 +) diff --git a/third_party/go-fuse/fuse/request_freebsd.go b/third_party/go-fuse/fuse/request_freebsd.go new file mode 100644 index 000000000..7b1a40135 --- /dev/null +++ b/third_party/go-fuse/fuse/request_freebsd.go @@ -0,0 +1,9 @@ +package fuse + +const outputDataSize = 144 + +const ( + _FUSE_KERNEL_VERSION = 7 + _MINIMUM_MINOR_VERSION = 12 + _OUR_MINOR_VERSION = 28 +) diff --git a/third_party/go-fuse/fuse/request_linux.go b/third_party/go-fuse/fuse/request_linux.go new file mode 100644 index 000000000..928a538c5 --- /dev/null +++ b/third_party/go-fuse/fuse/request_linux.go @@ -0,0 +1,13 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +const outputDataSize = 288 + +const ( + _FUSE_KERNEL_VERSION = 7 + _MINIMUM_MINOR_VERSION = 12 + _OUR_MINOR_VERSION = 28 +) diff --git a/third_party/go-fuse/fuse/server.go b/third_party/go-fuse/fuse/server.go new file mode 100644 index 000000000..ea4aa6d45 --- /dev/null +++ b/third_party/go-fuse/fuse/server.go @@ -0,0 +1,872 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "errors" + "fmt" + "log" + "math" + "os" + "path" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" +) + +const ( + // Linux v4.20+ caps requests at 1 MiB. Older kernels at 128 + // kiB. Deprecated: current linux kernels allow tuning this + // using sysctl. + MAX_KERNEL_WRITE = 1024 * 1024 + + // Linux kernel constant from include/uapi/linux/fuse.h + // Reads from /dev/fuse that are smaller fail with EINVAL. + _FUSE_MIN_READ_BUFFER = 8192 + + // defaultMaxWrite is the default value for MountOptions.MaxWrite + defaultMaxWrite = 128 * 1024 // 128 kiB + + minMaxReaders = 2 + maxMaxReaders = 16 +) + +// Server contains the logic for reading from the FUSE device. +type Server struct { + protocolServer + + // Empty if unmounted. + mountPoint string + + fuseFD *fuseFD + + opts *MountOptions + + // maxReaders is the maximum number of goroutines reading requests + maxReaders int + + // Pool for request structs. + reqPool sync.Pool + + // Pool for raw requests data + readPool sync.Pool + + singleReader bool + canSplice bool + serving atomic.Bool // for preventing duplicate Serve() calls + + // Used to implement WaitMount on macos. + ready chan error + + // for implementing single threaded processing. + requestProcessingMu sync.Mutex +} + +// SetDebug is deprecated. Use MountOptions.Debug instead. +func (ms *Server) SetDebug(dbg bool) { + // This will typically trigger the race detector. + ms.opts.Debug = dbg +} + +// KernelSettings returns the Init message from the kernel, so +// filesystems can adapt to availability of features of the kernel +// driver. The message should not be altered. +func (ms *Server) KernelSettings() *InitIn { + s := ms.kernelSettings + + return &s +} + +const _MAX_NAME_LEN = 20 + +// This type is deprecated. +type LatencyMap interface { + Add(name string, dt time.Duration) +} + +// RecordLatencies is deprecated. It is provided for backward compatibility but does not do anything. +func (ms *Server) RecordLatencies(l LatencyMap) { +} + +// Unmount calls fusermount -u on the mount. This has the effect of +// shutting down the filesystem. After the Server is unmounted, it +// should be discarded. This function is idempotent. +// +// Does not work when we were mounted with the magic /dev/fd/N mountpoint syntax, +// as we do not know the real mountpoint. Unmount using +// +// fusermount -u /path/to/real/mountpoint +// +// in this case. +func (ms *Server) Unmount() (err error) { + if ms.mountPoint == "" { + return nil + } + if parseFuseFd(ms.mountPoint) >= 0 { + return fmt.Errorf("Cannot unmount magic mountpoint %q. Please use `fusermount -u REALMOUNTPOINT` instead.", ms.mountPoint) + } + delay := time.Duration(0) + for try := 0; try < 5; try++ { + err = unmount(ms.mountPoint, ms.opts) + if err == nil { + break + } + + // Sleep for a bit. This is not pretty, but there is + // no way we can be certain that the kernel thinks all + // open files have already been closed. + delay = 2*delay + 5*time.Millisecond + time.Sleep(delay) + } + if err != nil { + return + } + // Wait for event loops to exit. + ms.fuseFD.loops.Wait() + ms.mountPoint = "" + return err +} + +// alignSlice ensures that the byte at alignedByte is aligned with the +// given logical block size. The input slice should be at least (size +// + blockSize) +func alignSlice(buf []byte, alignedByte, blockSize, size uintptr) []byte { + misaligned := uintptr(unsafe.Pointer(&buf[alignedByte])) & (blockSize - 1) + buf = buf[blockSize-misaligned:] + return buf[:size] +} + +func (o *MountOptions) setDefaults(fs RawFileSystem) { + if o.Logger == nil { + o.Logger = log.Default() + } + if o.MaxWrite < 0 { + o.MaxWrite = 0 + } + if o.MaxWrite == 0 { + o.MaxWrite = defaultMaxWrite + } + kernelMaxWrite := getMaxWrite() + if o.MaxWrite > kernelMaxWrite { + o.MaxWrite = kernelMaxWrite + } + if o.MaxInflightRequestBytes <= 0 { + o.MaxInflightRequestBytes = math.MaxInt + } + if o.MaxStackDepth == 0 { + o.MaxStackDepth = 1 + } + if o.Name == "" { + name := fs.String() + l := min(len(name), _MAX_NAME_LEN) + o.Name = strings.Replace(name[:l], ",", ";", -1) + } + if o.PanicHandler == nil { + l := o.Logger + o.PanicHandler = func(obj any) Status { + return defaultPanicHandler(l, obj) + } + } + + for _, s := range []struct { + flag bool + mask uint64 + }{ + {o.SyncRead, CAP_ASYNC_READ}, + {o.DisableReadDirPlus, CAP_READDIRPLUS}, + {!o.IDMappedMount, CAP_ALLOW_IDMAP}, + } { + if s.flag { + o.DisabledCapabilities |= s.mask + } + } +} + +func defaultPanicHandler(logger *log.Logger, obj any) Status { + const size = 64 << 10 + buf := make([]byte, size) + buf = buf[:runtime.Stack(buf, false)] + logger.Printf("panic in FS handler: %v\n%s", obj, buf) + return EIO +} + +// NewServer creates a FUSE server and attaches ("mounts") it to the +// `mountPoint` directory. +// +// See the "Mount styles" section in the package documentation if you want to +// know about the inner workings of the mount process. Usually you do not. +func NewServer(fs RawFileSystem, mountPoint string, opts *MountOptions) (*Server, error) { + if opts == nil { + opts = &MountOptions{ + MaxBackground: _DEFAULT_BACKGROUND_TASKS, + } + } + o := *opts + o.setDefaults(fs) + + maxReaders := runtime.GOMAXPROCS(0) + if maxReaders < minMaxReaders { + maxReaders = minMaxReaders + } else if maxReaders > maxMaxReaders { + maxReaders = maxMaxReaders + } + + readBufSize, readBufBytes, _ := requestAccountingSizes(o.MaxWrite) + + ms := &Server{ + protocolServer: protocolServer{ + fileSystem: fs, + retrieveTab: make(map[uint64]*retrieveCacheRequest), + opts: &o, + }, + opts: &o, + maxReaders: maxReaders, + singleReader: useSingleReader, + ready: make(chan error, 1), + } + ms.reqPool.New = func() any { + return &requestAlloc{ + request: request{ + cancel: make(chan struct{}), + }, + } + } + ms.readPool.New = func() any { + // O_DIRECT typically requires buffers aligned to + // blocksize (see man 2 open), but requirements vary + // across file systems. Presumably, we could also fix + // this by reading the requests using readv. + buf := make([]byte, readBufBytes) + buf = alignSlice(buf, unsafe.Sizeof(WriteIn{}), logicalBlockSize, uintptr(readBufSize)) + return buf + } + mountPoint = filepath.Clean(mountPoint) + if !filepath.IsAbs(mountPoint) { + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + mountPoint = filepath.Clean(filepath.Join(cwd, mountPoint)) + } + fd, err := mount(mountPoint, &o, ms.ready) + if err != nil { + return nil, err + } + + ms.mountPoint = mountPoint + ms.fuseFD, err = ms.newFuseFD(fd) + if err != nil { + return nil, err + } + ms.protocolServer.writev = ms.fuseFD.writev + + if code := ms.handleInit(); !code.Ok() { + ms.fuseFD.close() + // TODO - unmount as well? + return nil, fmt.Errorf("init: %s", code) + } + + // This prepares for Serve being called somewhere, either + // synchronously or asynchronously. + ms.fuseFD.loops.Add(1) + return ms, nil +} + +func requestAccountingSizes(maxWrite int) (readBufSize, readBufBytes, reqAllocBytes int) { + readBufSize = max(maxWrite+int(maxInputSize), _FUSE_MIN_READ_BUFFER) + readBufBytes = readBufSize + logicalBlockSize + reqAllocBytes = int(unsafe.Sizeof(requestAlloc{})) + return +} + +func escape(optionValue string) string { + return strings.Replace(strings.Replace(optionValue, `\`, `\\`, -1), `,`, `\,`, -1) +} + +func (o *MountOptions) optionsStrings() []string { + var r []string + r = append(r, o.Options...) + + if o.AllowOther { + r = append(r, "allow_other") + } + if o.FsName != "" { + r = append(r, "fsname="+o.FsName) + } + if o.Name != "" { + r = append(r, "subtype="+o.Name) + } + r = append(r, fmt.Sprintf("max_read=%d", o.MaxWrite)) + + // OSXFUSE applies a 60-second timeout for file operations. This + // is inconsistent with how FUSE works on Linux, where operations + // last as long as the daemon is willing to let them run. + if runtime.GOOS == "darwin" { + r = append(r, "daemon_timeout=0") + } + if o.IDMappedMount && !o.containsOption("default_permissions") { + r = append(r, "default_permissions") + } + + // Commas and backslashs in an option need to be escaped, because + // options are separated by a comma and backslashs are used to + // escape other characters. + var rEscaped []string + for _, s := range r { + rEscaped = append(rEscaped, escape(s)) + } + + return rEscaped +} + +func (o *MountOptions) containsOption(opt string) bool { + return slices.Contains(o.Options, opt) +} + +// DebugData returns internal status information for debugging +// purposes. +func (ms *Server) DebugData() string { + var r int + ms.fuseFD.reqMu.Lock() + r = ms.fuseFD.reqReaders + ms.fuseFD.reqMu.Unlock() + + return fmt.Sprintf("readers: %d", r) +} + +// handleEINTR retries the given function until it doesn't return syscall.EINTR. +// This is similar to the HANDLE_EINTR() macro from Chromium ( see +// https://code.google.com/p/chromium/codesearch#chromium/src/base/posix/eintr_wrapper.h +// ) and the TEMP_FAILURE_RETRY() from glibc (see +// https://www.gnu.org/software/libc/manual/html_node/Interrupted-Primitives.html +// ). +// +// Don't use handleEINTR() with syscall.Close(); see +// https://code.google.com/p/chromium/issues/detail?id=269623 . +func handleEINTR(fn func() error) (err error) { + for { + err = fn() + if err != syscall.EINTR { + break + } + } + return +} + +// Serve initiates the FUSE loop. Normally, callers should run Serve() +// and wait for it to exit, but tests will want to run this in a +// goroutine. +// +// Each filesystem operation executes in a separate goroutine. +func (ms *Server) Serve() { + if !ms.serving.CompareAndSwap(false, true) { + // Calling Serve() multiple times leads to a panic on unmount and fun + // debugging sessions ( https://github.com/hanwen/go-fuse/issues/512 ). + // Catch it early. + log.Panic("Serve() must only be called once, you have called it a second time") + } + + ms.loop() + ms.fuseFD.loops.Wait() + + ms.fuseFD.close() + + // shutdown in-flight cache retrieves. + // + // It is possible that umount comes in the middle - after retrieve + // request was sent to kernel, but corresponding kernel reply has not + // yet been read. We unblock all such readers and wake them up with ENODEV. + ms.retrieveMu.Lock() + rtab := ms.retrieveTab + // retrieve attempts might be erroneously tried even after close + // we have to keep retrieveTab !nil not to panic. + ms.retrieveTab = make(map[uint64]*retrieveCacheRequest) + ms.retrieveMu.Unlock() + for _, reading := range rtab { + reading.n = 0 + reading.st = ENODEV + close(reading.ready) + } + + ms.fileSystem.OnUnmount() +} + +// Wait waits for the serve loop to exit. This should only be called +// after Serve has been called, or it will hang indefinitely. +func (ms *Server) Wait() { + ms.fuseFD.loops.Wait() +} + +func (ms *Server) handleInit() Status { + // The first request should be INIT; read it synchronously, + // and don't spawn new readers. + orig := ms.singleReader + ms.singleReader = true + req, errNo := ms.fuseFD.readRequest() + ms.singleReader = orig + + if errNo != OK || req == nil { + return errNo + } + if code := ms.handleRequest(req); !code.Ok() { + return code + } + + if ms.kernelSettings.Minor >= 13 { + ms.setSplice() + } + + // INIT is handled. Init the file system, but don't accept + // incoming requests, so the file system can setup itself. + ms.fileSystem.Init(ms) + return OK +} + +// loop is the FUSE event loop. The simplistic way of calling this is +// with singleReader=true, which has a single goroutine reading the +// device, and spawning a new goroutine for each request. It is +// however 2x slower than processing the request inline with the +// reader. The latter requires more logic, because whenever we start +// processing the request, we have to make sure a new routine is +// spawned to read the device. +// +// Benchmark results i5-8350 pinned at 2Ghz: +// +// singleReader = true +// +// BenchmarkGoFuseRead 954 1137408 ns/op 1843.80 MB/s 5459 B/op 173 allocs/op +// BenchmarkGoFuseRead-2 1327 798635 ns/op 2625.92 MB/s 5072 B/op 169 allocs/op +// BenchmarkGoFuseStat 1530 750944 ns/op +// BenchmarkGoFuseStat-2 8455 120091 ns/op +// BenchmarkGoFuseReaddir 741 1561004 ns/op +// BenchmarkGoFuseReaddir-2 2508 599821 ns/op +// +// singleReader = false +// +// BenchmarkGoFuseRead 1890 671576 ns/op 3122.73 MB/s 5393 B/op 136 allocs/op +// BenchmarkGoFuseRead-2 2948 429578 ns/op 4881.88 MB/s 32235 B/op 157 allocs/op +// BenchmarkGoFuseStat 7886 153546 ns/op +// BenchmarkGoFuseStat-2 9310 121332 ns/op +// BenchmarkGoFuseReaddir 4074 361568 ns/op +// BenchmarkGoFuseReaddir-2 3511 319765 ns/op +func (ms *Server) loop() { + defer ms.fuseFD.loops.Done() +exit: + for { + req, errNo := ms.fuseFD.readRequest() + switch errNo { + case OK: + if req == nil { + break exit + } + case ENOENT: + continue + case ENODEV: + // Mount was killed. The obvious place to + // cancel outstanding requests is at the end + // of Serve, but that reader might be blocked. + ms.cancelAll() + if ms.opts.Debug { + ms.opts.Logger.Printf("received ENODEV (unmount request), thread exiting") + } + break exit + default: // some other error? + ms.opts.Logger.Printf("Failed to read from fuse conn: %v", errNo) + break exit + } + + if ms.singleReader && ms.fuseFD.canAcceptAnother() { + go ms.handleRequest(req) + continue + } + ms.handleRequest(req) + } +} + +func (ms *Server) handleRequest(req *requestAlloc) Status { + defer ms.fuseFD.returnRequest(req) + if ms.opts.SingleThreaded { + ms.requestProcessingMu.Lock() + defer ms.requestProcessingMu.Unlock() + } + + h, inSize, outSize, outPayloadSize, code := parseRequest(req.inputBuf, &ms.kernelSettings, ms.negotiatedFlags) + req.request.status = code + if !code.Ok() && code != ENOSYS { + ms.opts.Logger.Printf("parseRequest: %v", code) + } + if code.Ok() { + req.request.status = req.splitPayload(inSize, int(h.InputSize)) + if !req.request.status.Ok() { + ms.opts.Logger.Printf("op %s: bad request extension: %v", h.Name, req.request.status) + } + } + req.outHeaderBuf = req.outHeaderInline[:] + req.outDataBuf = req.outDataInline[:outSize] + clear(req.outHeaderBuf) + clear(req.outDataBuf) + if outPayloadSize > 0 { + req.outPayload = ms.buffers.AllocBuffer(uint32(outPayloadSize)) + req.bufferPoolOutputBuf = req.outPayload + } + ms.protocolServer.handleRequest(h, &req.request) + if req.suppressReply { + return OK + } + errno := ms.fuseFD.write(&req.request) + if observer, ok := ms.fileSystem.(ReplyAttemptObserver); ok { + observer.OnReplyAttempt(req.inHeader().Unique, req.inHeader().Opcode, req.status, errno) + } + if errno != 0 { + // Ignore ENOENT for INTERRUPT responses which + // indicates that the referred request is no longer + // known by the kernel. This is a normal if the + // referred request already has completed. + // + // Ignore ENOENT for RELEASE responses. When the FS + // is unmounted directly after a file close, the + // device can go away while we are still processing + // RELEASE. This is because RELEASE is analogous to + // FORGET, and is not synchronized with the calling + // process, but does require a response. + if ms.opts.Debug || !(errno == ENOENT && (req.inHeader().Opcode == _OP_INTERRUPT || + req.inHeader().Opcode == _OP_RELEASEDIR || + req.inHeader().Opcode == _OP_RELEASE)) { + ms.opts.Logger.Printf("writer: Write/Writev failed, err: %v. opcode: %v", + errno, operationName(req.inHeader().Opcode)) + } + } + return errno +} + +func notifyWrite(writev func([][]byte) (int, syscall.Errno), opts *MountOptions, req *request) Status { + req.serializeHeader(req.outPayloadSize()) + + if opts.Debug { + opts.Logger.Println(req.OutputDebug()) + } + + _, errno := writev([][]byte{req.outHeaderBuf, req.outDataBuf, req.outPayload}) + + if opts.Debug { + h := getHandler(req.inHeader().Opcode) + opts.Logger.Printf("Response %s: %v", h.Name, errno) + } + + return Status(errno) +} + +func (ms *protocolServer) notifyWrite(req *request) Status { + if ms.writev == nil { + return ENOSYS + } + errno := notifyWrite(ms.writev, ms.opts, req) + return Status(errno) +} + +func newNotifyRequest(opcode uint32) *request { + r := &request{ + inputBuf: make([]byte, unsafe.Sizeof(InHeader{})), + outHeaderBuf: make([]byte, sizeOfOutHeader), + outDataBuf: make([]byte, getHandler(opcode).OutputSize), + status: map[uint32]Status{ + _OP_NOTIFY_INVAL_INODE: NOTIFY_INVAL_INODE, + _OP_NOTIFY_INVAL_ENTRY: NOTIFY_INVAL_ENTRY, + _OP_NOTIFY_STORE_CACHE: NOTIFY_STORE_CACHE, + _OP_NOTIFY_RETRIEVE_CACHE: NOTIFY_RETRIEVE_CACHE, + _OP_NOTIFY_DELETE: NOTIFY_DELETE, + _OP_NOTIFY_PRUNE: NOTIFY_PRUNE, + }[opcode], + } + r.inHeader().Opcode = opcode + return r +} + +// InodeNotify invalidates the information associated with the inode +// (ie. data cache, attributes, etc.) +func (ms *protocolServer) InodeNotify(node uint64, off int64, length int64) Status { + req := newNotifyRequest(_OP_NOTIFY_INVAL_INODE) + + entry := (*NotifyInvalInodeOut)(req.outData()) + entry.Ino = node + entry.Off = off + entry.Length = length + + return ms.notifyWrite(req) +} + +func (ms *Server) PruneNotify(nodes []uint64) Status { + if !ms.kernelSettings.SupportsNotify(NOTIFY_PRUNE) { + return ENOSYS + } + if len(nodes) == 0 { + return OK + } + + req := newNotifyRequest(_OP_NOTIFY_PRUNE) + + entry := (*NotifyPruneOut)(req.outData()) + entry.Count = uint32(len(nodes)) + + req.outPayload = unsafe.Slice((*byte)(unsafe.Pointer(&nodes[0])), 8*len(nodes)) + + return ms.notifyWrite(req) +} + +// InodeNotifyStoreCache tells kernel to store data into inode's cache. +// +// This call is similar to InodeNotify, but instead of only invalidating a data +// region, it gives updated data directly to the kernel. +func (ms *protocolServer) InodeNotifyStoreCache(node uint64, offset int64, data []byte) Status { + for len(data) > 0 { + size := min(len(data), + // NotifyStoreOut has only uint32 for size. + // we check for max(int32), not max(uint32), because on 32-bit + // platforms int has only 31-bit for positive range. + math.MaxInt32) + + st := ms.inodeNotifyStoreCache32(node, offset, data[:size]) + if st != OK { + return st + } + + data = data[size:] + offset += int64(size) + } + + return OK +} + +// inodeNotifyStoreCache32 is internal worker for InodeNotifyStoreCache which +// handles data chunks not larger than 2GB. +func (ms *protocolServer) inodeNotifyStoreCache32(node uint64, offset int64, data []byte) Status { + req := newNotifyRequest(_OP_NOTIFY_STORE_CACHE) + + store := (*NotifyStoreOut)(req.outData()) + store.Nodeid = node + store.Offset = uint64(offset) // NOTE not int64, as it is e.g. in NotifyInvalInodeOut + store.Size = uint32(len(data)) + + req.outPayload = data + + return ms.notifyWrite(req) +} + +// InodeRetrieveCache retrieves data from kernel's inode cache. +// +// InodeRetrieveCache asks kernel to return data from its cache for inode at +// [offset:offset+len(dest)) and waits for corresponding reply. If kernel cache +// has fewer consecutive data starting at offset, that fewer amount is returned. +// In particular if inode data at offset is not cached (0, OK) is returned. +// +// The kernel returns ENOENT if it does not currently have entry for this inode +// in its dentry cache. +func (ms *protocolServer) InodeRetrieveCache(node uint64, offset int64, dest []byte) (n int, st Status) { + // the kernel won't send us in one go more then what we negotiated as MaxWrite. + // retrieve the data in chunks. + // TODO spawn some number of readahead retrievers in parallel. + ntotal := 0 + for { + chunkSize := min(len(dest), ms.opts.MaxWrite) + n, st = ms.inodeRetrieveCache1(node, offset, dest[:chunkSize]) + if st != OK || n == 0 { + break + } + + ntotal += n + offset += int64(n) + dest = dest[n:] + } + + // if we could retrieve at least something - it is ok. + // if ntotal=0 - st will be st returned from first inodeRetrieveCache1. + if ntotal > 0 { + st = OK + } + return ntotal, st +} + +// inodeRetrieveCache1 is internal worker for InodeRetrieveCache which +// actually talks to kernel and retrieves chunks not larger than ms.opts.MaxWrite. +func (ms *protocolServer) inodeRetrieveCache1(node uint64, offset int64, dest []byte) (n int, st Status) { + req := newNotifyRequest(_OP_NOTIFY_RETRIEVE_CACHE) + + // retrieve up to 2GB not to overflow uint32 size in NotifyRetrieveOut. + // see InodeNotifyStoreCache in similar place for why it is only 2GB, not 4GB. + // + // ( InodeRetrieveCache calls us with chunks not larger than + // ms.opts.MaxWrite, but MaxWrite is int, so let's be extra cautious ) + size := min(len(dest), math.MaxInt32) + dest = dest[:size] + + q := (*NotifyRetrieveOut)(req.outData()) + q.Nodeid = node + q.Offset = uint64(offset) // not int64, as it is e.g. in NotifyInvalInodeOut + q.Size = uint32(len(dest)) + + reading := &retrieveCacheRequest{ + nodeid: q.Nodeid, + offset: q.Offset, + dest: dest, + ready: make(chan struct{}), + } + + ms.retrieveMu.Lock() + q.NotifyUnique = ms.retrieveNext + ms.retrieveNext++ + ms.retrieveTab[q.NotifyUnique] = reading + ms.retrieveMu.Unlock() + + // Protect against concurrent close. + result := ms.notifyWrite(req) + if result != OK { + ms.retrieveMu.Lock() + r := ms.retrieveTab[q.NotifyUnique] + if r == reading { + delete(ms.retrieveTab, q.NotifyUnique) + } else if r == nil { + // ok - might be dequeued by umount + } else { + // although very unlikely, it is possible that kernel sends + // unexpected NotifyReply with our notifyUnique, then + // retrieveNext wraps, makes full cycle, and another + // retrieve request is made with the same notifyUnique. + ms.opts.Logger.Printf("W: INODE_RETRIEVE_CACHE: request with notifyUnique=%d mutated", q.NotifyUnique) + } + ms.retrieveMu.Unlock() + return 0, result + } + + // NotifyRetrieveOut sent to the kernel successfully. Now the kernel + // have to return data in a separate write-style NotifyReply request. + // Wait for the result. + <-reading.ready + return reading.n, reading.st +} + +// retrieveCacheRequest represents in-flight cache retrieve request. +type retrieveCacheRequest struct { + nodeid uint64 + offset uint64 + dest []byte + + // reply status + n int + st Status + ready chan struct{} +} + +// DeleteNotify notifies the kernel that an entry is removed from a +// directory. In many cases, this is equivalent to EntryNotify, +// except when the directory is in use, eg. as working directory of +// some process. You should not hold any FUSE filesystem locks, as that +// can lead to deadlock. +func (ms *protocolServer) DeleteNotify(parent uint64, child uint64, name string) Status { + req := newNotifyRequest(_OP_NOTIFY_DELETE) + + entry := (*NotifyInvalDeleteOut)(req.outData()) + entry.Parent = parent + entry.Child = child + entry.NameLen = uint32(len(name)) + + // Many versions of FUSE generate stacktraces if the + // terminating null byte is missing. + nameBytes := make([]byte, len(name)+1) + copy(nameBytes, name) + nameBytes[len(nameBytes)-1] = '\000' + req.outPayload = nameBytes + + return ms.notifyWrite(req) +} + +// EntryNotify should be used if the existence status of an entry +// within a directory changes. You should not hold any FUSE filesystem +// locks, as that can lead to deadlock. +func (ms *protocolServer) EntryNotify(parent uint64, name string) Status { + req := newNotifyRequest(_OP_NOTIFY_INVAL_ENTRY) + entry := (*NotifyInvalEntryOut)(req.outData()) + entry.Parent = parent + entry.NameLen = uint32(len(name)) + + // Many versions of FUSE generate stacktraces if the + // terminating null byte is missing. + nameBytes := make([]byte, len(name)+1) + copy(nameBytes, name) + nameBytes[len(nameBytes)-1] = '\000' + req.outPayload = nameBytes + + return ms.notifyWrite(req) +} + +// SupportsVersion returns true if the kernel supports the given +// protocol version or newer. +func (in *InitIn) SupportsVersion(maj, min uint32) bool { + return in.Major > maj || (in.Major == maj && in.Minor >= min) +} + +// SupportsNotify returns whether a certain notification type is +// supported. Pass any of the NOTIFY_* types as argument. +func (in *InitIn) SupportsNotify(notifyType int) bool { + switch notifyType { + case NOTIFY_INVAL_ENTRY: + return in.SupportsVersion(7, 12) + case NOTIFY_INVAL_INODE: + return in.SupportsVersion(7, 12) + case NOTIFY_STORE_CACHE, NOTIFY_RETRIEVE_CACHE: + return in.SupportsVersion(7, 15) + case NOTIFY_DELETE: + return in.SupportsVersion(7, 18) + case NOTIFY_PRUNE: + return in.SupportsVersion(7, 45) + } + return false +} + +// supportsRenameSwap returns whether the kernel supports the +// renamex_np(2) syscall. This is only supported on OS X. +func (in *InitIn) supportsRenameSwap() bool { + return in.Flags&CAP_RENAME_SWAP != 0 +} + +// WaitMount waits for the first request to be served. Use this to +// avoid racing between accessing the (empty or not yet mounted) +// mountpoint, and the OS trying to setup the user-space mount. +func (ms *Server) WaitMount() error { + err := <-ms.ready + if err != nil { + return err + } + if parseFuseFd(ms.mountPoint) >= 0 { + // Magic `/dev/fd/N` mountpoint. We don't know the real mountpoint, so + // we cannot run the poll hack. + return nil + } + return pollHack(ms.mountPoint) +} + +// parseFuseFd checks if `mountPoint` is the special form /dev/fd/N (with N >= 0), +// and returns N in this case. Returns -1 otherwise. +func parseFuseFd(mountPoint string) (fd int) { + dir, file := path.Split(mountPoint) + if dir != "/dev/fd/" { + return -1 + } + fd, err := strconv.Atoi(file) + if err != nil || fd <= 0 { + return -1 + } + return fd +} + +// errRecoverSplice is returned by trySplice when the caller should +// fall back to to pread/read without logging. +var errRecoverSplice = errors.New("splice failed; must fallback") diff --git a/third_party/go-fuse/fuse/splice_darwin.go b/third_party/go-fuse/fuse/splice_darwin.go new file mode 100644 index 000000000..cbf2a1c46 --- /dev/null +++ b/third_party/go-fuse/fuse/splice_darwin.go @@ -0,0 +1,13 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +func (s *Server) setSplice() { + s.canSplice = false +} + +func (ms *Server) trySplice(header []byte, req *request, fdData *readResultFd) error { + return errRecoverSplice +} diff --git a/third_party/go-fuse/fuse/splice_freebsd.go b/third_party/go-fuse/fuse/splice_freebsd.go new file mode 100644 index 000000000..e15fe04cf --- /dev/null +++ b/third_party/go-fuse/fuse/splice_freebsd.go @@ -0,0 +1,9 @@ +package fuse + +func (s *Server) setSplice() { + s.canSplice = false +} + +func (ms *Server) trySplice(header []byte, req *request, fdData *readResultFd) error { + return errRecoverSplice +} diff --git a/third_party/go-fuse/fuse/splice_linux.go b/third_party/go-fuse/fuse/splice_linux.go new file mode 100644 index 000000000..bdc02f9df --- /dev/null +++ b/third_party/go-fuse/fuse/splice_linux.go @@ -0,0 +1,152 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "fmt" + "log" + "os" + "syscall" + + "github.com/hanwen/go-fuse/v2/splice" +) + +func (s *Server) setSplice() { + s.canSplice = splice.Resizable() && !s.opts.DisableSplice +} + +// trySplice: Zero-copy read from fdData.Fd into /dev/fuse +// +// Optimistic fast path: assumes fdData.Sz bytes are available (no short +// read). The caller has already serialized the reply header with that size. +// File data is copied into a pipe only once: +// +// 1. Write pre-serialized header into pipe --> pipe: [header] +// 2. Splice file data directly into pipe --> pipe: [header][payload] +// 3. Splice pipe into /dev/fuse +// +// If a short read occurs (payloadLen < fdData.Sz), the header in the pipe +// would carry the wrong total length, so we return an error and let the +// caller fall back to a Pread-based path. +func (r *fuseFD) trySplice(req *request, readResult ReadResult) error { + // The caller (handleRequest) already called req.serializeHeader with + // readResult.Size(), so req.outHeaderBuf is correct for the optimistic case. + total := len(req.outHeaderBuf) + len(req.outDataBuf) + readResult.Size() + + spliceFlags := 0 + if f, ok := readResult.(withSpliceFlags); ok { + spliceFlags = f.SpliceFlags() + } + + pair, err := splice.Get() + if err != nil { + return err + } + defer splice.Done(pair) + + // Grow pipe to header + payload + one extra page. + // Without the extra page the kernel will block once the pipe is almost full. + if err := pair.Grow(total + os.Getpagesize()); err != nil { + return err + } + + // Write header into pipe. + headerSz, err := pair.Writev([][]byte{req.outHeaderBuf, req.outDataBuf}) + if err != nil { + return err + } + if want := len(req.outHeaderBuf) + len(req.outDataBuf); headerSz != want { + return fmt.Errorf("short write into splice: wrote %d, want %d", headerSz, want) + } + + // Splice file data directly into pipe (single copy). + var payloadLen int + var fd uintptr + var sz int + var off int64 + if seekable, ok := readResult.(seekableResult); ok { + fd, off, sz = seekable.Seekable() + payloadLen, err = pair.LoadFromAt(fd, sz, off) + } else if stateful, ok := readResult.(statefulResult); ok { + fd, sz = stateful.Stateful() + payloadLen, err = pair.LoadFrom(fd, sz) + } else { + return errRecoverSplice + } + + if err != nil { + return err + } + + if payloadLen != sz { + // Short read at EOF: the header carries the wrong total length. + + // drain header + _, err := pair.Read(make([]byte, headerSz)) + if err != nil { + return fmt.Errorf("fallback drain: %w", err) + } + + if r.server.opts.Debug { + log.Printf("tx %d: OK fixup fd %db data", req.inHeader().Unique, payloadLen) + } + // New length. + req.serializeHeader(payloadLen) + + return r.trySplice(req, ReadResultPipeFlags(pair, payloadLen, spliceFlags)) + } + + // Write header + payload to /dev/fuse. + if cerr := r.withFD(func(fd int) { + _, err = pair.WriteToFlags(uintptr(fd), total, spliceFlags) + }); cerr != nil { + return cerr + } + return err +} + +type pipeReadResult struct { + pair *splice.Pair + size int + flags int +} + +func (r *pipeReadResult) Done() { + splice.Done(r.pair) + r.pair = nil +} + +func (r *pipeReadResult) Bytes(buf []byte) ([]byte, Status) { + n, err := r.pair.Read(buf) + if n == -1 && err == syscall.EAGAIN { + return nil, 0 + } + return buf[:n], ToStatus(err) +} + +func (r *pipeReadResult) Size() int { + return r.size +} + +func (r *pipeReadResult) Stateful() (fd uintptr, sz int) { + return r.pair.ReadFd(), r.size +} + +func (r *pipeReadResult) SpliceFlags() int { return r.flags } + +// ReadResultPipe returns a [ReadResult] of `size` bytes that was preloaded +// into the given pipe. The pipe is discarded with splice.Done() +// after the read completes. +func ReadResultPipe(pipe *splice.Pair, size int) ReadResult { + return &pipeReadResult{pair: pipe, size: size} +} + +// ReadResultPipeFlags is [ReadResultPipe] with splice(2) flags. Only +// SPLICE_F_MOVE has an effect on /dev/fuse: it moves each page out of the +// source file's page cache, after waiting on its writeback, so use it only for +// pages the filesystem can lose. Only readahead reads are eligible. +func ReadResultPipeFlags(pipe *splice.Pair, size, flags int) ReadResult { + return &pipeReadResult{pair: pipe, size: size, flags: flags} +} diff --git a/third_party/go-fuse/fuse/syscall_darwin.go b/third_party/go-fuse/fuse/syscall_darwin.go new file mode 100644 index 000000000..cbb9413f2 --- /dev/null +++ b/third_party/go-fuse/fuse/syscall_darwin.go @@ -0,0 +1,128 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "bytes" + "syscall" + "unsafe" +) + +func getxattr(path string, attr string, dest []byte) (sz int, errno int) { + pathBs, err := syscall.BytePtrFromString(path) + if err != nil { + return 0, int(syscall.EINVAL) + } + + attrBs, err := syscall.BytePtrFromString(attr) + if err != nil { + return 0, int(syscall.EINVAL) + } + + size, _, errNo := syscall.Syscall6( + syscall.SYS_GETXATTR, + uintptr(unsafe.Pointer(pathBs)), + uintptr(unsafe.Pointer(attrBs)), + uintptr(unsafe.Pointer(&dest[0])), + uintptr(len(dest)), + 0, 0) + return int(size), int(errNo) +} + +func GetXAttr(path string, attr string, dest []byte) (value []byte, errno int) { + sz, errno := getxattr(path, attr, dest) + + for sz > cap(dest) && errno == 0 { + dest = make([]byte, sz) + sz, errno = getxattr(path, attr, dest) + } + + if errno != 0 { + return nil, errno + } + + return dest[:sz], errno +} + +func listxattr(path string, dest []byte) (sz int, errno int) { + pathbs, err := syscall.BytePtrFromString(path) + if err != nil { + return 0, int(syscall.EINVAL) + } + + var destPointer unsafe.Pointer + if len(dest) > 0 { + destPointer = unsafe.Pointer(&dest[0]) + } + size, _, errNo := syscall.Syscall( + syscall.SYS_LISTXATTR, + uintptr(unsafe.Pointer(pathbs)), + uintptr(destPointer), + uintptr(len(dest))) + + return int(size), int(errNo) +} + +func ListXAttr(path string) (attributes []string, errno int) { + dest := make([]byte, 0) + sz, errno := listxattr(path, dest) + if errno != 0 { + return nil, errno + } + + for sz > cap(dest) && errno == 0 { + dest = make([]byte, sz) + sz, errno = listxattr(path, dest) + } + + // -1 to drop the final empty slice. + dest = dest[:sz-1] + attributesBytes := bytes.Split(dest, []byte{0}) + attributes = make([]string, len(attributesBytes)) + for i, v := range attributesBytes { + attributes[i] = string(v) + } + return attributes, errno +} + +func Setxattr(path string, attr string, data []byte, flags int) (errno int) { + pathbs, err := syscall.BytePtrFromString(path) + if err != nil { + return int(syscall.EINVAL) + } + + attrbs, err := syscall.BytePtrFromString(attr) + if err != nil { + return int(syscall.EINVAL) + } + + _, _, errNo := syscall.Syscall6( + syscall.SYS_SETXATTR, + uintptr(unsafe.Pointer(pathbs)), + uintptr(unsafe.Pointer(attrbs)), + uintptr(unsafe.Pointer(&data[0])), + uintptr(len(data)), + uintptr(flags), 0) + + return int(errNo) +} + +func Removexattr(path string, attr string) (errno int) { + pathbs, err := syscall.BytePtrFromString(path) + if err != nil { + return int(syscall.EINVAL) + } + + attrbs, err := syscall.BytePtrFromString(attr) + if err != nil { + return int(syscall.EINVAL) + } + + _, _, errNo := syscall.Syscall( + syscall.SYS_REMOVEXATTR, + uintptr(unsafe.Pointer(pathbs)), + uintptr(unsafe.Pointer(attrbs)), 0) + return int(errNo) +} diff --git a/third_party/go-fuse/fuse/syscall_linux.go b/third_party/go-fuse/fuse/syscall_linux.go new file mode 100644 index 000000000..f28593137 --- /dev/null +++ b/third_party/go-fuse/fuse/syscall_linux.go @@ -0,0 +1,14 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "golang.org/x/sys/unix" +) + +func writev(fd int, packet [][]byte) (n int, err error) { + n, err = unix.Writev(fd, packet) + return +} diff --git a/third_party/go-fuse/fuse/syscall_unix.go b/third_party/go-fuse/fuse/syscall_unix.go new file mode 100644 index 000000000..4d09d0f38 --- /dev/null +++ b/third_party/go-fuse/fuse/syscall_unix.go @@ -0,0 +1,43 @@ +//go:build !linux + +package fuse + +import ( + "syscall" + "unsafe" +) + +func sys_writev(fd int, iovecs *syscall.Iovec, cnt int) (n int, err error) { + n1, _, e1 := syscall.Syscall( + syscall.SYS_WRITEV, + uintptr(fd), uintptr(unsafe.Pointer(iovecs)), uintptr(cnt)) + n = int(n1) + if e1 != 0 { + err = syscall.Errno(e1) + } + return n, err +} + +// Until golang.orgx/sys/unix provides Writev for Darwin and FreeBSD, +// keep the syscall wrapping funcitons. +func writev(fd int, packet [][]byte) (n int, err error) { + iovecs := make([]syscall.Iovec, 0, len(packet)) + + for _, v := range packet { + if len(v) == 0 { + continue + } + vec := syscall.Iovec{ + Base: &v[0], + } + vec.SetLen(len(v)) + iovecs = append(iovecs, vec) + } + + err = handleEINTR(func() error { + var err error + n, err = sys_writev(fd, &iovecs[0], len(iovecs)) + return err + }) + return n, err +} diff --git a/third_party/go-fuse/fuse/typeprint.go b/third_party/go-fuse/fuse/typeprint.go new file mode 100644 index 000000000..dda4a9ff2 --- /dev/null +++ b/third_party/go-fuse/fuse/typeprint.go @@ -0,0 +1,9 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +func (a *Attr) String() string { + return Print(a) +} diff --git a/third_party/go-fuse/fuse/types.go b/third_party/go-fuse/fuse/types.go new file mode 100644 index 000000000..dde862045 --- /dev/null +++ b/third_party/go-fuse/fuse/types.go @@ -0,0 +1,905 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "io" + "syscall" + "time" + "unsafe" +) + +const ( + _DEFAULT_BACKGROUND_TASKS = 12 +) + +// Status is the errno number that a FUSE call returns to the kernel. +type Status int32 + +const ( + OK = Status(0) + + // EACCESS Permission denied + EACCES = Status(syscall.EACCES) + + // EBUSY Device or resource busy + EBUSY = Status(syscall.EBUSY) + + // EAGAIN Resource temporarily unavailable + EAGAIN = Status(syscall.EAGAIN) + + // EINTR Call was interrupted + EINTR = Status(syscall.EINTR) + + // EINVAL Invalid argument + EINVAL = Status(syscall.EINVAL) + + // EIO I/O error + EIO = Status(syscall.EIO) + + // ENOENT No such file or directory + ENOENT = Status(syscall.ENOENT) + + // ENOSYS Function not implemented + ENOSYS = Status(syscall.ENOSYS) + + // ENOTDIR Not a directory + ENOTDIR = Status(syscall.ENOTDIR) + + // ENOTSUP Not supported + ENOTSUP = Status(syscall.ENOTSUP) + + // EISDIR Is a directory + EISDIR = Status(syscall.EISDIR) + + // EPERM Operation not permitted + EPERM = Status(syscall.EPERM) + + // ERANGE Math result not representable + ERANGE = Status(syscall.ERANGE) + + // EXDEV Cross-device link + EXDEV = Status(syscall.EXDEV) + + // EBADF Bad file number + EBADF = Status(syscall.EBADF) + + // ENODEV No such device + ENODEV = Status(syscall.ENODEV) + + // EROFS Read-only file system + EROFS = Status(syscall.EROFS) +) + +type ForgetIn struct { + InHeader + + Nlookup uint64 +} + +// batch forget is handled internally. +type _ForgetOne struct { + NodeId uint64 + Nlookup uint64 +} + +// batch forget is handled internally. +type _BatchForgetIn struct { + InHeader + Count uint32 + Dummy uint32 +} + +type MkdirIn struct { + InHeader + + // The mode for the new directory. The calling process' umask + // is already factored into the mode. + Mode uint32 + Umask uint32 +} + +type RenameIn struct { + InHeader + Newdir uint64 + Flags uint32 + Padding uint32 +} + +const compatRenameInSize = int(unsafe.Sizeof(InHeader{})) + 8 + +func renameInSize(kernelSettings *InitIn) int { + if !kernelSettings.supportsRenameSwap() { + return compatRenameInSize + } + return int(unsafe.Sizeof(RenameIn{})) +} + +type LinkIn struct { + InHeader + Oldnodeid uint64 +} + +type Owner struct { + Uid uint32 + Gid uint32 +} + +const ( // SetAttrIn.Valid + FATTR_MODE = (1 << 0) + FATTR_UID = (1 << 1) + FATTR_GID = (1 << 2) + FATTR_SIZE = (1 << 3) + FATTR_ATIME = (1 << 4) + FATTR_MTIME = (1 << 5) + FATTR_FH = (1 << 6) + FATTR_ATIME_NOW = (1 << 7) + FATTR_MTIME_NOW = (1 << 8) + FATTR_LOCKOWNER = (1 << 9) + FATTR_CTIME = (1 << 10) + FATTR_KILL_SUIDGID = (1 << 11) +) + +type SetAttrInCommon struct { + InHeader + + Valid uint32 + Padding uint32 + Fh uint64 + Size uint64 + LockOwner uint64 + Atime uint64 + Mtime uint64 + Ctime uint64 + Atimensec uint32 + Mtimensec uint32 + Ctimensec uint32 + Mode uint32 + Unused4 uint32 + Owner + Unused5 uint32 +} + +// GetFh returns the file handle if available, or 0 if undefined. +func (s *SetAttrInCommon) GetFh() (uint64, bool) { + if s.Valid&FATTR_FH != 0 { + return s.Fh, true + } + return 0, false +} + +func (s *SetAttrInCommon) GetMode() (uint32, bool) { + if s.Valid&FATTR_MODE != 0 { + return s.Mode & 07777, true + } + return 0, false +} + +func (s *SetAttrInCommon) GetUID() (uint32, bool) { + if s.Valid&FATTR_UID != 0 { + return s.Uid, true + } + return ^uint32(0), false +} + +func (s *SetAttrInCommon) GetGID() (uint32, bool) { + if s.Valid&FATTR_GID != 0 { + return s.Gid, true + } + return ^uint32(0), false +} + +func (s *SetAttrInCommon) GetSize() (uint64, bool) { + if s.Valid&FATTR_SIZE != 0 { + return s.Size, true + } + return 0, false +} + +func (s *SetAttrInCommon) GetMTime() (time.Time, bool) { + var t time.Time + if s.Valid&FATTR_MTIME != 0 { + if s.Valid&FATTR_MTIME_NOW != 0 { + t = time.Now() + } else { + t = time.Unix(int64(s.Mtime), int64(s.Mtimensec)) + } + return t, true + } + + return t, false +} + +func (s *SetAttrInCommon) GetATime() (time.Time, bool) { + var t time.Time + if s.Valid&FATTR_ATIME != 0 { + if s.Valid&FATTR_ATIME_NOW != 0 { + t = time.Now() + } else { + t = time.Unix(int64(s.Atime), int64(s.Atimensec)) + } + return t, true + } + + return t, false +} + +func (s *SetAttrInCommon) GetCTime() (time.Time, bool) { + var t time.Time + if s.Valid&FATTR_CTIME != 0 { + t = time.Unix(int64(s.Ctime), int64(s.Ctimensec)) + return t, true + } + + return t, false +} + +const RELEASE_FLUSH = (1 << 0) + +type ReleaseIn struct { + InHeader + Fh uint64 + Flags uint32 + ReleaseFlags uint32 + LockOwner uint64 +} + +type OpenIn struct { + InHeader + Flags uint32 + + // Mode holds open_flags (OPEN_*), not a file mode. + Mode uint32 +} + +const ( + // OpenIn.Mode, CreateIn.OpenFlags: O_TRUNC by an unprivileged caller; clear suid/sgid (CAP_HANDLE_KILLPRIV_V2). + OPEN_KILL_SUIDGID = (1 << 0) +) + +const ( + // OpenOut.Flags + FOPEN_DIRECT_IO = (1 << 0) + FOPEN_KEEP_CACHE = (1 << 1) + FOPEN_NONSEEKABLE = (1 << 2) + FOPEN_CACHE_DIR = (1 << 3) + FOPEN_STREAM = (1 << 4) + FOPEN_NOFLUSH = (1 << 5) + FOPEN_PARALLEL_DIRECT_WRITES = (1 << 6) + FOPEN_PASSTHROUGH = (1 << 7) +) + +type OpenOut struct { + Fh uint64 + OpenFlags uint32 + BackingID int32 +} + +// To be set in InitIn/InitOut.Flags. +// +// Keep in sync with either of +// * https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/fuse.h +// * https://github.com/libfuse/libfuse/blob/master/include/fuse_kernel.h +// but NOT with +// * https://github.com/libfuse/libfuse/blob/master/include/fuse_common.h +// This file has CAP_HANDLE_KILLPRIV and CAP_POSIX_ACL reversed! +const ( + CAP_ASYNC_READ = (1 << 0) + CAP_POSIX_LOCKS = (1 << 1) + CAP_FILE_OPS = (1 << 2) + CAP_ATOMIC_O_TRUNC = (1 << 3) + CAP_EXPORT_SUPPORT = (1 << 4) + CAP_BIG_WRITES = (1 << 5) + CAP_DONT_MASK = (1 << 6) + CAP_SPLICE_WRITE = (1 << 7) + CAP_SPLICE_MOVE = (1 << 8) + CAP_SPLICE_READ = (1 << 9) + CAP_FLOCK_LOCKS = (1 << 10) + CAP_IOCTL_DIR = (1 << 11) + CAP_AUTO_INVAL_DATA = (1 << 12) // mtime changes invalidate page cache. + CAP_READDIRPLUS = (1 << 13) + CAP_READDIRPLUS_AUTO = (1 << 14) + CAP_ASYNC_DIO = (1 << 15) + CAP_WRITEBACK_CACHE = (1 << 16) + CAP_NO_OPEN_SUPPORT = (1 << 17) + CAP_PARALLEL_DIROPS = (1 << 18) + CAP_HANDLE_KILLPRIV = (1 << 19) + CAP_POSIX_ACL = (1 << 20) + CAP_ABORT_ERROR = (1 << 21) + CAP_MAX_PAGES = (1 << 22) + CAP_CACHE_SYMLINKS = (1 << 23) + + /* bits 24..31 differ across linux and mac */ + /* bits 32..63 get shifted down 32 bits into the Flags2 field */ + CAP_SECURITY_CTX = (1 << 32) + CAP_HAS_INODE_DAX = (1 << 33) + CAP_CREATE_SUPP_GROUP = (1 << 34) + CAP_HAS_EXPIRE_ONLY = (1 << 35) + CAP_DIRECT_IO_ALLOW_MMAP = (1 << 36) + CAP_PASSTHROUGH = (1 << 37) + CAP_NO_EXPORT_SUPPORT = (1 << 38) + CAP_HAS_RESEND = (1 << 39) + CAP_ALLOW_IDMAP = (1 << 40) + CAP_OVER_IO_URING = (1 << 41) + CAP_REQUEST_TIMEOUT = (1 << 42) +) + +type InitIn struct { + InHeader + + Major uint32 + Minor uint32 + MaxReadAhead uint32 + Flags uint32 + Flags2 uint32 + Unused [11]uint32 +} + +func (i *InitIn) Flags64() uint64 { + return uint64(i.Flags) | uint64(i.Flags2)<<32 +} + +type InitOut struct { + Major uint32 + Minor uint32 + MaxReadAhead uint32 + Flags uint32 + MaxBackground uint16 + CongestionThreshold uint16 + MaxWrite uint32 + TimeGran uint32 + MaxPages uint16 + MapAlignment uint16 + Flags2 uint32 + MaxStackDepth uint32 + RequestTimeout uint16 + + _Unused [11]uint16 +} + +func (o *InitOut) Flags64() uint64 { + return uint64(o.Flags) | uint64(o.Flags2)<<32 +} + +type _CuseInitIn struct { + InHeader + Major uint32 + Minor uint32 + Unused uint32 + Flags uint32 +} + +type _CuseInitOut struct { + Major uint32 + Minor uint32 + Unused uint32 + Flags uint32 + MaxRead uint32 + MaxWrite uint32 + DevMajor uint32 + DevMinor uint32 + Spare [10]uint32 +} + +type InterruptIn struct { + InHeader + Unique uint64 +} + +type _BmapIn struct { + InHeader + Block uint64 + Blocksize uint32 + Padding uint32 +} + +type _BmapOut struct { + Block uint64 +} + +const ( + IOCTL_COMPAT = (1 << 0) + IOCTL_UNRESTRICTED = (1 << 1) + IOCTL_RETRY = (1 << 2) + IOCTL_DIR = (1 << 4) +) + +type IoctlIn struct { + InHeader + Fh uint64 + Flags uint32 + + // The command, consisting of 16-bits metadata (direction + size) and 16-bits to encode the ioctl number + Cmd uint32 + + // The uint64 argument. If there is a payload, this is the + // payload address in the caller, and should not be used. + Arg uint64 + + // Size for the payload, non-zero if Cmd is WRITE or READ/WRITE. + InSize uint32 + + // Size for the payload, non-zero if Cmd is READ or READ/WRITE. + OutSize uint32 +} + +type IoctlOut struct { + Result int32 + + // The following fields are used for unrestricted ioctls, + // which are only enabled on CUSE. + Flags uint32 + InIovs uint32 + OutIovs uint32 +} + +type _PollIn struct { + InHeader + Fh uint64 + Kh uint64 + Flags uint32 + Padding uint32 +} + +type _PollOut struct { + Revents uint32 + Padding uint32 +} + +type _NotifyPollWakeupOut struct { + Kh uint64 +} + +type WriteOut struct { + Size uint32 + Padding uint32 +} + +type GetXAttrOut struct { + Size uint32 + Padding uint32 +} + +type FileLock struct { + Start uint64 + End uint64 + Typ uint32 + Pid uint32 +} + +const LK_FLOCK = (1 << 0) + +type LkIn struct { + InHeader + Fh uint64 + Owner uint64 + Lk FileLock + LkFlags uint32 + Padding uint32 +} + +type LkOut struct { + Lk FileLock +} + +// For AccessIn.Mask. +const ( + X_OK = 1 + W_OK = 2 + R_OK = 4 + F_OK = 0 +) + +type AccessIn struct { + InHeader + Mask uint32 + Padding uint32 +} + +type FsyncIn struct { + InHeader + Fh uint64 + FsyncFlags uint32 + Padding uint32 +} + +type OutHeader struct { + Length uint32 + Status int32 + Unique uint64 +} + +type NotifyInvalInodeOut struct { + Ino uint64 + Off int64 + Length int64 +} + +type NotifyInvalEntryOut struct { + Parent uint64 + NameLen uint32 + Flags uint32 +} + +const ( + // NotifyInvalEntryOut.Flags: only expire the entry's timeout, + // rather than dropping it from the dcache. Needs + // CAP_HAS_EXPIRE_ONLY. + EXPIRE_ONLY = (1 << 0) +) + +type NotifyInvalDeleteOut struct { + Parent uint64 + Child uint64 + NameLen uint32 + Padding uint32 +} + +type NotifyStoreOut struct { + Nodeid uint64 + Offset uint64 + Size uint32 + Padding uint32 +} + +type NotifyRetrieveOut struct { + NotifyUnique uint64 + Nodeid uint64 + Offset uint64 + Size uint32 + Padding uint32 +} + +type NotifyRetrieveIn struct { + InHeader + Dummy1 uint64 + Offset uint64 + Size uint32 + Dummy2 uint32 + Dummy3 uint64 + Dummy4 uint64 +} + +type NotifyPruneOut struct { + Count uint32 + _padding uint32 + _spare uint64 +} + +const ( + NOTIFY_POLL = -1 // notify kernel that a poll waiting for IO on a file handle should wake up + NOTIFY_INVAL_INODE = -2 // notify kernel that an inode should be invalidated + NOTIFY_INVAL_ENTRY = -3 // notify kernel that a directory entry should be invalidated + NOTIFY_STORE_CACHE = -4 // store data into kernel cache of an inode + NOTIFY_RETRIEVE_CACHE = -5 // retrieve data from kernel cache of an inode + NOTIFY_DELETE = -6 // notify kernel that a directory entry has been deleted + NOTIFY_RESEND = -7 + NOTIFY_INC_EPOCH = -8 + NOTIFY_PRUNE = -9 +) + +type FlushIn struct { + InHeader + Fh uint64 + Unused uint32 + Padding uint32 + LockOwner uint64 +} + +type LseekIn struct { + InHeader + Fh uint64 + Offset uint64 + Whence uint32 + Padding uint32 +} + +type LseekOut struct { + Offset uint64 +} + +type CopyFileRangeIn struct { + InHeader + FhIn uint64 + OffIn uint64 + NodeIdOut uint64 + FhOut uint64 + OffOut uint64 + Len uint64 + Flags uint64 +} + +// EntryOut holds the result of a (directory,name) lookup. It has two +// TTLs, one for the (directory, name) lookup itself, and one for the +// attributes (eg. size, mode). The entry TTL also applies if the +// lookup result is ENOENT ("negative entry lookup") +type EntryOut struct { + NodeId uint64 + Generation uint64 + EntryValid uint64 + AttrValid uint64 + EntryValidNsec uint32 + AttrValidNsec uint32 + Attr +} + +// EntryTimeout returns the timeout in nanoseconds for a directory +// entry (existence or non-existence of a file within a directory). +func (o *EntryOut) EntryTimeout() time.Duration { + return time.Duration(uint64(o.EntryValidNsec) + o.EntryValid*1e9) +} + +// AttrTimeout returns the TTL in nanoseconds of the attribute data. +func (o *EntryOut) AttrTimeout() time.Duration { + return time.Duration(uint64(o.AttrValidNsec) + o.AttrValid*1e9) +} + +// SetEntryTimeout sets the entry TTL. +func (o *EntryOut) SetEntryTimeout(dt time.Duration) { + ns := int64(dt) + o.EntryValidNsec = uint32(ns % 1e9) + o.EntryValid = uint64(ns / 1e9) +} + +// SetAttrTimeout sets the attribute TTL. +func (o *EntryOut) SetAttrTimeout(dt time.Duration) { + ns := int64(dt) + o.AttrValidNsec = uint32(ns % 1e9) + o.AttrValid = uint64(ns / 1e9) +} + +// AttrOut is the type returned by the Getattr call. +type AttrOut struct { + AttrValid uint64 + AttrValidNsec uint32 + Dummy uint32 + Attr +} + +func (o *AttrOut) Timeout() time.Duration { + return time.Duration(uint64(o.AttrValidNsec) + o.AttrValid*1e9) +} + +func (o *AttrOut) SetTimeout(dt time.Duration) { + ns := int64(dt) + o.AttrValidNsec = uint32(ns % 1e9) + o.AttrValid = uint64(ns / 1e9) +} + +type CreateOut struct { + EntryOut + OpenOut +} + +// Caller has data on the process making the FS call. +// +// The UID and GID are effective UID/GID, except for the ACCESS +// opcode, where UID and GID are the real UIDs +type Caller struct { + Owner + Pid uint32 +} + +type InHeader struct { + Length uint32 + Opcode uint32 + Unique uint64 + NodeId uint64 + Caller + TotalExtlen uint16 // in 8-byte units + Padding uint16 +} + +type StatfsOut struct { + Blocks uint64 + Bfree uint64 + Bavail uint64 + Files uint64 + Ffree uint64 + Bsize uint32 + NameLen uint32 + Frsize uint32 + Padding uint32 + Spare [6]uint32 +} + +// _Dirent is what we send to the kernel, but we offer DirEntry and +// DirEntryList to the user. +type _Dirent struct { + Ino uint64 + Off uint64 + NameLen uint32 + Typ uint32 +} + +const ( + READ_LOCKOWNER = (1 << 1) +) + +const ( + WRITE_CACHE = (1 << 0) + WRITE_LOCKOWNER = (1 << 1) + WRITE_KILL_SUIDGID = (1 << 2) +) + +type FallocateIn struct { + InHeader + Fh uint64 + Offset uint64 + Length uint64 + Mode uint32 + Padding uint32 +} + +func (lk *FileLock) ToFlockT(flockT *syscall.Flock_t) { + flockT.Start = int64(lk.Start) + if lk.End == (1<<63)-1 { + flockT.Len = 0 + } else { + flockT.Len = int64(lk.End - lk.Start + 1) + } + flockT.Whence = int16(io.SeekStart) + flockT.Type = int16(lk.Typ) +} + +func (lk *FileLock) FromFlockT(flockT *syscall.Flock_t) { + lk.Typ = uint32(flockT.Type) + if flockT.Type != syscall.F_UNLCK { + lk.Start = uint64(flockT.Start) + if flockT.Len == 0 { + lk.End = (1 << 63) - 1 + } else { + lk.End = uint64(flockT.Start + flockT.Len - 1) + } + } + lk.Pid = uint32(flockT.Pid) +} + +const ( + // Mask for GetAttrIn.Flags. If set, GetAttrIn has a file handle set. + FUSE_GETATTR_FH = (1 << 0) +) + +type GetAttrIn struct { + InHeader + + Flags_ uint32 + Dummy uint32 + Fh_ uint64 +} + +// Flags accesses the flags. This is a method, because OSXFuse does not +// have GetAttrIn flags. +func (g *GetAttrIn) Flags() uint32 { + return g.Flags_ +} + +// Fh accesses the file handle. This is a method, because OSXFuse does not +// have GetAttrIn flags. +func (g *GetAttrIn) Fh() uint64 { + return g.Fh_ +} + +type MknodIn struct { + InHeader + + // Mode to use, including the Umask value + Mode uint32 + Rdev uint32 + Umask uint32 + Padding uint32 +} + +type CreateIn struct { + InHeader + Flags uint32 + + // Mode for the new file; already takes Umask into account. + Mode uint32 + + // Umask used for this create call. + Umask uint32 + + OpenFlags uint32 +} + +type ReadIn struct { + InHeader + Fh uint64 + Offset uint64 + Size uint32 + ReadFlags uint32 + LockOwner uint64 + Flags uint32 + Padding uint32 +} + +type WriteIn struct { + InHeader + Fh uint64 + Offset uint64 + Size uint32 + WriteFlags uint32 + LockOwner uint64 + Flags uint32 + Padding uint32 +} + +// Data for registering a file as backing an inode. +type BackingMap struct { + Fd int32 + Flags uint32 + padding uint64 +} + +type SxTime struct { + Sec uint64 + Nsec uint32 + _reserved uint32 +} + +func (t *SxTime) Seconds() float64 { + return ft(t.Sec, t.Nsec) +} + +type Statx struct { + Mask uint32 + Blksize uint32 + Attributes uint64 + Nlink uint32 + + Uid uint32 + Gid uint32 + Mode uint16 + _spare0 uint16 + Ino uint64 + Size uint64 + Blocks uint64 + AttributesMask uint64 + + Atime SxTime + Btime SxTime + Ctime SxTime + Mtime SxTime + + RdevMajor uint32 + RdevMinor uint32 + DevMajor uint32 + DevMinor uint32 + _spare2 [14]uint64 +} + +type StatxIn struct { + InHeader + + GetattrFlags uint32 + _reserved uint32 + Fh uint64 + SxFlags uint32 + SxMask uint32 +} + +type StatxOut struct { + AttrValid uint64 + AttrValidNsec uint32 + Flags uint32 + _spare [2]uint64 + + Statx +} + +func (o *StatxOut) Timeout() time.Duration { + return time.Duration(uint64(o.AttrValidNsec) + o.AttrValid*1e9) +} + +func (o *StatxOut) SetTimeout(dt time.Duration) { + ns := int64(dt) + o.AttrValidNsec = uint32(ns % 1e9) + o.AttrValid = uint64(ns / 1e9) +} + +type CopyFileRangeOut struct { + BytesCopied uint64 +} diff --git a/third_party/go-fuse/fuse/types_darwin.go b/third_party/go-fuse/fuse/types_darwin.go new file mode 100644 index 000000000..91c269638 --- /dev/null +++ b/third_party/go-fuse/fuse/types_darwin.go @@ -0,0 +1,142 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" + "unsafe" +) + +const ( + ENODATA = Status(syscall.ENODATA) + ENOATTR = Status(syscall.ENOATTR) // ENOATTR is not defined for all GOOS. + + // EREMOTEIO is not supported on Darwin. + EREMOTEIO = Status(syscall.EIO) +) + +type Attr struct { + Ino uint64 + Size uint64 + Blocks uint64 + Atime uint64 + Mtime uint64 + Ctime uint64 + Crtime_ uint64 // OS X + Atimensec uint32 + Mtimensec uint32 + Ctimensec uint32 + Crtimensec_ uint32 // OS X + Mode uint32 + Nlink uint32 + Owner + Rdev uint32 + Flags_ uint32 // OS X + Blksize uint32 + Padding uint32 +} + +const ( + FATTR_CRTIME = (1 << 28) + FATTR_CHGTIME = (1 << 29) + FATTR_BKUPTIME = (1 << 30) + FATTR_FLAGS = (1 << 31) +) + +type SetAttrIn struct { + SetAttrInCommon + + // OS X only + Bkuptime_ uint64 + Chgtime_ uint64 + Crtime uint64 + BkuptimeNsec uint32 + ChgtimeNsec uint32 + CrtimeNsec uint32 + Flags_ uint32 // see chflags(2) +} + +const ( + FOPEN_PURGE_ATTR = (1 << 30) + FOPEN_PURGE_UBC = (1 << 31) +) + +type SetXAttrIn struct { + InHeader + Size uint32 + Flags uint32 + Position uint32 + Padding uint32 +} + +func setXAttrInSize(negotiated uint64) int { + return int(unsafe.Sizeof(SetXAttrIn{})) +} + +type GetXAttrIn struct { + InHeader + Size uint32 + Padding uint32 + Position uint32 + Padding2 uint32 +} + +const ( + CAP_NODE_RWLOCK = (1 << 24) + CAP_RENAME_SWAP = (1 << 25) + CAP_RENAME_EXCL = (1 << 26) + CAP_ALLOCATE = (1 << 27) + CAP_EXCHANGE_DATA = (1 << 28) + CAP_CASE_INSENSITIVE = (1 << 29) + CAP_VOL_RENAME = (1 << 30) + CAP_XTIMES = (1 << 31) + + // CAP_EXPLICIT_INVAL_DATA is not supported on Darwin. + CAP_EXPLICIT_INVAL_DATA = 0x0 +) + +type GetxtimesOut struct { + Bkuptime uint64 + Crtime uint64 + Bkuptimensec uint32 + Crtimensec uint32 +} + +type ExchangeIn struct { + InHeader + Olddir uint64 + Newdir uint64 + Options uint64 +} + +type MonitorIn struct { + InHeader + Flags uint32 + Padding uint32 +} + +func (s *StatfsOut) FromStatfsT(statfs *syscall.Statfs_t) { + s.Blocks = statfs.Blocks + s.Bfree = statfs.Bfree + s.Bavail = statfs.Bavail + s.Files = statfs.Files + s.Ffree = statfs.Ffree + s.Bsize = uint32(statfs.Iosize) // Iosize translates to Bsize: the optimal transfer size. + s.Frsize = s.Bsize // Bsize translates to Frsize: the minimum transfer size. + + // The block counts are in units of statfs.Bsize. + // If s.Bsize != statfs.Bsize, we have to recalculate the block counts + // accordingly (s.Bsize is usually 256*statfs.Bsize). + if s.Bsize > statfs.Bsize { + adj := uint64(s.Bsize / statfs.Bsize) + s.Blocks /= adj + s.Bfree /= adj + s.Bavail /= adj + } +} + +func (o *InitOut) setFlags(flags uint64) { + o.Flags = uint32(flags) +} diff --git a/third_party/go-fuse/fuse/types_freebsd.go b/third_party/go-fuse/fuse/types_freebsd.go new file mode 100644 index 000000000..a7a551ea3 --- /dev/null +++ b/third_party/go-fuse/fuse/types_freebsd.go @@ -0,0 +1,38 @@ +package fuse + +import "syscall" + +const ( + ENOATTR = Status(syscall.ENOATTR) + ENODATA = Status(syscall.EIO) +) + +const ( + CAP_NO_OPENDIR_SUPPORT = (1 << 24) + + // The higher capabilities are not currently defined by FreeBSD. + // Ref: https://cgit.freebsd.org/src/tree/sys/fs/fuse/fuse_kernel.h + + // CAP_RENAME_SWAP only exists on OSX. + CAP_RENAME_SWAP = 0x0 + + // CAP_EXPLICIT_INVAL_DATA is not supported on FreeBSD. + CAP_EXPLICIT_INVAL_DATA = 0x0 + + CAP_SETXATTR_EXT = 0x0 +) + +func (s *StatfsOut) FromStatfsT(statfs *syscall.Statfs_t) { + s.Blocks = statfs.Blocks + s.Bsize = uint32(statfs.Bsize) + s.Bfree = statfs.Bfree + s.Bavail = uint64(statfs.Bavail) + s.Files = statfs.Files + s.Ffree = uint64(statfs.Ffree) + s.Frsize = uint32(statfs.Bsize) + s.NameLen = uint32(statfs.Namemax) +} + +func (o *InitOut) setFlags(flags uint64) { + o.Flags = uint32(flags) +} diff --git a/third_party/go-fuse/fuse/types_linux.go b/third_party/go-fuse/fuse/types_linux.go new file mode 100644 index 000000000..40593c5d7 --- /dev/null +++ b/third_party/go-fuse/fuse/types_linux.go @@ -0,0 +1,59 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package fuse + +import ( + "syscall" + + "golang.org/x/sys/unix" +) + +const ( + ENODATA = Status(syscall.ENODATA) + ENOATTR = Status(syscall.ENODATA) // On Linux, ENOATTR is an alias for ENODATA. + + // EREMOTEIO Remote I/O error + EREMOTEIO = Status(syscall.EREMOTEIO) +) + +// To be set in InitIn/InitOut.Flags. +// +// This flags conflict with https://github.com/macfuse/library/blob/master/include/fuse_common.h +// and should be used only on Linux. +const ( + CAP_NO_OPENDIR_SUPPORT = (1 << 24) + CAP_EXPLICIT_INVAL_DATA = (1 << 25) + + CAP_MAP_ALIGNMENT = (1 << 26) + CAP_SUBMOUNTS = (1 << 27) + CAP_HANDLE_KILLPRIV_V2 = (1 << 28) + CAP_SETXATTR_EXT = (1 << 29) + CAP_INIT_EXT = (1 << 30) + CAP_INIT_RESERVED = (1 << 31) + + // CAP_RENAME_SWAP only exists on OSX. + CAP_RENAME_SWAP = 0x0 +) + +func (s *StatfsOut) FromStatfsT(statfs *syscall.Statfs_t) { + s.Blocks = statfs.Blocks + s.Bsize = uint32(statfs.Bsize) + s.Bfree = statfs.Bfree + s.Bavail = statfs.Bavail + s.Files = statfs.Files + s.Ffree = statfs.Ffree + s.Frsize = uint32(statfs.Frsize) + s.NameLen = uint32(statfs.Namelen) +} + +func (o *InitOut) setFlags(flags uint64) { + o.Flags = uint32(flags) | CAP_INIT_EXT + o.Flags2 = uint32(flags >> 32) +} + +func (t *SxTime) FromStatxTimestamp(ts *unix.StatxTimestamp) { + t.Sec = uint64(ts.Sec) + t.Nsec = ts.Nsec +} diff --git a/third_party/go-fuse/fuse/types_unix.go b/third_party/go-fuse/fuse/types_unix.go new file mode 100644 index 000000000..d8114b232 --- /dev/null +++ b/third_party/go-fuse/fuse/types_unix.go @@ -0,0 +1,68 @@ +//go:build !darwin + +package fuse + +import "unsafe" + +type Attr struct { + Ino uint64 + Size uint64 + + // Blocks is the number of 512-byte blocks that the file occupies on disk. + Blocks uint64 + Atime uint64 + Mtime uint64 + Ctime uint64 + Atimensec uint32 + Mtimensec uint32 + Ctimensec uint32 + Mode uint32 + Nlink uint32 + Owner + Rdev uint32 + + // Blksize is the preferred size for file system operations. + Blksize uint32 + + Flags uint32 +} + +const ( + // Attr.Flags: directory is the root of a server-side submount; virtio-fs automounts it (CAP_SUBMOUNTS). + ATTR_SUBMOUNT = (1 << 0) + // Attr.Flags: map the file through the virtio-fs DAX window (CAP_HAS_INODE_DAX, dax=inode). + ATTR_DAX = (1 << 1) +) + +type SetAttrIn struct { + SetAttrInCommon +} + +type SetXAttrIn struct { + InHeader + Size uint32 + Flags uint32 + + SetXAttrFlags uint32 + Padding uint32 +} + +const ( + // SetXAttrIn.SetXAttrFlags: setting system.posix_acl_access must clear sgid. + SETXATTR_ACL_KILL_SGID = (1 << 0) +) + +const compatSetXAttrInSize = int(unsafe.Sizeof(InHeader{})) + 8 + +func setXAttrInSize(negotiated uint64) int { + if negotiated&CAP_SETXATTR_EXT == 0 { + return compatSetXAttrInSize + } + return int(unsafe.Sizeof(SetXAttrIn{})) +} + +type GetXAttrIn struct { + InHeader + Size uint32 + Padding uint32 +} diff --git a/third_party/go-fuse/go.mod b/third_party/go-fuse/go.mod new file mode 100644 index 000000000..65e3055de --- /dev/null +++ b/third_party/go-fuse/go.mod @@ -0,0 +1,15 @@ +module github.com/hanwen/go-fuse/v2 + +require ( + github.com/kylelemons/godebug v1.1.0 + github.com/moby/sys/mountinfo v0.7.2 + golang.org/x/sync v0.10.0 + golang.org/x/sys v0.28.0 +) + +require ( + github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect + github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00 +) + +go 1.21 diff --git a/third_party/go-fuse/splice/copy.go b/third_party/go-fuse/splice/copy.go new file mode 100644 index 000000000..0c36195f2 --- /dev/null +++ b/third_party/go-fuse/splice/copy.go @@ -0,0 +1,72 @@ +//go:build linux + +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +import ( + "io" + "os" +) + +func SpliceCopy(dst *os.File, src *os.File, p *Pair) (int64, error) { + total := int64(0) + + for { + n, err := p.LoadFrom(src.Fd(), p.size) + if err != nil { + return total, err + } + if n == 0 { + break + } + m, err := p.WriteTo(dst.Fd(), n) + total += int64(m) + if err != nil { + return total, err + } + if m < n { + return total, err + } + if int(n) < p.size { + break + } + } + + return total, nil +} + +// Argument ordering follows io.Copy. +func CopyFile(dstName string, srcName string, mode int) error { + src, err := os.Open(srcName) + if err != nil { + return err + } + defer src.Close() + + dst, err := os.OpenFile(dstName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(mode)) + if err != nil { + return err + } + defer dst.Close() + + return CopyFds(dst, src) +} + +func CopyFds(dst *os.File, src *os.File) (err error) { + p, err := splicePool.get() + if p != nil { + p.Grow(256 * 1024) + _, err := SpliceCopy(dst, src, p) + splicePool.done(p) + return err + } else { + _, err = io.Copy(dst, src) + } + if err == io.EOF { + err = nil + } + return err +} diff --git a/third_party/go-fuse/splice/pair.go b/third_party/go-fuse/splice/pair.go new file mode 100644 index 000000000..47601c0d5 --- /dev/null +++ b/third_party/go-fuse/splice/pair.go @@ -0,0 +1,95 @@ +//go:build linux + +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +import ( + "fmt" + "log" + "os" + "syscall" +) + +type Pair struct { + r, w *os.File + rConn, wConn syscall.RawConn + size int +} + +func (p *Pair) MaxGrow() { + for p.Grow(2*p.size) == nil { + } +} + +func (p *Pair) Grow(n int) error { + if n <= p.size { + return nil + } + if !resizable { + return fmt.Errorf("splice: want %d bytes, but not resizable", n) + } + if n > maxPipeSize { + return fmt.Errorf("splice: want %d bytes, max pipe size %d", n, maxPipeSize) + } + + var newsize int + var errNo syscall.Errno + p.rConn.Control(func(fd uintptr) { + newsize, errNo = fcntl(fd, F_SETPIPE_SZ, n) + }) + if errNo != 0 { + return fmt.Errorf("splice: fcntl returned %v", errNo) + } + p.size = newsize + return nil +} + +func (p *Pair) Cap() int { + return p.size +} + +func (p *Pair) Close() error { + if p.r == nil { + log.Panicf("2nd close r") + } + err1 := p.r.Close() + p.r = nil + if p.w == nil { + log.Panicf("2nd close w") + } + err2 := p.w.Close() + p.w = nil + if err1 != nil { + return err1 + } + return err2 +} + +func (p *Pair) Read(d []byte) (n int, err error) { + p.rConn.Control(func(fd uintptr) { + n, err = syscall.Read(int(fd), d) + }) + return +} + +func (p *Pair) Write(d []byte) (n int, err error) { + p.wConn.Control(func(fd uintptr) { + n, err = syscall.Write(int(fd), d) + }) + return +} + +func (p *Pair) ReadFd() uintptr { + var fd uintptr + p.rConn.Control(func(f uintptr) { fd = f }) + return fd +} + +func (p *Pair) WriteFd() uintptr { + var fd uintptr + p.wConn.Control(func(f uintptr) { fd = f }) + return fd +} diff --git a/third_party/go-fuse/splice/pair_linux.go b/third_party/go-fuse/splice/pair_linux.go new file mode 100644 index 000000000..2d44fae4b --- /dev/null +++ b/third_party/go-fuse/splice/pair_linux.go @@ -0,0 +1,118 @@ +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +import ( + "fmt" + "log" + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func (p *Pair) Writev(bufs [][]byte) (int, error) { + var n int + var err error + p.wConn.Control(func(wfd uintptr) { + n, err = unix.Writev(int(wfd), bufs) + }) + return n, err +} + +func (p *Pair) SpliceFrom(src *Pair, sz int) (int, error) { + var n int + var err error + src.rConn.Control(func(rfd uintptr) { + p.wConn.Control(func(wfd uintptr) { + var sn int64 + sn, err = syscall.Splice(int(rfd), nil, int(wfd), nil, sz, 0) + n = int(sn) + }) + }) + if err != nil { + err = os.NewSyscallError("Splice", err) + } + return n, err +} + +func (p *Pair) LoadFromAt(fd uintptr, sz int, off int64) (int, error) { + var n int + var err error + p.wConn.Control(func(wfd uintptr) { + var sn int64 + sn, err = syscall.Splice(int(fd), &off, int(wfd), nil, sz, 0) + n = int(sn) + }) + return n, err +} + +func (p *Pair) LoadFrom(fd uintptr, sz int) (int, error) { + if sz > p.size { + return 0, fmt.Errorf("LoadFrom: not enough space %d, %d", + sz, p.size) + } + + var n int + var err error + p.wConn.Control(func(wfd uintptr) { + var sn int64 + sn, err = syscall.Splice(int(fd), nil, int(wfd), nil, sz, 0) + n = int(sn) + }) + if err != nil { + err = os.NewSyscallError("Splice load from", err) + } + return n, err +} + +func (p *Pair) WriteTo(fd uintptr, n int) (int, error) { + return p.WriteToFlags(fd, n, 0) +} + +// WriteToFlags is WriteTo with splice(2) flags. /dev/fuse acts on SPLICE_F_MOVE +// even though the generic pipe-to-file path ignores it. +func (p *Pair) WriteToFlags(fd uintptr, n int, flags int) (int, error) { + var m int + var err error + p.rConn.Control(func(rfd uintptr) { + var sm int64 + sm, err = syscall.Splice(int(rfd), nil, int(fd), nil, n, flags) + m = int(sm) + }) + if err != nil { + err = os.NewSyscallError("Splice write", err) + } + return m, err +} + +const ( + _SPLICE_F_NONBLOCK = 0x2 + _FIONREAD = 0x541b +) + +func (p *Pair) discard() { + for { + var err error + p.rConn.Control(func(rfd uintptr) { + _, err = syscall.Splice(int(rfd), nil, devNullFD(), nil, int(p.size), _SPLICE_F_NONBLOCK) + }) + if err != nil && err != syscall.EAGAIN { + // This can happen if something closed our fd inadvertently (eg. double close) + log.Panicf("splicing into /dev/null: %v", err) + } + + var n int + p.rConn.Control(func(rfd uintptr) { + n, err = unix.IoctlGetInt(int(rfd), _FIONREAD) + }) + if err != nil { + log.Panicf("FIONREAD on pipe: %v", err) + } + if n == 0 { + return + } + } +} diff --git a/third_party/go-fuse/splice/pool.go b/third_party/go-fuse/splice/pool.go new file mode 100644 index 000000000..5576df5ab --- /dev/null +++ b/third_party/go-fuse/splice/pool.go @@ -0,0 +1,107 @@ +//go:build linux + +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +import ( + "sync" +) + +var splicePool *pairPool + +type pairPool struct { + sync.Mutex + unused []*Pair + usedCount int +} + +func ClearSplicePool() { + splicePool.clear() +} + +func Get() (*Pair, error) { + return splicePool.get() +} + +func Total() int { + return splicePool.total() +} + +func Used() int { + return splicePool.used() +} + +// Done returns the pipe pair to pool. +func Done(p *Pair) { + splicePool.done(p) +} + +// Closes and discards pipe pair. +func Drop(p *Pair) { + splicePool.drop(p) +} + +func newSplicePairPool() *pairPool { + return &pairPool{} +} + +func (pp *pairPool) clear() { + pp.Lock() + for _, p := range pp.unused { + p.Close() + } + pp.unused = pp.unused[:0] + pp.Unlock() +} + +func (pp *pairPool) used() (n int) { + pp.Lock() + n = pp.usedCount + pp.Unlock() + + return n +} + +func (pp *pairPool) total() int { + pp.Lock() + n := pp.usedCount + len(pp.unused) + pp.Unlock() + return n +} + +func (pp *pairPool) drop(p *Pair) { + p.Close() + pp.Lock() + pp.usedCount-- + pp.Unlock() +} + +func (pp *pairPool) get() (p *Pair, err error) { + pp.Lock() + defer pp.Unlock() + + pp.usedCount++ + l := len(pp.unused) + if l > 0 { + p := pp.unused[l-1] + pp.unused = pp.unused[:l-1] + return p, nil + } + + return newSplicePair() +} + +func (pp *pairPool) done(p *Pair) { + p.discard() + pp.Lock() + pp.usedCount-- + pp.unused = append(pp.unused, p) + pp.Unlock() +} + +func init() { + splicePool = newSplicePairPool() +} diff --git a/third_party/go-fuse/splice/splice.go b/third_party/go-fuse/splice/splice.go new file mode 100644 index 000000000..9b851d190 --- /dev/null +++ b/third_party/go-fuse/splice/splice.go @@ -0,0 +1,114 @@ +//go:build linux + +// Copyright 2016 the Go-FUSE Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package splice + +// Routines for efficient file to file copying. + +import ( + "fmt" + "log" + "os" + "sync" + "syscall" +) + +var maxPipeSize int +var resizable bool + +func Resizable() bool { + return resizable +} + +func MaxPipeSize() int { + return maxPipeSize +} + +// From manpage on ubuntu Lucid: +// +// Since Linux 2.6.11, the pipe capacity is 65536 bytes. +const DefaultPipeSize = 16 * 4096 + +var ( + devNullFDOnce sync.Once + devNullFDValue int +) + +func init() { + content, err := os.ReadFile("/proc/sys/fs/pipe-max-size") + if err != nil { + maxPipeSize = DefaultPipeSize + } else { + fmt.Sscan(string(content), &maxPipeSize) + } + + r, w, err := os.Pipe() + if err != nil { + log.Panicf("cannot create pipe: %v", err) + } + sz, errNo := fcntl(r.Fd(), F_GETPIPE_SZ, 0) + resizable = (errNo == 0) + _, errNo = fcntl(r.Fd(), F_SETPIPE_SZ, 2*sz) + resizable = resizable && (errNo == 0) + r.Close() + w.Close() +} + +// We empty pipes by splicing to /dev/null. +func devNullFD() int { + devNullFDOnce.Do(func() { + fd, err := syscall.Open("/dev/null", os.O_WRONLY, 0) + if err != nil { + panic(fmt.Sprintf("failed to open /dev/null: %s", err)) + } + devNullFDValue = fd + }) + return devNullFDValue +} + +// copy & paste from syscall. +func fcntl(fd uintptr, cmd int, arg int) (val int, errno syscall.Errno) { + r0, _, e1 := syscall.Syscall(syscall.SYS_FCNTL, fd, uintptr(cmd), uintptr(arg)) + val = int(r0) + errno = syscall.Errno(e1) + return +} + +const F_SETPIPE_SZ = 1031 +const F_GETPIPE_SZ = 1032 + +func newSplicePair() (p *Pair, err error) { + p = &Pair{} + p.r, p.w, err = os.Pipe() + if err != nil { + return nil, err + } + p.rConn, err = p.r.SyscallConn() + if err != nil { + p.r.Close() + p.w.Close() + return nil, err + } + p.wConn, err = p.w.SyscallConn() + if err != nil { + p.r.Close() + p.w.Close() + return nil, err + } + var errNo syscall.Errno + p.rConn.Control(func(fd uintptr) { + p.size, errNo = fcntl(fd, F_GETPIPE_SZ, 0) + }) + if errNo == syscall.EINVAL { + p.size = DefaultPipeSize + return p, nil + } + if errNo != 0 { + p.Close() + return nil, fmt.Errorf("fcntl getsize: %v", errNo) + } + return p, nil +} From f2acadccf79dc6672b6aa6e0504812fa471f944f Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 18:14:56 +0900 Subject: [PATCH 30/35] filesystem: retire unadmitted replies and keepalive renewals Signed-off-by: bootjp --- Dockerfile.redis-proxy | 1 + internal/filesystem/fuseadapter/ack.go | 20 ++++++++ internal/filesystem/fuseadapter/adapter.go | 7 ++- .../fuseadapter/adapter_ops_test.go | 20 ++++++++ .../filesystem/fuseadapter/mount_e2e_test.go | 3 ++ internal/filesystem/operation_journal_exec.go | 51 +++++++++++++++++++ internal/filesystem/operation_journal_test.go | 36 +++++++++++++ internal/filesystem/operation_receipt.go | 4 +- 8 files changed, 140 insertions(+), 2 deletions(-) diff --git a/Dockerfile.redis-proxy b/Dockerfile.redis-proxy index a6d17b073..fa0e994de 100644 --- a/Dockerfile.redis-proxy +++ b/Dockerfile.redis-proxy @@ -2,6 +2,7 @@ FROM golang:latest AS build WORKDIR $GOPATH/src/app COPY go.mod go.sum ./ +COPY third_party/go-fuse/go.mod third_party/go-fuse/go.mod RUN go mod download COPY . . diff --git a/internal/filesystem/fuseadapter/ack.go b/internal/filesystem/fuseadapter/ack.go index f9c4a9acf..8c689c95b 100644 --- a/internal/filesystem/fuseadapter/ack.go +++ b/internal/filesystem/fuseadapter/ack.go @@ -19,6 +19,7 @@ const ( ) type replyIdentityKey struct{} +type backgroundOperationKey struct{} func withReplyIdentity(ctx context.Context, unique uint64) context.Context { if unique == 0 { @@ -27,6 +28,25 @@ func withReplyIdentity(ctx context.Context, unique uint64) context.Context { return context.WithValue(ctx, replyIdentityKey{}, unique) } +func withBackgroundOperation(ctx context.Context) context.Context { + return context.WithValue(ctx, backgroundOperationKey{}, true) +} + +func (a *Adapter) queueAcknowledgement(id filesystem.OperationID) { + a.replyMu.Lock() + if a.ackPending == nil { + a.ackPending = make(map[filesystem.OperationID]struct{}) + } + a.ackPending[id] = struct{}{} + a.replyMu.Unlock() + if a.ackWake != nil { + select { + case a.ackWake <- struct{}{}: + default: + } + } +} + func (a *Adapter) recordReplyIdentity(ctx context.Context, id filesystem.OperationID) { unique, ok := ctx.Value(replyIdentityKey{}).(uint64) if !ok || unique == 0 { diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index c9cee0af2..02e29be20 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -741,6 +741,11 @@ func (a *Adapter) refreshJournaledLease(ctx context.Context, inode uint64, fh ui a.retainLatestLeaseReceipt(ctx, inode, fh, id) a.markLeaseRefreshed(inode, fh) } + // Keepalive renewals have no kernel reply. Completion of the renewal is + // their acknowledgement boundary; uncertain outcomes remain retained. + if ctx.Value(backgroundOperationKey{}) == true && !errors.Is(err, kv.ErrTransactionOutcomeUnknown) { + a.queueAcknowledgement(id) + } return errors.Wrap(err, "refresh journaled open handle lease") } @@ -850,7 +855,7 @@ func (a *Adapter) keepOpenHandlesAlive(ctx context.Context) { func (a *Adapter) refreshTrackedOpenHandles(ctx context.Context) { for _, handle := range a.openHandleSnapshot() { - handleCtx, cancel := context.WithTimeout(ctx, a.keepaliveTimeout) + handleCtx, cancel := context.WithTimeout(withBackgroundOperation(ctx), a.keepaliveTimeout) errno := a.refreshOpenHandleLease(handleCtx, handle.inode, handle.fh, false) cancel() switch { diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index 644f5ded9..c7ce86ab1 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -183,10 +183,30 @@ func TestAdapterKeepaliveUsesJournaledLeaseRefresh(t *testing.T) { adapter.trackOpenHandle(7, 9) adapter.refreshTrackedOpenHandles(t.Context()) require.Len(t, core.leaseIDs, 1) + adapter.replyMu.Lock() + _, queued := adapter.ackPending[core.leaseIDs[0]] + adapter.replyMu.Unlock() + require.True(t, queued) require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) require.ElementsMatch(t, core.leaseIDs, core.verified) } +func TestAdapterKeepaliveRetainsUnknownLease(t *testing.T) { + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, leaseErr: kv.ErrTransactionOutcomeUnknown} + adapter := New(core, []byte("fuse-session"), WithHandleKeepaliveInterval(0), WithJournaledLeaseRefresh(session)) + t.Cleanup(adapter.Close) + adapter.trackOpenHandle(7, 9) + adapter.refreshTrackedOpenHandles(t.Context()) + require.Len(t, core.leaseIDs, 1) + adapter.replyMu.Lock() + _, queued := adapter.ackPending[core.leaseIDs[0]] + adapter.replyMu.Unlock() + require.False(t, queued) +} + func (c *journalLifecycleCore) CreateWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte, _ filesystem.CreateOptions) (filesystem.CreateResult, error) { c.createID = id return filesystem.CreateResult{Inode: 7, FH: 9, Stat: filesystem.Stat{Inode: 7}}, nil diff --git a/internal/filesystem/fuseadapter/mount_e2e_test.go b/internal/filesystem/fuseadapter/mount_e2e_test.go index aebd1f9ee..f09f9aec4 100644 --- a/internal/filesystem/fuseadapter/mount_e2e_test.go +++ b/internal/filesystem/fuseadapter/mount_e2e_test.go @@ -451,6 +451,9 @@ func TestKernelMountStrictModeMutationsAndReplyAcknowledgement(t *testing.T) { data, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, []byte("str"), data) + require.NoError(t, os.Mkdir(filepath.Join(mountPoint, "directory"), 0o700)) + err = os.Rename(path, filepath.Join(mountPoint, "directory", "cross")) + require.ErrorIs(t, err, syscall.EXDEV) require.NoError(t, os.Rename(path, filepath.Join(mountPoint, "renamed"))) require.NoError(t, os.Remove(filepath.Join(mountPoint, "renamed"))) directory, err := os.Open(mountPoint) diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index 8089ac89f..ac25418c9 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -567,6 +567,57 @@ func (j *OperationJournal) Acknowledge(ctx context.Context, id OperationID) erro return errors.WithStack(ErrOperationQuota) } +// RetireUnadmitted fences an identity whose reply was attempted before an +// operation record was admitted. The session and record read set makes this +// race atomically with admission: an in-flight admission either wins and must +// be resolved normally, or observes the retired identity and cannot publish. +func (j *OperationJournal) RetireUnadmitted(ctx context.Context, id OperationID) error { + for range operationCASRetries { + err := j.retireUnadmittedOnce(ctx, id) + if err == nil { + return nil + } + if !IsWriteConflict(err) { + return errors.Wrap(err, "retire unadmitted operation") + } + } + return errors.WithStack(ErrOperationQuota) +} + +func (j *OperationJournal) retireUnadmittedOnce(ctx context.Context, id OperationID) error { + ts, err := j.backend.Snapshot(ctx) + if err != nil { + return errors.Wrap(err, "fence unadmitted operation retirement") + } + session, exists, err := j.loadSessionAt(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session}, ts) + if err != nil { + return err + } + if !exists { + return errors.WithStack(ErrOperationSessionNotFound) + } + if session.retired(id.Sequence) { + return nil + } + _, found, err := j.loadRecordAt(ctx, id, ts) + if err != nil { + return err + } + if found { + return errors.WithStack(ErrOperationGeneration) + } + if err := advanceAckWatermark(&session, id.Sequence); err != nil { + return err + } + raw, err := encodeOperationSession(session) + if err != nil { + return err + } + _, err = j.backend.Commit(ctx, ts, []*kv.Elem[kv.OP]{{Op: kv.Put, Key: session.ID.key(), Value: raw}}, + [][]byte{id.key(), session.ID.key()}) + return errors.Wrap(err, "commit unadmitted operation retirement") +} + func (j *OperationJournal) acknowledgeOnce(ctx context.Context, id OperationID) error { ts, err := j.backend.Snapshot(ctx) if err != nil { diff --git a/internal/filesystem/operation_journal_test.go b/internal/filesystem/operation_journal_test.go index 064648e67..81bc94ad1 100644 --- a/internal/filesystem/operation_journal_test.go +++ b/internal/filesystem/operation_journal_test.go @@ -728,6 +728,42 @@ func TestAcknowledgeOperationCancelsPendingReply(t *testing.T) { require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session})) } +func TestAcknowledgeOperationRetiresUnadmittedIdentity(t *testing.T) { + ctx := context.Background() + journal, st, coord := testOperationJournal(t) + svc, err := NewService(st, coord) + require.NoError(t, err) + first := testOperationID(t, journal, 1) + second := OperationID{Filesystem: first.Filesystem, Session: first.Session, Sequence: 2} + _, err = journal.Admit(ctx, second, []byte("write"), 128, 1024) + require.NoError(t, err) + attempt, err := journal.BeginAttempt(ctx, second) + require.NoError(t, err) + _, err = journal.Publish(ctx, second, attempt.AttemptID, operationAttemptPlan{Intent: []byte("write"), Result: []byte("done")}) + require.NoError(t, err) + require.NoError(t, svc.AcknowledgeOperation(ctx, second)) + require.NoError(t, svc.AcknowledgeOperation(ctx, first)) + require.NoError(t, svc.AcknowledgeOperation(ctx, first)) + for _, id := range []OperationID{first, second} { + _, err = journal.Resolve(ctx, id) + require.ErrorIs(t, err, ErrOperationRetired) + _, err = journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.ErrorIs(t, err, ErrOperationRetired) + } + require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: first.Filesystem, Session: first.Session})) +} + +func TestRetireUnadmittedDoesNotRetireAdmittedIdentity(t *testing.T) { + ctx := context.Background() + journal, _, _ := testOperationJournal(t) + id := testOperationID(t, journal, 1) + _, err := journal.Admit(ctx, id, []byte("write"), 128, 1024) + require.NoError(t, err) + require.ErrorIs(t, journal.RetireUnadmitted(ctx, id), ErrOperationGeneration) + _, err = journal.Resolve(ctx, id) + require.ErrorIs(t, err, ErrOperationPending) +} + func TestOperationJournalAcknowledgementGapsRetireWithoutReuse(t *testing.T) { ctx := context.Background() journal, _, _ := testOperationJournal(t) diff --git a/internal/filesystem/operation_receipt.go b/internal/filesystem/operation_receipt.go index d5a38799c..6afbfb028 100644 --- a/internal/filesystem/operation_receipt.go +++ b/internal/filesystem/operation_receipt.go @@ -31,7 +31,9 @@ func (s *Service) AcknowledgeOperation(ctx context.Context, id OperationID) erro return err } if _, err := journal.Resolve(ctx, id); errors.Is(err, ErrOperationPending) { - if _, err := journal.Cancel(ctx, id); err != nil { + if _, err := journal.Cancel(ctx, id); errors.Is(err, ErrOperationPending) { + return journal.RetireUnadmitted(ctx, id) + } else if err != nil { return err } } else if err != nil && !errors.Is(err, ErrOperationRetired) { From a64076cafb931be52966244baf12ba66ec930bd1 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 18:49:37 +0900 Subject: [PATCH 31/35] filesystem: drain terminal replies and size handle reservations Signed-off-by: bootjp --- internal/filesystem/fuseadapter/ack.go | 32 ++- internal/filesystem/fuseadapter/ack_test.go | 218 ++++++++++++++++++ internal/filesystem/fuseadapter/adapter.go | 34 ++- .../fuseadapter/adapter_ops_test.go | 4 +- internal/filesystem/fuseadapter/receipts.go | 4 +- internal/filesystem/fuseadapter/server.go | 33 +-- .../filesystem/fuseadapter/unmount_context.go | 13 +- .../operation_handle_effect_limit.go | 25 ++ internal/filesystem/operation_lease.go | 7 +- internal/filesystem/operation_open.go | 7 +- internal/filesystem/operation_open_test.go | 18 ++ third_party/go-fuse/README.md | 11 +- third_party/go-fuse/fuse/api.go | 6 + third_party/go-fuse/fuse/server.go | 3 + 14 files changed, 364 insertions(+), 51 deletions(-) create mode 100644 internal/filesystem/fuseadapter/ack_test.go create mode 100644 internal/filesystem/operation_handle_effect_limit.go diff --git a/internal/filesystem/fuseadapter/ack.go b/internal/filesystem/fuseadapter/ack.go index 8c689c95b..ead4439f3 100644 --- a/internal/filesystem/fuseadapter/ack.go +++ b/internal/filesystem/fuseadapter/ack.go @@ -5,6 +5,7 @@ import ( "context" "log/slog" "slices" + "sort" "time" "github.com/bootjp/elastickv/internal/filesystem" @@ -81,6 +82,12 @@ func (a *Adapter) replyAttempted(unique uint64) { } } +// replySuppressed is the terminal boundary for a request that go-fuse will +// never answer, such as an interrupted request after the connection died. +func (a *Adapter) replySuppressed(unique uint64) { + a.replyAttempted(unique) +} + func (a *Adapter) retryAcknowledgements(ctx context.Context) { ticker := time.NewTicker(ackRetryInterval) defer ticker.Stop() @@ -124,17 +131,23 @@ func (a *Adapter) processAcknowledgementBatch(ctx context.Context) bool { func (a *Adapter) pendingAcknowledgements() []filesystem.OperationID { a.replyMu.Lock() defer a.replyMu.Unlock() - ids := make([]filesystem.OperationID, 0, min(len(a.ackPending), ackBatchSize)) + ids := make([]filesystem.OperationID, 0, len(a.ackPending)) for id := range a.ackPending { ids = append(ids, id) } slices.SortFunc(ids, func(left, right filesystem.OperationID) int { return cmp.Compare(left.Sequence, right.Sequence) }) - if len(ids) > ackBatchSize { - ids = ids[:ackBatchSize] + if len(ids) == 0 { + return ids } - return ids + start := sort.Search(len(ids), func(i int) bool { return ids[i].Sequence > a.ackCursor }) + selected := make([]filesystem.OperationID, 0, min(len(ids), ackBatchSize)) + for i := 0; i < len(ids) && i < ackBatchSize; i++ { + selected = append(selected, ids[(start+i)%len(ids)]) + } + a.ackCursor = selected[len(selected)-1].Sequence + return selected } func (a *Adapter) acknowledgeReceipt(ctx context.Context, id filesystem.OperationID) error { @@ -145,25 +158,28 @@ func (a *Adapter) acknowledgeReceipt(ctx context.Context, id filesystem.Operatio return filesystem.ErrUnsupported } a.receiptMu.Lock() - defer a.receiptMu.Unlock() - if a.hasReceiptLocked(id) { + hasReceipt := a.hasReceiptLocked(id) + a.receiptMu.Unlock() + if hasReceipt { verifier, ok := a.core.(operationReceiptVerifier) if !ok { return filesystem.ErrUnsupported } - if err := verifier.VerifyOperationReceipt(ctx, id); err != nil { + if err := verifier.VerifyOperationReceipt(ctx, id); err != nil && !errors.Is(err, filesystem.ErrOperationRetired) { return errors.Wrap(err, "verify operation before acknowledgement") } } if err := acker.AcknowledgeOperation(ctx, id); err != nil { return errors.Wrap(err, "acknowledge operation after reply") } + a.receiptMu.Lock() for inode, ids := range a.receipts { delete(ids, id) if len(ids) == 0 { delete(a.receipts, inode) } } + a.receiptMu.Unlock() return nil } @@ -190,7 +206,7 @@ func (a *Adapter) finishStrictSession(ctx context.Context) error { defer ticker.Stop() for { a.replyMu.Lock() - pending := len(a.ackPending) + pending := len(a.ackPending) + len(a.replyIDs) a.replyMu.Unlock() if pending == 0 { return errors.Wrap(closer.CloseOperationSession(ctx, a.operationSession), "close strict operation session") diff --git a/internal/filesystem/fuseadapter/ack_test.go b/internal/filesystem/fuseadapter/ack_test.go new file mode 100644 index 000000000..3fd9779c4 --- /dev/null +++ b/internal/filesystem/fuseadapter/ack_test.go @@ -0,0 +1,218 @@ +package fuseadapter + +import ( + "context" + "syscall" + "testing" + "time" + + "github.com/bootjp/elastickv/internal/filesystem" + "github.com/hanwen/go-fuse/v2/fuse" + "github.com/stretchr/testify/require" +) + +type acknowledgementCore struct { + *receiptCore + acknowledged chan filesystem.OperationID + closed bool +} + +type lostAcknowledgementCore struct { + *receiptCore + retired bool +} + +func (c *lostAcknowledgementCore) VerifyOperationReceipt(context.Context, filesystem.OperationID) error { + if c.retired { + return filesystem.ErrOperationRetired + } + return nil +} + +func (c *lostAcknowledgementCore) AcknowledgeOperation(context.Context, filesystem.OperationID) error { + if !c.retired { + c.retired = true + return context.DeadlineExceeded + } + return nil +} + +func (c *acknowledgementCore) AcknowledgeOperation(_ context.Context, id filesystem.OperationID) error { + c.acknowledged <- id + return nil +} + +func (c *acknowledgementCore) CloseOperationSession(_ context.Context, _ filesystem.OperationSessionID) error { + select { + case <-c.acknowledged: + c.closed = true + return nil + default: + return filesystem.ErrOperationPending + } +} + +func newAcknowledgementCore() *acknowledgementCore { + return &acknowledgementCore{ + receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ + journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, + }}, + acknowledged: make(chan filesystem.OperationID, 1), + } +} + +func TestAcknowledgementAcceptsRetiredReceipt(t *testing.T) { + core := newAcknowledgementCore() + core.verifyErr = filesystem.ErrOperationRetired + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0)) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReceipt(7, id) + require.NoError(t, adapter.acknowledgeReceipt(t.Context(), id)) + require.Empty(t, adapter.receipts) + select { + case got := <-core.acknowledged: + require.Equal(t, id, got) + default: + t.Fatal("operation was not acknowledged") + } + adapter.recordReceipt(7, id) + require.Zero(t, adapter.Fsync(t.Context(), 7, 9, false)) + require.True(t, core.fsyncCalled) +} + +func TestAcknowledgementRetriesAfterLostResponse(t *testing.T) { + core := &lostAcknowledgementCore{receiptCore: newAcknowledgementCore().receiptCore} + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0)) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReceipt(7, id) + require.ErrorIs(t, adapter.acknowledgeReceipt(t.Context(), id), context.DeadlineExceeded) + require.Contains(t, adapter.receipts[7], id) + require.NoError(t, adapter.acknowledgeReceipt(t.Context(), id)) + require.Empty(t, adapter.receipts) +} + +type blockingAcknowledgementCore struct { + *acknowledgementCore + verifying chan struct{} + release chan struct{} +} + +func (c *blockingAcknowledgementCore) VerifyOperationReceipt(_ context.Context, _ filesystem.OperationID) error { + close(c.verifying) + <-c.release + return nil +} + +func TestAcknowledgementDoesNotHoldReceiptLockDuringVerification(t *testing.T) { + core := &blockingAcknowledgementCore{ + acknowledgementCore: newAcknowledgementCore(), + verifying: make(chan struct{}), + release: make(chan struct{}), + } + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0)) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReceipt(7, id) + done := make(chan error, 1) + go func() { done <- adapter.acknowledgeReceipt(t.Context(), id) }() + select { + case <-core.verifying: + case <-time.After(time.Second): + t.Fatal("verification did not start") + } + recorded := make(chan struct{}) + go func() { + adapter.recordReceipt(8, filesystem.OperationID{Filesystem: id.Filesystem, Session: id.Session, Sequence: 2}) + close(recorded) + }() + select { + case <-recorded: + case <-time.After(time.Second): + t.Fatal("receipt lock held during verification") + } + close(core.release) + require.NoError(t, <-done) +} + +func TestFsyncDoesNotHoldReceiptLockDuringVerification(t *testing.T) { + core := &blockingAcknowledgementCore{ + acknowledgementCore: newAcknowledgementCore(), + verifying: make(chan struct{}), + release: make(chan struct{}), + } + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0)) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReceipt(7, id) + done := make(chan syscall.Errno, 1) + go func() { done <- adapter.Fsync(t.Context(), 7, 9, false) }() + select { + case <-core.verifying: + case <-time.After(time.Second): + t.Fatal("verification did not start") + } + recorded := make(chan struct{}) + go func() { + adapter.recordReceipt(8, filesystem.OperationID{Filesystem: id.Filesystem, Session: id.Session, Sequence: 2}) + close(recorded) + }() + select { + case <-recorded: + case <-time.After(time.Second): + t.Fatal("receipt lock held during fsync verification") + } + close(core.release) + require.Zero(t, <-done) +} + +func TestStrictCloseDrainsAcknowledgements(t *testing.T) { + core := newAcknowledgementCore() + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0), WithStrictMode(session)) + adapter.queueAcknowledgement(filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 1}) + adapter.Close() + require.True(t, core.closed) +} + +func TestAcknowledgementBatchRotatesPastFailures(t *testing.T) { + adapter := New(&fakeCore{}, []byte("client"), WithHandleKeepaliveInterval(0)) + adapter.ackPending = make(map[filesystem.OperationID]struct{}) + for sequence := uint64(1); sequence <= 70; sequence++ { + adapter.ackPending[filesystem.OperationID{ + Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: sequence, + }] = struct{}{} + } + first := adapter.pendingAcknowledgements() + second := adapter.pendingAcknowledgements() + require.Len(t, first, ackBatchSize) + require.EqualValues(t, 1, first[0].Sequence) + require.EqualValues(t, 64, first[len(first)-1].Sequence) + require.EqualValues(t, 65, second[0].Sequence) + require.EqualValues(t, 70, second[5].Sequence) +} + +func TestSuppressedReplyQueuesOperationIdentity(t *testing.T) { + adapter := New(&fakeCore{}, []byte("client"), WithHandleKeepaliveInterval(0)) + fs := NewRawFileSystem(adapter) + id := filesystem.OperationID{Filesystem: [16]byte{1}, Session: [16]byte{2}, Sequence: 1} + adapter.recordReplyIdentity(withReplyIdentity(t.Context(), 42), id) + fs.OnReplySuppressed(42, 0, fuse.EINTR) + require.Empty(t, adapter.replyIDs) + require.Contains(t, adapter.ackPending, id) +} + +func TestStrictSessionWaitsForUnansweredRequest(t *testing.T) { + core := newAcknowledgementCore() + session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} + adapter := New(core, []byte("client"), WithHandleKeepaliveInterval(0), WithStrictMode(session)) + t.Cleanup(adapter.Close) + id := filesystem.OperationID{Filesystem: session.Filesystem, Session: session.Session, Sequence: 1} + adapter.recordReplyIdentity(withReplyIdentity(t.Context(), 42), id) + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer cancel() + require.ErrorIs(t, adapter.finishStrictSession(ctx), context.DeadlineExceeded) + require.False(t, core.closed) + adapter.replySuppressed(42) + ctx, cancel = context.WithTimeout(t.Context(), time.Second) + defer cancel() + require.NoError(t, adapter.finishStrictSession(ctx)) + require.True(t, core.closed) +} diff --git a/internal/filesystem/fuseadapter/adapter.go b/internal/filesystem/fuseadapter/adapter.go index 02e29be20..eb275874f 100644 --- a/internal/filesystem/fuseadapter/adapter.go +++ b/internal/filesystem/fuseadapter/adapter.go @@ -69,6 +69,7 @@ type Adapter struct { keepaliveInterval time.Duration keepaliveTimeout time.Duration keepaliveCancel context.CancelFunc + keepaliveDone chan struct{} closeOnce sync.Once handlesMu sync.Mutex handles map[openHandleKey]struct{} @@ -77,6 +78,7 @@ type Adapter struct { replyMu sync.Mutex replyIDs map[uint64][]filesystem.OperationID ackPending map[filesystem.OperationID]struct{} + ackCursor uint64 ackWake chan struct{} ackCancel context.CancelFunc } @@ -253,7 +255,11 @@ func New(core Core, clientID []byte, opts ...Option) *Adapter { if a.keepaliveInterval > disabledHandleKeepaliveInterval { ctx, cancel := context.WithCancel(context.Background()) a.keepaliveCancel = cancel - go a.keepOpenHandlesAlive(ctx) + a.keepaliveDone = make(chan struct{}) + go func() { + defer close(a.keepaliveDone) + a.keepOpenHandlesAlive(ctx) + }() } if a.operationSession != (filesystem.OperationSessionID{}) { ctx, cancel := context.WithCancel(context.Background()) @@ -269,16 +275,27 @@ func (a *Adapter) Close() { if a.keepaliveCancel != nil { a.keepaliveCancel() } - if a.ackCancel != nil { - a.ackCancel() - } if a.strictMode { ctx, cancel := context.WithTimeout(context.Background(), ackTimeout) defer cancel() - if err := a.finishStrictSession(ctx); err != nil { + var err error + if a.keepaliveDone != nil { + select { + case <-a.keepaliveDone: + case <-ctx.Done(): + err = ctx.Err() + } + } + if err == nil { + err = a.finishStrictSession(ctx) + } + if err != nil { slog.Warn("filesystem strict session remains open after close", "error", err.Error()) } } + if a.ackCancel != nil { + a.ackCancel() + } }) } @@ -741,9 +758,10 @@ func (a *Adapter) refreshJournaledLease(ctx context.Context, inode uint64, fh ui a.retainLatestLeaseReceipt(ctx, inode, fh, id) a.markLeaseRefreshed(inode, fh) } - // Keepalive renewals have no kernel reply. Completion of the renewal is - // their acknowledgement boundary; uncertain outcomes remain retained. - if ctx.Value(backgroundOperationKey{}) == true && !errors.Is(err, kv.ErrTransactionOutcomeUnknown) { + // Keepalive renewals have no kernel reply. Completion of the call is their + // acknowledgement boundary; the worker resolves uncertain outcomes under + // the journal's admission and publication fences before retirement. + if ctx.Value(backgroundOperationKey{}) == true { a.queueAcknowledgement(id) } return errors.Wrap(err, "refresh journaled open handle lease") diff --git a/internal/filesystem/fuseadapter/adapter_ops_test.go b/internal/filesystem/fuseadapter/adapter_ops_test.go index c7ce86ab1..132557912 100644 --- a/internal/filesystem/fuseadapter/adapter_ops_test.go +++ b/internal/filesystem/fuseadapter/adapter_ops_test.go @@ -191,7 +191,7 @@ func TestAdapterKeepaliveUsesJournaledLeaseRefresh(t *testing.T) { require.ElementsMatch(t, core.leaseIDs, core.verified) } -func TestAdapterKeepaliveRetainsUnknownLease(t *testing.T) { +func TestAdapterKeepaliveQueuesUnknownLeaseForResolution(t *testing.T) { session := filesystem.OperationSessionID{Filesystem: [16]byte{1}, Session: [16]byte{2}} core := &journalLeaseCore{receiptCore: &receiptCore{journalMetadataCore: &journalMetadataCore{ journalWriteCore: &journalWriteCore{fakeCore: &fakeCore{}}, @@ -204,7 +204,7 @@ func TestAdapterKeepaliveRetainsUnknownLease(t *testing.T) { adapter.replyMu.Lock() _, queued := adapter.ackPending[core.leaseIDs[0]] adapter.replyMu.Unlock() - require.False(t, queued) + require.True(t, queued) } func (c *journalLifecycleCore) CreateWithOperation(_ context.Context, id filesystem.OperationID, _ uint64, _ []byte, _ filesystem.CreateOptions) (filesystem.CreateResult, error) { diff --git a/internal/filesystem/fuseadapter/receipts.go b/internal/filesystem/fuseadapter/receipts.go index 712b46a83..cf363b5b0 100644 --- a/internal/filesystem/fuseadapter/receipts.go +++ b/internal/filesystem/fuseadapter/receipts.go @@ -52,11 +52,11 @@ func (a *Adapter) retainLatestLeaseReceipt(ctx context.Context, inode, fh uint64 func (a *Adapter) verifyReceipts(ctx context.Context, inode uint64) ([]filesystem.OperationID, syscall.Errno) { a.receiptMu.Lock() - defer a.receiptMu.Unlock() receipts := make([]filesystem.OperationID, 0, len(a.receipts[inode])) for id := range a.receipts[inode] { receipts = append(receipts, id) } + a.receiptMu.Unlock() if len(receipts) == 0 { return receipts, 0 } @@ -65,7 +65,7 @@ func (a *Adapter) verifyReceipts(ctx context.Context, inode uint64) ([]filesyste return nil, syscall.EOPNOTSUPP } for _, id := range receipts { - if err := verifier.VerifyOperationReceipt(ctx, id); err != nil { + if err := verifier.VerifyOperationReceipt(ctx, id); err != nil && !errors.Is(err, filesystem.ErrOperationRetired) { return nil, Errno(err) } } diff --git a/internal/filesystem/fuseadapter/server.go b/internal/filesystem/fuseadapter/server.go index cd593632c..90075de68 100644 --- a/internal/filesystem/fuseadapter/server.go +++ b/internal/filesystem/fuseadapter/server.go @@ -94,6 +94,12 @@ func (r *RawFileSystem) OnReplyAttempt(unique uint64, _ uint32, _ fuse.Status, _ r.adapter.replyAttempted(unique) } +// OnReplySuppressed retires a request after go-fuse has decided that no FUSE +// reply can be sent, including an interrupted request on a dead connection. +func (r *RawFileSystem) OnReplySuppressed(unique uint64, _ uint32, _ fuse.Status) { + r.adapter.replySuppressed(unique) +} + // toStatus converts an adapter errno into a FUSE status. Errno values // outside the int32 range of fuse.Status cannot be a real errno, so they // map to EIO instead of being truncated. @@ -907,30 +913,19 @@ func (s *Server) Unmount() error { if s == nil { return nil } - defer func() { + if s.inner == nil || s.isReleased() { if s.adapter != nil { s.adapter.Close() } - }() - if s.inner == nil || s.isReleased() { return nil } if err := s.inner.Unmount(); err != nil { return errors.WithStack(err) } - s.finishStrictSession() - return nil -} - -func (s *Server) finishStrictSession() { - if s.adapter == nil { - return - } - ctx, cancel := context.WithTimeout(context.Background(), ackTimeout) - defer cancel() - if err := s.adapter.finishStrictSession(ctx); err != nil { - slog.Warn("filesystem strict session remains open after unmount", "error", err.Error()) + if s.adapter != nil { + s.adapter.Close() } + return nil } // UnmountLazy detaches the mount from the namespace even while clients still @@ -954,12 +949,18 @@ func (s *Server) UnmountLazy(ctx context.Context) error { } s.fs.cancelInFlight() if s.inner == nil || s.mountPoint == "" { + if s.adapter != nil { + s.adapter.Close() + } return nil } _, err := detachFUSEMount(ctx, s.mountPoint, s.fsType) if errors.Is(err, errNoFUSEMount) { slog.InfoContext(ctx, "filesystem FUSE mount is already gone, nothing to detach", "mount_point", s.mountPoint) - return nil + err = nil + } + if err == nil && s.adapter != nil { + s.adapter.Close() } return err } diff --git a/internal/filesystem/fuseadapter/unmount_context.go b/internal/filesystem/fuseadapter/unmount_context.go index 32d87bc85..09fb77599 100644 --- a/internal/filesystem/fuseadapter/unmount_context.go +++ b/internal/filesystem/fuseadapter/unmount_context.go @@ -13,21 +13,22 @@ func (s *Server) UnmountContext(ctx context.Context) error { if s == nil { return nil } - defer func() { + if s.inner == nil || s.isReleased() || s.mountPoint == "" { if s.adapter != nil { s.adapter.Close() } - }() - if s.inner == nil || s.isReleased() || s.mountPoint == "" { return nil } // Ordinary unmount can succeed before backend handlers leave Serve. // Cancel their common parent before the lifecycle can close stores. - defer s.fs.cancelInFlight() - if err := unmountFUSEMount(ctx, s.mountPoint, s.fsType); err != nil { + err := unmountFUSEMount(ctx, s.mountPoint, s.fsType) + s.fs.cancelInFlight() + if err != nil { return err } - s.finishStrictSession() + if s.adapter != nil { + s.adapter.Close() + } return nil } diff --git a/internal/filesystem/operation_handle_effect_limit.go b/internal/filesystem/operation_handle_effect_limit.go new file mode 100644 index 000000000..37606e812 --- /dev/null +++ b/internal/filesystem/operation_handle_effect_limit.go @@ -0,0 +1,25 @@ +package filesystem + +import ( + "math" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/cockroachdb/errors" +) + +const minHandleOperationEffectLimit = 256 + +// handleLeaseEffectLimit reserves the encoded key bytes that the operation +// may publish. The client ID uses ordered-byte escaping in RefKey, so its +// encoded length must be measured rather than inferred from len(clientID). +func handleLeaseEffectLimit(inode uint64, clientID []byte, withFence bool) (uint32, error) { + keyBytes := uint64(len(fskeys.RefKey(inode, clientID, 0))) + if withFence { + keyBytes += uint64(len(fskeys.RefFenceKey(inode))) + } + limit := max(uint64(minHandleOperationEffectLimit), keyBytes) + if limit > math.MaxUint32 { + return 0, errors.WithStack(ErrOperationQuota) + } + return uint32(limit), nil +} diff --git a/internal/filesystem/operation_lease.go b/internal/filesystem/operation_lease.go index 014a9619a..67800c16c 100644 --- a/internal/filesystem/operation_lease.go +++ b/internal/filesystem/operation_lease.go @@ -14,7 +14,6 @@ import ( const ( leaseOperationVersion = 1 leaseOperationResultLimit = 1 - leaseOperationEffectLimit = 256 leaseResultSuccess = 0 leaseResultMissing = 1 ) @@ -33,7 +32,11 @@ func (s *Service) RefreshOpenHandleLeaseWithOperation(ctx context.Context, id Op if err != nil { return errors.Wrap(err, "encode lease intent") } - journal, attempt, terminal, err := s.admitHandleOperation(ctx, id, intent, leaseOperationResultLimit, leaseOperationEffectLimit) + effectLimit, err := handleLeaseEffectLimit(inode, clientID, false) + if err != nil { + return err + } + journal, attempt, terminal, err := s.admitHandleOperation(ctx, id, intent, leaseOperationResultLimit, effectLimit) if err != nil { return err } diff --git a/internal/filesystem/operation_open.go b/internal/filesystem/operation_open.go index e3f35d781..327b31f4e 100644 --- a/internal/filesystem/operation_open.go +++ b/internal/filesystem/operation_open.go @@ -14,7 +14,6 @@ import ( const ( openOperationVersion = 1 openOperationResultLimit = 128 - openOperationEffectLimit = 256 openResultMissing = 1 ) @@ -37,11 +36,15 @@ func (s *Service) OpenWithOperation(ctx context.Context, id OperationID, inode u if err != nil { return 0, errors.Wrap(err, "encode open intent") } + effectLimit, err := handleLeaseEffectLimit(inode, clientID, true) + if err != nil { + return 0, err + } journal, err := NewOperationJournal(s) if err != nil { return 0, err } - admitted, err := journal.Admit(ctx, id, intent, openOperationResultLimit, openOperationEffectLimit) + admitted, err := journal.Admit(ctx, id, intent, openOperationResultLimit, effectLimit) if err != nil { return 0, err } diff --git a/internal/filesystem/operation_open_test.go b/internal/filesystem/operation_open_test.go index ca4677e4d..6825e05d0 100644 --- a/internal/filesystem/operation_open_test.go +++ b/internal/filesystem/operation_open_test.go @@ -1,6 +1,7 @@ package filesystem import ( + "bytes" "context" "testing" @@ -8,6 +9,23 @@ import ( "github.com/stretchr/testify/require" ) +func TestHandleOperationsSupportLongClientID(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := bytes.Repeat([]byte{0}, 300) // ordered-byte encoding expands NUL bytes + openID := testWriteOperationID(t, svc, 1) + fh, err := svc.OpenWithOperation(ctx, openID, file.Inode, clientID) + require.NoError(t, err) + require.NoError(t, svc.VerifyOperationReceipt(ctx, openID)) + leaseID := openID + leaseID.Sequence = 2 + require.NoError(t, svc.RefreshOpenHandleLeaseWithOperation(ctx, leaseID, file.Inode, fh, clientID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, leaseID)) +} + func TestOpenWithOperationRetainsOneHandle(t *testing.T) { ctx := context.Background() svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) diff --git a/third_party/go-fuse/README.md b/third_party/go-fuse/README.md index f3c7a8083..9e671e31a 100644 --- a/third_party/go-fuse/README.md +++ b/third_party/go-fuse/README.md @@ -3,10 +3,11 @@ This directory contains the `fuse` and `splice` packages from `github.com/hanwen/go-fuse/v2` v2.12.0, under its BSD license. -The local change adds `ReplyAttemptObserver` and calls it immediately after +The local change adds `ReplyAttemptObserver` immediately after `Server.handleRequest` attempts to write a response to the FUSE device. -Elastickv uses that boundary to retire terminal filesystem operation IDs. -The callback is skipped when go-fuse suppresses the reply. +`ReplySuppressedObserver` runs instead when go-fuse has decided to suppress +the reply. Elastickv uses either terminal boundary to retire filesystem +operation IDs after resolving their journal outcome. -When updating go-fuse, preserve the notification after the device write and -the suppressed-reply condition, then rerun the kernel FUSE tests. +When updating go-fuse, preserve both notification boundaries, then rerun the +kernel FUSE tests. diff --git a/third_party/go-fuse/fuse/api.go b/third_party/go-fuse/fuse/api.go index 4691e0816..607a6048f 100644 --- a/third_party/go-fuse/fuse/api.go +++ b/third_party/go-fuse/fuse/api.go @@ -424,6 +424,12 @@ type ReplyAttemptObserver interface { OnReplyAttempt(unique uint64, opcode uint32, replyStatus Status, writeStatus Status) } +// ReplySuppressedObserver is called after the handler returns when the FUSE +// connection will not send a reply for the request. +type ReplySuppressedObserver interface { + OnReplySuppressed(unique uint64, opcode uint32, replyStatus Status) +} + type RawFileSystem interface { String() string diff --git a/third_party/go-fuse/fuse/server.go b/third_party/go-fuse/fuse/server.go index ea4aa6d45..658b8f261 100644 --- a/third_party/go-fuse/fuse/server.go +++ b/third_party/go-fuse/fuse/server.go @@ -520,6 +520,9 @@ func (ms *Server) handleRequest(req *requestAlloc) Status { } ms.protocolServer.handleRequest(h, &req.request) if req.suppressReply { + if observer, ok := ms.fileSystem.(ReplySuppressedObserver); ok { + observer.OnReplySuppressed(req.inHeader().Unique, req.inHeader().Opcode, req.status) + } return OK } errno := ms.fuseFD.write(&req.request) From 10ace1d7cd3673003ebc7a9a958e7812f36645c0 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 18:59:51 +0900 Subject: [PATCH 32/35] filesystem: size create reservations from effect keys Signed-off-by: bootjp --- internal/filesystem/operation_create.go | 18 +++++++++++++++++- internal/filesystem/operation_create_test.go | 18 ++++++++++++++++++ ...fect_limit.go => operation_effect_limit.go} | 15 ++++++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) rename internal/filesystem/{operation_handle_effect_limit.go => operation_effect_limit.go} (60%) diff --git a/internal/filesystem/operation_create.go b/internal/filesystem/operation_create.go index 01070abcf..f0956a068 100644 --- a/internal/filesystem/operation_create.go +++ b/internal/filesystem/operation_create.go @@ -53,11 +53,15 @@ func (s *Service) createNodeWithOperation(ctx context.Context, id OperationID, p if err != nil { return CreateResult{}, errors.Wrap(err, "encode create intent") } + effectLimit, err := createEffectLimit(parent, name, opts.ClientID) + if err != nil { + return CreateResult{}, err + } journal, err := NewOperationJournal(s) if err != nil { return CreateResult{}, err } - admitted, err := journal.Admit(ctx, id, intent, createOperationResultLimit, createOperationEffectLimit) + admitted, err := journal.Admit(ctx, id, intent, createOperationResultLimit, effectLimit) if err != nil { return CreateResult{}, err } @@ -71,6 +75,18 @@ func (s *Service) createNodeWithOperation(ctx context.Context, id OperationID, p return s.executeCreateOperation(ctx, journal, id, attempt, parent, name, typ, opts, intent) } +func createEffectLimit(parent uint64, name, clientID []byte) (uint32, error) { + keys := [][]byte{ + fskeys.InodeKey(0), fskeys.HomeKey(0), fskeys.DirEntryKey(parent, name), + fskeys.InodeKey(parent), fskeys.DirVersionKey(parent), + fskeys.UsageRouteKey(kv.RouteKey(fskeys.InodeKey(0))), + } + if len(clientID) > 0 { + keys = append(keys, fskeys.RefKey(0, clientID, 0)) + } + return operationEffectLimitForKeys(createOperationEffectLimit, keys...) +} + func (s *Service) executeCreateOperation(ctx context.Context, journal *OperationJournal, id OperationID, attempt operationRecord, parent uint64, name []byte, typ FileType, opts CreateOptions, intent []byte) (CreateResult, error) { deadlineCtx, cancel := context.WithTimeout(ctx, writeConflictRetryBudget) defer cancel() diff --git a/internal/filesystem/operation_create_test.go b/internal/filesystem/operation_create_test.go index 91fc01f85..bb1c5d91a 100644 --- a/internal/filesystem/operation_create_test.go +++ b/internal/filesystem/operation_create_test.go @@ -1,6 +1,7 @@ package filesystem import ( + "bytes" "context" "testing" @@ -8,6 +9,23 @@ import ( "github.com/stretchr/testify/require" ) +func TestCreateWithOperationSupportsLongClientID(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + clientID := bytes.Repeat([]byte("a"), 4096) + result, err := svc.CreateWithOperation(ctx, id, RootInode, []byte("file"), CreateOptions{ + Mode: testFileMode, ClientID: clientID, + }) + require.NoError(t, err) + require.NotZero(t, result.FH) + require.NoError(t, svc.VerifyOperationReceipt(ctx, id)) + resolved, err := svc.Resolve(ctx, RootInode, []byte("file")) + require.NoError(t, err) + require.Equal(t, result.Inode, resolved) +} + func TestCreateWithOperationRetainsOriginalInodeAndHandle(t *testing.T) { ctx := context.Background() svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) diff --git a/internal/filesystem/operation_handle_effect_limit.go b/internal/filesystem/operation_effect_limit.go similarity index 60% rename from internal/filesystem/operation_handle_effect_limit.go rename to internal/filesystem/operation_effect_limit.go index 37606e812..899ba3392 100644 --- a/internal/filesystem/operation_handle_effect_limit.go +++ b/internal/filesystem/operation_effect_limit.go @@ -13,11 +13,20 @@ const minHandleOperationEffectLimit = 256 // may publish. The client ID uses ordered-byte escaping in RefKey, so its // encoded length must be measured rather than inferred from len(clientID). func handleLeaseEffectLimit(inode uint64, clientID []byte, withFence bool) (uint32, error) { - keyBytes := uint64(len(fskeys.RefKey(inode, clientID, 0))) + keys := [][]byte{fskeys.RefKey(inode, clientID, 0)} if withFence { - keyBytes += uint64(len(fskeys.RefFenceKey(inode))) + keys = append(keys, fskeys.RefFenceKey(inode)) } - limit := max(uint64(minHandleOperationEffectLimit), keyBytes) + return operationEffectLimitForKeys(minHandleOperationEffectLimit, keys...) +} + +func operationEffectLimitForKeys(minimum uint32, keys ...[]byte) (uint32, error) { + limit := uint64(minimum) + keyBytes := uint64(0) + for _, key := range keys { + keyBytes += uint64(len(key)) + } + limit = max(limit, keyBytes) if limit > math.MaxUint32 { return 0, errors.WithStack(ErrOperationQuota) } From 2488c638055de561d659cfedc87d9e32f06e0cb5 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 19:08:39 +0900 Subject: [PATCH 33/35] filesystem: reserve release lease effects Signed-off-by: bootjp --- internal/filesystem/operation_effect_limit.go | 14 ++++++++---- internal/filesystem/operation_release.go | 10 ++++++++- internal/filesystem/operation_release_test.go | 22 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/internal/filesystem/operation_effect_limit.go b/internal/filesystem/operation_effect_limit.go index 899ba3392..52606cd2e 100644 --- a/internal/filesystem/operation_effect_limit.go +++ b/internal/filesystem/operation_effect_limit.go @@ -21,12 +21,18 @@ func handleLeaseEffectLimit(inode uint64, clientID []byte, withFence bool) (uint } func operationEffectLimitForKeys(minimum uint32, keys ...[]byte) (uint32, error) { - limit := uint64(minimum) - keyBytes := uint64(0) + keyBytes, err := operationEffectLimitWithSlack(0, keys...) + if err != nil { + return 0, err + } + return max(minimum, keyBytes), nil +} + +func operationEffectLimitWithSlack(slack uint32, keys ...[]byte) (uint32, error) { + limit := uint64(slack) for _, key := range keys { - keyBytes += uint64(len(key)) + limit += uint64(len(key)) } - limit = max(limit, keyBytes) if limit > math.MaxUint32 { return 0, errors.WithStack(ErrOperationQuota) } diff --git a/internal/filesystem/operation_release.go b/internal/filesystem/operation_release.go index 0e4071dfc..2f24ee1e0 100644 --- a/internal/filesystem/operation_release.go +++ b/internal/filesystem/operation_release.go @@ -30,7 +30,11 @@ func (s *Service) ReleaseWithOperation(ctx context.Context, id OperationID, inod if err != nil { return errors.Wrap(err, "encode release intent") } - journal, attempt, terminal, err := s.admitHandleOperation(ctx, id, intent, releaseOperationResultLimit, releaseOperationEffectLimit) + effectLimit, err := releaseEffectLimit(inode, fh, clientID) + if err != nil { + return err + } + journal, attempt, terminal, err := s.admitHandleOperation(ctx, id, intent, releaseOperationResultLimit, effectLimit) if err != nil { return err } @@ -40,6 +44,10 @@ func (s *Service) ReleaseWithOperation(ctx context.Context, id OperationID, inod return s.executeReleaseOperation(ctx, journal, id, attempt, inode, fh, clientID, intent) } +func releaseEffectLimit(inode, fh uint64, clientID []byte) (uint32, error) { + return operationEffectLimitWithSlack(releaseOperationEffectLimit, fskeys.RefKey(inode, clientID, fh)) +} + func (s *Service) admitHandleOperation(ctx context.Context, id OperationID, intent []byte, resultLimit, effectLimit uint32) (*OperationJournal, operationRecord, bool, error) { journal, err := NewOperationJournal(s) if err != nil { diff --git a/internal/filesystem/operation_release_test.go b/internal/filesystem/operation_release_test.go index 3a35d21c7..7e140a084 100644 --- a/internal/filesystem/operation_release_test.go +++ b/internal/filesystem/operation_release_test.go @@ -1,6 +1,7 @@ package filesystem import ( + "bytes" "context" "testing" @@ -8,6 +9,27 @@ import ( "github.com/stretchr/testify/require" ) +func TestReleaseWithOperationSupportsLongClientID(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := bytes.Repeat([]byte("a"), 65536) + openID := testWriteOperationID(t, svc, 1) + fh, err := svc.OpenWithOperation(ctx, openID, file.Inode, clientID) + require.NoError(t, err) + releaseID := openID + releaseID.Sequence = 2 + require.NoError(t, svc.ReleaseWithOperation(ctx, releaseID, file.Inode, fh, clientID)) + require.NoError(t, svc.VerifyOperationReceipt(ctx, releaseID)) + ts, err := svc.readTS(ctx) + require.NoError(t, err) + _, exists, err := svc.openHandleLeaseAt(ctx, fskeys.RefKey(file.Inode, clientID, fh), ts) + require.NoError(t, err) + require.False(t, exists) +} + func TestReleaseWithOperationDoesNotRemoveNewHandle(t *testing.T) { ctx := context.Background() svc := newTestServiceWithOptions(t, []uint64{2, 3, 4}) From 1989dcc6cfcd36474c2179b326e49114b9d6c8a4 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 20:19:57 +0900 Subject: [PATCH 34/35] filesystem: settle exhausted conflicts and split stress jobs Signed-off-by: bootjp --- .github/workflows/jepsen-test-scheduled.yml | 216 +++++++----------- internal/filesystem/operation_contention.go | 43 ++++ .../filesystem/operation_contention_test.go | 151 ++++++++++++ internal/filesystem/operation_create.go | 21 +- internal/filesystem/operation_journal.go | 23 +- internal/filesystem/operation_journal_exec.go | 33 ++- internal/filesystem/operation_lease.go | 10 +- internal/filesystem/operation_open.go | 20 +- internal/filesystem/operation_release.go | 10 +- internal/filesystem/operation_rename.go | 24 +- internal/filesystem/operation_setattr.go | 14 +- internal/filesystem/operation_unlink.go | 26 ++- internal/filesystem/operation_write.go | 2 +- 13 files changed, 416 insertions(+), 177 deletions(-) create mode 100644 internal/filesystem/operation_contention.go create mode 100644 internal/filesystem/operation_contention_test.go diff --git a/.github/workflows/jepsen-test-scheduled.yml b/.github/workflows/jepsen-test-scheduled.yml index 0afa783b3..03741b7d8 100644 --- a/.github/workflows/jepsen-test-scheduled.yml +++ b/.github/workflows/jepsen-test-scheduled.yml @@ -36,9 +36,35 @@ permissions: contents: read jobs: test: + name: test (${{ matrix.workload }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + workload: + - unit + - redis + - redis-zset + - dynamodb + - dynamodb-type-string + - dynamodb-type-number + - dynamodb-type-binary + - dynamodb-type-bool + - dynamodb-type-null + - dynamodb-type-string-set + - dynamodb-type-number-set + - dynamodb-type-binary-set + - dynamodb-type-list + - dynamodb-type-map + - s3 + - filesystem-cross-mount + - filesystem-sqlite-check + - filesystem-overwrite + - filesystem-rename + - sqlite env: GOCACHE: /tmp/go-build + WORKLOAD: ${{ matrix.workload }} steps: - uses: actions/checkout@v7 with: @@ -74,20 +100,15 @@ jobs: restore-keys: | ${{ runner.os }}-maven- - name: Pre-fetch Go modules and build binaries - # The demo cluster is built rather than started through `go run`: - # `go run` execs the compiled program as a child and does not forward - # SIGTERM to it, so killing the recorded `go run` PID would leave the - # demo listening and block the filesystem cluster below. The server - # binary serves that filesystem stress phase: the demo cluster - # (cmd/server/demo.go) has no --filesystemMount, so the FUSE workload - # runs against three real server processes instead. + if: matrix.workload != 'unit' run: | mkdir -p "$GOCACHE" /tmp/go-tmp - GOPATH=$(go env GOPATH) export GOCACHE GOTMPDIR=/tmp/go-tmp go mod download - go build -o /tmp/elastickv-demo ./cmd/server - go build -o /tmp/elastickv-bin . + case "$WORKLOAD" in + filesystem-*|sqlite) go build -o /tmp/elastickv-bin . ;; + *) go build -o /tmp/elastickv-demo ./cmd/server ;; + esac # Warm the Maven cache up front so unit tests / workloads start # against a populated ~/.m2. `lein deps` is wrapped with a retry # loop because Maven Central transiently returns 429 during peak @@ -111,16 +132,18 @@ jobs: sleep "$sleep_secs" done - name: Run Jepsen unit tests + if: matrix.workload == 'unit' working-directory: jepsen run: ~/lein test - name: Launch demo cluster + if: matrix.workload != 'unit' && !startsWith(matrix.workload, 'filesystem-') && matrix.workload != 'sqlite' run: | set -euo pipefail nohup /tmp/elastickv-demo > /tmp/elastickv-demo.log 2>&1 & echo $! > /tmp/elastickv-demo.pid echo "Waiting for redis (63791-63793), dynamo (63801-63803), and s3 (63901-63903) listeners..." - for i in {1..90}; do + for _ in {1..90}; do if nc -z 127.0.0.1 63791 && nc -z 127.0.0.1 63792 && nc -z 127.0.0.1 63793 \ && nc -z 127.0.0.1 63801 && nc -z 127.0.0.1 63802 && nc -z 127.0.0.1 63803 \ && nc -z 127.0.0.1 63901 && nc -z 127.0.0.1 63902 && nc -z 127.0.0.1 63903; then @@ -134,6 +157,7 @@ jobs: tail -n 200 /tmp/elastickv-demo.log || true exit 1 - name: Run Redis Jepsen workload against elastickv + if: matrix.workload == 'redis' working-directory: jepsen timeout-minutes: 15 run: | @@ -147,6 +171,7 @@ jobs: --ports 63791,63792,63793 \ --host 127.0.0.1 - name: Run Redis ZSet safety Jepsen workload against elastickv + if: matrix.workload == 'redis-zset' working-directory: jepsen timeout-minutes: 15 run: | @@ -157,6 +182,7 @@ jobs: --ports 63791,63792,63793 \ --host 127.0.0.1 - name: Run DynamoDB Jepsen workload against elastickv + if: matrix.workload == 'dynamodb' working-directory: jepsen timeout-minutes: 15 run: | @@ -169,64 +195,30 @@ jobs: --max-txn-length ${{ inputs.max-txn-length || '4' }} \ --dynamo-ports 63801,63802,63803 \ --host 127.0.0.1 - - name: Run DynamoDB per-type Jepsen workloads against elastickv + - name: Run DynamoDB per-type Jepsen workload against elastickv + if: startsWith(matrix.workload, 'dynamodb-type-') working-directory: jepsen - # The per-type sweep is a coverage check across all 10 attribute - # types, not the deep stress run — it uses its own shorter - # time-limit so the 10-type loop fits comfortably inside the job - # timeout regardless of the workflow_dispatch time-limit input. - # The per-invocation `timeout` is derived from TYPE_TL + buffer - # so bumping TYPE_TL never races against the outer timeout. - timeout-minutes: 30 + # Each attribute type uses a fresh cluster on its own runner. + timeout-minutes: 6 env: - # Per-type sweep is a coverage check, not the deep stress run, so - # it uses its own shorter runtime and smaller history density than - # the parent dynamodb-workload step. Keeping per-key ops modest - # also keeps Knossos's linearizability analysis inside its - # time budget (dense histories cause :valid? :unknown verdicts). TYPE_TL: "60" TYPE_CONCURRENCY: "4" TYPE_KEY_COUNT: "8" TYPE_MAX_WRITES: "80" run: | - # Run every type independently: one failure does not stop - # the sweep so the final summary shows which specific types - # passed/failed. The step still fails if any type failed. PER_TYPE_TIMEOUT=$((TYPE_TL + 180)) - declare -A RESULT - FAILED=() - for t in string number binary bool null string-set number-set binary-set list map; do - echo "::group::value-type=${t}" - set +e - timeout "${PER_TYPE_TIMEOUT}" ~/lein run -m elastickv.dynamodb-types-workload --local \ - --time-limit "${TYPE_TL}" \ - --rate ${{ inputs.rate || '5' }} \ - --concurrency "${TYPE_CONCURRENCY}" \ - --key-count "${TYPE_KEY_COUNT}" \ - --max-writes-per-key "${TYPE_MAX_WRITES}" \ - --value-type "${t}" \ - --dynamo-ports 63801,63802,63803 \ - --host 127.0.0.1 - rc=$? - set -e - if [ "$rc" -eq 0 ]; then - RESULT[$t]="pass" - else - RESULT[$t]="fail(${rc})" - FAILED+=("$t") - fi - echo "::endgroup::" - done - echo - echo "=== per-type jepsen summary ===" - for t in string number binary bool null string-set number-set binary-set list map; do - printf ' %-12s %s\n' "$t" "${RESULT[$t]}" - done - if [ ${#FAILED[@]} -ne 0 ]; then - echo "FAILED types: ${FAILED[*]}" - exit 1 - fi + TYPE="${WORKLOAD#dynamodb-type-}" + timeout "${PER_TYPE_TIMEOUT}" ~/lein run -m elastickv.dynamodb-types-workload --local \ + --time-limit "${TYPE_TL}" \ + --rate ${{ inputs.rate || '5' }} \ + --concurrency "${TYPE_CONCURRENCY}" \ + --key-count "${TYPE_KEY_COUNT}" \ + --max-writes-per-key "${TYPE_MAX_WRITES}" \ + --value-type "${TYPE}" \ + --dynamo-ports 63801,63802,63803 \ + --host 127.0.0.1 - name: Run S3 Jepsen workload against elastickv + if: matrix.workload == 's3' working-directory: jepsen timeout-minutes: 15 run: | @@ -239,30 +231,8 @@ jobs: --threads-per-key 4 \ --s3-ports 63901,63902,63903 \ --host 127.0.0.1 - - name: Stop demo cluster before the filesystem cluster - # The filesystem stress phase needs --filesystemMount, which the - # single-process demo cluster does not offer, so it runs against a - # real-binary cluster on the same ports once the demo is down. The - # demo keeps its Raft state in memory; nothing is lost by stopping it. - # The PID is the demo binary itself (not a `go run` wrapper), so the - # listeners close with it; the loop below confirms that before the - # ports are reused. - run: | - if [ -f /tmp/elastickv-demo.pid ]; then - pid=$(cat /tmp/elastickv-demo.pid) - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - rm -f /tmp/elastickv-demo.pid - fi - for i in {1..30}; do - if ! nc -z 127.0.0.1 63791 && ! nc -z 127.0.0.1 63792 && ! nc -z 127.0.0.1 63793; then - exit 0 - fi - sleep 1 - done - echo "demo cluster listeners still open after 30s" >&2 - exit 1 - name: Launch filesystem cluster + if: startsWith(matrix.workload, 'filesystem-') || matrix.workload == 'sqlite' # Mirrors the cluster in jepsen-test.yml: every node mounts the shared # filesystem so the workload drives leader and follower mounts alike, # and the root directory is owned by the runner user because the @@ -300,7 +270,7 @@ jobs: done echo "Waiting for redis (63791-63793) listeners and FUSE mounts..." - for i in {1..90}; do + for _ in {1..90}; do if nc -z 127.0.0.1 63791 && nc -z 127.0.0.1 63792 && nc -z 127.0.0.1 63793 \ && mountpoint -q /tmp/elastickv-fs/fs-n1 && mountpoint -q /tmp/elastickv-fs/fs-n2 \ && mountpoint -q /tmp/elastickv-fs/fs-n3; then @@ -316,6 +286,7 @@ jobs: done exit 1 - name: Verify FUSE cross-mount disjoint writes without client retries + if: matrix.workload == 'filesystem-cross-mount' run: | python3 scripts/test-fuse-cross-mount.py \ /tmp/elastickv-fs/fs-n1 \ @@ -323,15 +294,12 @@ jobs: /tmp/elastickv-fs/fs-n3 \ --iterations 50 - name: Verify SQLite rollback journals without client retries + if: matrix.workload == 'filesystem-sqlite-check' run: python3 scripts/test-fuse-sqlite.py /tmp/elastickv-fs/fs-n1 --writes 30 - - name: Run filesystem Jepsen workloads against elastickv + - name: Run filesystem Jepsen workload against elastickv + if: matrix.workload == 'filesystem-overwrite' || matrix.workload == 'filesystem-rename' working-directory: jepsen - # Linearizable register over the FUSE mounts, at the same stress - # settings as the other workloads: every node mounts the same - # filesystem and each key is one file. `overwrite` writes the record - # in place (one chunk RMW); `rename` replaces it through a temp file + - # rename (namespace txn and remove-while-open). Both modes run even if - # one fails so the summary shows which mode broke. + # Each write mode uses a fresh filesystem cluster on its own runner. # See jepsen/src/elastickv/filesystem_workload.clj. timeout-minutes: 30 env: @@ -340,8 +308,6 @@ jobs: run: | # Knossos checks each register independently. Bound each history's # ambiguous writes while keeping the requested total write budget. - # Both write modes can accumulate enough indeterminate operations - # to exhaust Knossos at 64 writes per key. valid_java_positive_int() { [[ "$1" =~ ^[1-9][0-9]*$ ]] || return 1 [[ ${#1} -lt 10 ]] && return 0 @@ -354,41 +320,19 @@ jobs: fi bash test/filesystem_budget_test.sh source ./filesystem_budget.sh - declare -A RESULT - FAILED=() - for mode in overwrite rename; do - echo "::group::write-mode=${mode}" - filesystem_budget "$mode" "$FS_REQUESTED_KEYS" "$FS_REQUESTED_WRITES" - echo "Filesystem register budget: ${FS_KEY_COUNT} keys, ${FS_MAX_WRITES} writes per key" - set +e - timeout 720 ~/lein run -m elastickv.filesystem-workload --local \ - --time-limit ${{ inputs.time-limit || '300' }} \ - --rate ${{ inputs.rate || '10' }} \ - --concurrency ${{ inputs.concurrency || '8' }} \ - --key-count "$FS_KEY_COUNT" \ - --max-writes-per-key "$FS_MAX_WRITES" \ - --write-mode "${mode}" \ - --mount-dirs /tmp/elastickv-fs/fs-n1,/tmp/elastickv-fs/fs-n2,/tmp/elastickv-fs/fs-n3 - rc=$? - set -e - if [ "$rc" -eq 0 ]; then - RESULT[$mode]="pass" - else - RESULT[$mode]="fail(${rc})" - FAILED+=("$mode") - fi - echo "::endgroup::" - done - echo - echo "=== filesystem jepsen summary ===" - for mode in overwrite rename; do - printf ' %-10s %s\n' "$mode" "${RESULT[$mode]}" - done - if [ ${#FAILED[@]} -ne 0 ]; then - echo "FAILED modes: ${FAILED[*]}" - exit 1 - fi + MODE="${WORKLOAD#filesystem-}" + filesystem_budget "$MODE" "$FS_REQUESTED_KEYS" "$FS_REQUESTED_WRITES" + echo "Filesystem register budget: ${FS_KEY_COUNT} keys, ${FS_MAX_WRITES} writes per key" + timeout 720 ~/lein run -m elastickv.filesystem-workload --local \ + --time-limit ${{ inputs.time-limit || '300' }} \ + --rate ${{ inputs.rate || '10' }} \ + --concurrency ${{ inputs.concurrency || '8' }} \ + --key-count "$FS_KEY_COUNT" \ + --max-writes-per-key "$FS_MAX_WRITES" \ + --write-mode "$MODE" \ + --mount-dirs /tmp/elastickv-fs/fs-n1,/tmp/elastickv-fs/fs-n2,/tmp/elastickv-fs/fs-n3 - name: Run SQLite Jepsen workload on FUSE + if: matrix.workload == 'sqlite' working-directory: jepsen timeout-minutes: 20 run: | @@ -404,17 +348,17 @@ jobs: --concurrency 8 --key-count "$SQLITE_KEY_COUNT" --max-writes-per-key "$SQLITE_MAX_WRITES" \ --mount-dirs /tmp/elastickv-fs/fs-n1,/tmp/elastickv-fs/fs-n2,/tmp/elastickv-fs/fs-n3 - name: Archive Jepsen store on failure - if: failure() + if: failure() && matrix.workload != 'unit' run: bash scripts/archive-jepsen-store.sh jepsen/store /tmp/jepsen-store-types.tar.gz - name: Upload Jepsen store on failure - if: failure() + if: failure() && matrix.workload != 'unit' uses: actions/upload-artifact@v7 with: - name: jepsen-store-types + name: jepsen-store-${{ matrix.workload }} path: /tmp/jepsen-store-types.tar.gz retention-days: 7 - name: Dump demo cluster log on failure - if: failure() + if: failure() && matrix.workload != 'unit' && !startsWith(matrix.workload, 'filesystem-') && matrix.workload != 'sqlite' # The previous `tail -n 500` truncated a 3-minute workload's # log down to startup-only lines, making it impossible to # correlate a Jepsen anomaly with the server-side state @@ -430,15 +374,15 @@ jobs: echo "=== full log line count ===" wc -l /tmp/elastickv-demo.log || true - name: Upload demo cluster log on failure - if: failure() + if: failure() && matrix.workload != 'unit' && !startsWith(matrix.workload, 'filesystem-') && matrix.workload != 'sqlite' uses: actions/upload-artifact@v7 with: - name: elastickv-demo-log + name: elastickv-demo-log-${{ matrix.workload }} path: /tmp/elastickv-demo.log retention-days: 14 if-no-files-found: warn - name: Dump filesystem cluster logs on failure - if: failure() + if: failure() && (startsWith(matrix.workload, 'filesystem-') || matrix.workload == 'sqlite') run: | for node in n1 n2 n3; do log=/tmp/elastickv-fs-${node}.log @@ -451,10 +395,10 @@ jobs: wc -l "$log" || true done - name: Upload filesystem cluster logs on failure - if: failure() + if: failure() && (startsWith(matrix.workload, 'filesystem-') || matrix.workload == 'sqlite') uses: actions/upload-artifact@v7 with: - name: elastickv-fs-logs + name: elastickv-fs-logs-${{ matrix.workload }} path: | /tmp/elastickv-fs-n1.log /tmp/elastickv-fs-n2.log diff --git a/internal/filesystem/operation_contention.go b/internal/filesystem/operation_contention.go new file mode 100644 index 000000000..69ca3966c --- /dev/null +++ b/internal/filesystem/operation_contention.go @@ -0,0 +1,43 @@ +package filesystem + +import ( + "context" + + "github.com/bootjp/elastickv/kv" + "github.com/cockroachdb/errors" +) + +const operationCASMaxBackoffShift = 5 + +func waitOperationCASRetry(ctx context.Context, retry int) error { + return waitWriteConflictRetry(ctx, min(retry, operationCASMaxBackoffShift)) +} + +func cancelledOperationError(status, contentionStatus uint8) error { + if status == contentionStatus { + return errors.WithStack(ErrContentionLimit) + } + return errors.WithStack(ErrOperationRecordCorrupt) +} + +// cancelOperationContention retains a terminal result after the last +// definitively rejected attempt has been fenced. A failure to prove the +// cancellation leaves the outcome unknown rather than claiming it was aborted. +func cancelOperationContention(ctx context.Context, journal *OperationJournal, id OperationID, result []byte) (operationRecord, error) { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), writeConflictRetryBudget) + defer cancel() + for range operationCASRetries { + record, err := journal.CancelWithResult(cleanupCtx, id, result) + if err == nil { + return record, nil + } + if !isDefinitiveWriteConflict(err) { + break + } + } + record, err := journal.Resolve(cleanupCtx, id) + if err == nil { + return record, nil + } + return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) +} diff --git a/internal/filesystem/operation_contention_test.go b/internal/filesystem/operation_contention_test.go new file mode 100644 index 000000000..f977170c9 --- /dev/null +++ b/internal/filesystem/operation_contention_test.go @@ -0,0 +1,151 @@ +package filesystem + +import ( + "context" + "encoding/json" + "testing" + + "github.com/bootjp/elastickv/internal/fskeys" + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +type operationPublicationConflictDispatcher struct { + inner *testCoordinator + conflicts int +} + +type operationMetadataConflictDispatcher struct { + inner *testCoordinator + remaining int + match func(*kv.OperationGroup[kv.OP]) bool +} + +func (d *operationMetadataConflictDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + if d.remaining > 0 && d.match(req) { + d.remaining-- + return nil, store.ErrWriteConflict + } + return d.inner.Dispatch(ctx, req) +} + +func TestOperationMetadataConflictsDoNotReportQuota(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + fault := &operationMetadataConflictDispatcher{inner: inner, remaining: 8, match: func(req *kv.OperationGroup[kv.OP]) bool { + for _, elem := range req.Elems { + if elem != nil && fskeys.IsOperationKey(elem.Key) && validOperationHeader(elem.Value) && + OperationState(elem.Value[5]) == OperationPending { + return true + } + } + return false + }} + svc.dispatch = fault + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + _, err = journal.Admit(ctx, id, []byte("intent"), 1, 1) + require.NoError(t, err) + require.Zero(t, fault.remaining) + svc.dispatch = inner + _, err = journal.CancelWithResult(ctx, id, []byte{1}) + require.NoError(t, err) + fault = &operationMetadataConflictDispatcher{inner: inner, remaining: 8, match: func(req *kv.OperationGroup[kv.OP]) bool { + for _, elem := range req.Elems { + if elem != nil && elem.Op == kv.Del && fskeys.IsOperationKey(elem.Key) { + return true + } + } + return false + }} + svc.dispatch = fault + require.NoError(t, journal.Acknowledge(ctx, id)) + require.Zero(t, fault.remaining) +} + +func (d *operationPublicationConflictDispatcher) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + for _, elem := range req.Elems { + if elem == nil || !fskeys.IsOperationKey(elem.Key) { + continue + } + if validOperationHeader(elem.Value) && + (OperationState(elem.Value[5]) == OperationCommitted || OperationState(elem.Value[5]) == OperationRejected) { + d.conflicts++ + return nil, store.ErrWriteConflict + } + } + return d.inner.Dispatch(ctx, req) +} + +func TestHandleContentionIsTerminalAndReplayable(t *testing.T) { + tests := []struct { + name string + run func(*Service, context.Context, OperationID, uint64, uint64, []byte) error + }{ + {"lease", (*Service).RefreshOpenHandleLeaseWithOperation}, + {"release", (*Service).ReleaseWithOperation}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + svc := newTestServiceWithOptions(t, []uint64{2, 3}) + require.NoError(t, svc.InitializeRoot(ctx, testRootMode, 1000, 1000)) + file, err := svc.Create(ctx, RootInode, []byte("file"), CreateOptions{Mode: testFileMode}) + require.NoError(t, err) + clientID := []byte("mount") + fh, err := svc.Open(ctx, file.Inode, clientID) + require.NoError(t, err) + id := testWriteOperationID(t, svc, 1) + inner, ok := svc.dispatch.(*testCoordinator) + require.True(t, ok) + fault := &operationPublicationConflictDispatcher{inner: inner} + svc.dispatch = fault + require.ErrorIs(t, tt.run(svc, ctx, id, file.Inode, fh, clientID), ErrContentionLimit) + require.Equal(t, writeConflictRetryLimit, fault.conflicts) + svc.dispatch = inner + require.ErrorIs(t, tt.run(svc, ctx, id, file.Inode, fh, clientID), ErrContentionLimit) + journal, err := NewOperationJournal(svc) + require.NoError(t, err) + record, err := journal.Resolve(ctx, id) + require.NoError(t, err) + require.Equal(t, OperationCancelled, record.State) + require.NoError(t, journal.CloseSession(ctx, OperationSessionID{Filesystem: id.Filesystem, Session: id.Session})) + }) + } +} + +func TestOperationContentionResultsRemainTyped(t *testing.T) { + createResult, err := json.Marshal(createOperationResult{Version: createOperationVersion, Error: createResultContention}) + require.NoError(t, err) + openResult, err := json.Marshal(openOperationResult{Version: openOperationVersion, Error: openResultContention}) + require.NoError(t, err) + setAttrResult, err := json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Error: setAttrResultContention}) + require.NoError(t, err) + tests := []struct { + name string + result []byte + decode func(operationRecord) error + }{ + {"create", createResult, func(r operationRecord) error { _, err := decodeCreateOperationResult(r); return err }}, + {"open", openResult, func(r operationRecord) error { _, err := decodeOpenOperationResult(r); return err }}, + {"setattr", setAttrResult, func(r operationRecord) error { _, err := decodeSetAttrOperationResult(r); return err }}, + {"rename", []byte{renameResultContention}, decodeRenameOperationResult}, + {"unlink", []byte{unlinkResultContention}, decodeUnlinkOperationResult}, + {"lease", []byte{leaseResultContention}, decodeLeaseOperationResult}, + {"release", []byte{releaseResultContention}, decodeReleaseOperationResult}, + {"write", encodeWriteOperationResult(0, writeResultPlanContention), func(r operationRecord) error { _, err := decodeWriteOperationResult(r); return err }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + record := operationRecord{State: OperationCancelled, Result: tt.result} + require.ErrorIs(t, tt.decode(record), ErrContentionLimit) + record.State = OperationRejected + require.ErrorIs(t, tt.decode(record), ErrOperationRecordCorrupt) + }) + } +} diff --git a/internal/filesystem/operation_create.go b/internal/filesystem/operation_create.go index f0956a068..015d4d90a 100644 --- a/internal/filesystem/operation_create.go +++ b/internal/filesystem/operation_create.go @@ -17,6 +17,7 @@ const ( createResultMissing = 1 createResultNotDir = 2 createResultExists = 3 + createResultContention = 4 ) type createOperationIntent struct { @@ -100,7 +101,15 @@ func (s *Service) executeCreateOperation(ctx context.Context, journal *Operation return CreateResult{}, err } if retry == writeConflictRetryLimit-1 { - return CreateResult{}, errors.WithStack(ErrContentionLimit) + result, marshalErr := json.Marshal(createOperationResult{Version: createOperationVersion, Error: createResultContention}) + if marshalErr != nil { + return CreateResult{}, errors.Wrap(marshalErr, "encode create contention") + } + record, cancelErr := cancelOperationContention(ctx, journal, id, result) + if cancelErr != nil { + return CreateResult{}, cancelErr + } + return decodeCreateOperationResult(record) } if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { return CreateResult{}, err @@ -213,12 +222,20 @@ func decodeCreateOperationResult(record operationRecord) (CreateResult, error) { if record.State == OperationRejected { return CreateResult{}, rejectedCreateError(result.Error) } - if record.State != OperationCommitted || result.Error != 0 || result.Result.Inode == 0 || result.Result.Stat.Inode != result.Result.Inode { + if record.State == OperationCancelled { + return CreateResult{}, cancelledOperationError(result.Error, createResultContention) + } + if !validCommittedCreateResult(record, result) { return CreateResult{}, errors.WithStack(ErrOperationRecordCorrupt) } return result.Result, nil } +func validCommittedCreateResult(record operationRecord, result createOperationResult) bool { + return record.State == OperationCommitted && result.Error == 0 && result.Result.Inode != 0 && + result.Result.Stat.Inode == result.Result.Inode +} + func rejectedCreateError(status uint8) error { switch status { case createResultMissing: diff --git a/internal/filesystem/operation_journal.go b/internal/filesystem/operation_journal.go index f52518d8d..5e006910b 100644 --- a/internal/filesystem/operation_journal.go +++ b/internal/filesystem/operation_journal.go @@ -13,7 +13,7 @@ import ( ) const ( - operationCASRetries = 8 + operationCASRetries = 64 maxOperationEffectKeys = 65535 operationJournalRows = 2 ) @@ -157,7 +157,7 @@ func randomOperationToken() ([16]byte, error) { // EnsureFilesystem returns the single Raft-replicated identity shared by all // mounts. A conflict rereads the winner; a lost response remains unknown. func (j *OperationJournal) EnsureFilesystem(ctx context.Context) ([16]byte, error) { - for range operationCASRetries { + for retry := range operationCASRetries { ts, err := j.backend.Snapshot(ctx) if err != nil { return [16]byte{}, errors.Wrap(err, "fence filesystem identity") @@ -179,8 +179,11 @@ func (j *OperationJournal) EnsureFilesystem(ctx context.Context) ([16]byte, erro if !IsWriteConflict(err) { return [16]byte{}, errors.Wrap(err, "register filesystem identity") } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return [16]byte{}, err + } } - return [16]byte{}, errors.WithStack(ErrOperationQuota) + return [16]byte{}, errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) loadFilesystemAt(ctx context.Context, ts uint64) ([16]byte, bool, error) { @@ -223,13 +226,16 @@ func (j *OperationJournal) RegisterSessionWithID(ctx context.Context, id Operati if id.Filesystem == [16]byte{} || id.Session == [16]byte{} || owner == "" { return errors.WithStack(ErrInvalid) } - for range operationCASRetries { + for retry := range operationCASRetries { err := j.registerSessionOnce(ctx, id, owner) if !IsWriteConflict(err) { return err } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return err + } } - return errors.WithStack(ErrOperationQuota) + return errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) registerSessionOnce(ctx context.Context, id OperationSessionID, owner string) error { @@ -325,14 +331,17 @@ func (j *OperationJournal) Admit(ctx context.Context, id OperationID, intent []b if err := validateOperationRecord(record); err != nil { return operationRecord{}, err } - for range operationCASRetries { + for retry := range operationCASRetries { admitted, err := j.admitOnce(ctx, record) if IsWriteConflict(err) { + if err = waitOperationCASRetry(ctx, retry); err != nil { + return operationRecord{}, err + } continue } return admitted, err } - return operationRecord{}, errors.WithStack(ErrOperationQuota) + return operationRecord{}, errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) admitOnce(ctx context.Context, record operationRecord) (operationRecord, error) { diff --git a/internal/filesystem/operation_journal_exec.go b/internal/filesystem/operation_journal_exec.go index ac25418c9..b807cff0c 100644 --- a/internal/filesystem/operation_journal_exec.go +++ b/internal/filesystem/operation_journal_exec.go @@ -16,7 +16,7 @@ import ( // BeginAttempt records a fresh transaction generation before any effect is // dispatched. A duplicate caller receives the same active attempt identity. func (j *OperationJournal) BeginAttempt(ctx context.Context, id OperationID) (operationRecord, error) { - for range operationCASRetries { + for retry := range operationCASRetries { ts, record, session, err := j.pendingSnapshot(ctx, id) if err != nil { return operationRecord{}, err @@ -34,13 +34,16 @@ func (j *OperationJournal) BeginAttempt(ctx context.Context, id OperationID) (op record.Generation++ if err = j.storeRecordAndSession(ctx, ts, record, session, nil, nil); err != nil { if IsWriteConflict(err) { + if err = waitOperationCASRetry(ctx, retry); err != nil { + return operationRecord{}, err + } continue } return operationRecord{}, err } return record, nil } - return operationRecord{}, errors.WithStack(ErrOperationGeneration) + return operationRecord{}, errors.WithStack(ErrContentionLimit) } // AdvanceGeneration fences an old attempt by committing a new version of its @@ -51,7 +54,7 @@ func (j *OperationJournal) AdvanceGeneration(ctx context.Context, id OperationID if old == [16]byte{} { return operationRecord{}, errors.WithStack(ErrInvalid) } - for range operationCASRetries { + for retry := range operationCASRetries { observed, ready := j.backend.JournalRouteVersion() if !ready { return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) @@ -60,6 +63,9 @@ func (j *OperationJournal) AdvanceGeneration(ctx context.Context, id OperationID if !IsWriteConflict(err) { return record, err } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return operationRecord{}, err + } } return operationRecord{}, errors.WithStack(kv.ErrTransactionOutcomeUnknown) } @@ -552,7 +558,7 @@ func (j *OperationJournal) Acknowledge(ctx context.Context, id OperationID) erro } return err } - for range operationCASRetries { + for retry := range operationCASRetries { err := j.acknowledgeOnce(ctx, id) if errors.Is(err, ErrOperationRetired) { return nil @@ -563,8 +569,11 @@ func (j *OperationJournal) Acknowledge(ctx context.Context, id OperationID) erro if !IsWriteConflict(err) { return errors.Wrap(err, "acknowledge operation") } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return err + } } - return errors.WithStack(ErrOperationQuota) + return errors.WithStack(ErrContentionLimit) } // RetireUnadmitted fences an identity whose reply was attempted before an @@ -572,7 +581,7 @@ func (j *OperationJournal) Acknowledge(ctx context.Context, id OperationID) erro // race atomically with admission: an in-flight admission either wins and must // be resolved normally, or observes the retired identity and cannot publish. func (j *OperationJournal) RetireUnadmitted(ctx context.Context, id OperationID) error { - for range operationCASRetries { + for retry := range operationCASRetries { err := j.retireUnadmittedOnce(ctx, id) if err == nil { return nil @@ -580,8 +589,11 @@ func (j *OperationJournal) RetireUnadmitted(ctx context.Context, id OperationID) if !IsWriteConflict(err) { return errors.Wrap(err, "retire unadmitted operation") } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return err + } } - return errors.WithStack(ErrOperationQuota) + return errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) retireUnadmittedOnce(ctx context.Context, id OperationID) error { @@ -715,7 +727,7 @@ func (j *OperationJournal) RecoverOwnerSession(ctx context.Context, filesystemID } func (j *OperationJournal) setSessionClosed(ctx context.Context, id OperationSessionID, allowPending bool) error { - for range operationCASRetries { + for retry := range operationCASRetries { err := j.closeSessionOnce(ctx, id, allowPending) if err == nil { return nil @@ -723,8 +735,11 @@ func (j *OperationJournal) setSessionClosed(ctx context.Context, id OperationSes if !IsWriteConflict(err) { return errors.Wrap(err, "close operation session") } + if err = waitOperationCASRetry(ctx, retry); err != nil { + return err + } } - return errors.WithStack(ErrOperationQuota) + return errors.WithStack(ErrContentionLimit) } func (j *OperationJournal) closeSessionOnce(ctx context.Context, id OperationSessionID, allowPending bool) error { diff --git a/internal/filesystem/operation_lease.go b/internal/filesystem/operation_lease.go index 67800c16c..9ccf3e99a 100644 --- a/internal/filesystem/operation_lease.go +++ b/internal/filesystem/operation_lease.go @@ -16,6 +16,7 @@ const ( leaseOperationResultLimit = 1 leaseResultSuccess = 0 leaseResultMissing = 1 + leaseResultContention = 2 ) type leaseOperationIntent struct { @@ -59,7 +60,11 @@ func (s *Service) executeLeaseOperation(ctx context.Context, journal *OperationJ return err } if retry == writeConflictRetryLimit-1 { - return errors.WithStack(ErrContentionLimit) + record, cancelErr := cancelOperationContention(ctx, journal, id, []byte{leaseResultContention}) + if cancelErr != nil { + return cancelErr + } + return decodeLeaseOperationResult(record) } if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { return err @@ -123,6 +128,9 @@ func decodeLeaseOperationResult(record operationRecord) error { if record.State == OperationRejected && record.Result[0] == leaseResultMissing { return errors.WithStack(ErrNotFound) } + if record.State == OperationCancelled && record.Result[0] == leaseResultContention { + return errors.WithStack(ErrContentionLimit) + } return errors.WithStack(ErrOperationRecordCorrupt) } diff --git a/internal/filesystem/operation_open.go b/internal/filesystem/operation_open.go index 327b31f4e..6ddf3496a 100644 --- a/internal/filesystem/operation_open.go +++ b/internal/filesystem/operation_open.go @@ -15,6 +15,7 @@ const ( openOperationVersion = 1 openOperationResultLimit = 128 openResultMissing = 1 + openResultContention = 2 ) type openOperationIntent struct { @@ -71,7 +72,15 @@ func (s *Service) executeOpenOperation(ctx context.Context, journal *OperationJo return 0, err } if retry == writeConflictRetryLimit-1 { - return 0, errors.WithStack(ErrContentionLimit) + result, marshalErr := json.Marshal(openOperationResult{Version: openOperationVersion, Error: openResultContention}) + if marshalErr != nil { + return 0, errors.Wrap(marshalErr, "encode open contention") + } + record, cancelErr := cancelOperationContention(ctx, journal, id, result) + if cancelErr != nil { + return 0, cancelErr + } + return decodeOpenOperationResult(record) } if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { return 0, err @@ -162,12 +171,19 @@ func decodeOpenOperationResult(record operationRecord) (uint64, error) { } return 0, errors.WithStack(ErrOperationRecordCorrupt) } - if record.State != OperationCommitted || result.Error != 0 || result.FH == 0 { + if record.State == OperationCancelled { + return 0, cancelledOperationError(result.Error, openResultContention) + } + if !validCommittedOpenResult(record, result) { return 0, errors.WithStack(ErrOperationRecordCorrupt) } return result.FH, nil } +func validCommittedOpenResult(record operationRecord, result openOperationResult) bool { + return record.State == OperationCommitted && result.Error == 0 && result.FH != 0 +} + func resolveOpenOperation(ctx context.Context, journal *OperationJournal, id OperationID) (uint64, error) { record, err := journal.Resolve(ctx, id) if errors.Is(err, ErrOperationPending) { diff --git a/internal/filesystem/operation_release.go b/internal/filesystem/operation_release.go index 2f24ee1e0..7ff325f89 100644 --- a/internal/filesystem/operation_release.go +++ b/internal/filesystem/operation_release.go @@ -12,6 +12,7 @@ import ( const ( releaseOperationVersion = 1 + releaseResultContention = 2 releaseOperationResultLimit = 1 releaseOperationEffectLimit = 64 << 10 ) @@ -77,7 +78,11 @@ func (s *Service) executeReleaseOperation(ctx context.Context, journal *Operatio return err } if retry == writeConflictRetryLimit-1 { - return errors.WithStack(ErrContentionLimit) + record, cancelErr := cancelOperationContention(ctx, journal, id, []byte{releaseResultContention}) + if cancelErr != nil { + return cancelErr + } + return decodeReleaseOperationResult(record) } if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { return err @@ -122,6 +127,9 @@ func (s *Service) planReleaseOperation(ctx context.Context, snapshot operationPl } func decodeReleaseOperationResult(record operationRecord) error { + if record.State == OperationCancelled && len(record.Result) == 1 && record.Result[0] == releaseResultContention { + return errors.WithStack(ErrContentionLimit) + } if record.State != OperationCommitted || len(record.Result) != 1 || record.Result[0] != releaseOperationVersion { return errors.WithStack(ErrOperationRecordCorrupt) } diff --git a/internal/filesystem/operation_rename.go b/internal/filesystem/operation_rename.go index b10c36f3e..9711b2e28 100644 --- a/internal/filesystem/operation_rename.go +++ b/internal/filesystem/operation_rename.go @@ -11,13 +11,14 @@ import ( ) const ( - renameIntentVersion = 1 - renameEffectLimit = 64 << 10 - renameResultSuccess = 0 - renameResultMissing = 1 - renameResultNotDir = 2 - renameResultIsDir = 3 - renameResultNotEmpty = 4 + renameIntentVersion = 1 + renameEffectLimit = 64 << 10 + renameResultSuccess = 0 + renameResultMissing = 1 + renameResultNotDir = 2 + renameResultIsDir = 3 + renameResultNotEmpty = 4 + renameResultContention = 5 ) type renameOperationIntent struct { @@ -78,7 +79,11 @@ func (s *Service) executeRenameOperation(ctx context.Context, journal *Operation return err } if retry == writeConflictRetryLimit-1 { - return errors.WithStack(ErrContentionLimit) + record, cancelErr := cancelOperationContention(ctx, journal, id, []byte{renameResultContention}) + if cancelErr != nil { + return cancelErr + } + return decodeRenameOperationResult(record) } if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { return err @@ -186,6 +191,9 @@ func decodeRenameOperationResult(record operationRecord) error { if record.State == OperationCommitted && record.Result[0] == renameResultSuccess { return nil } + if record.State == OperationCancelled { + return cancelledOperationError(record.Result[0], renameResultContention) + } if record.State != OperationRejected { return errors.WithStack(ErrOperationRecordCorrupt) } diff --git a/internal/filesystem/operation_setattr.go b/internal/filesystem/operation_setattr.go index a620684f7..8ef43fe02 100644 --- a/internal/filesystem/operation_setattr.go +++ b/internal/filesystem/operation_setattr.go @@ -16,6 +16,7 @@ const ( setAttrSizeEffectLimit = 64 << 10 setAttrResultNotFound = 1 setAttrResultIsDir = 2 + setAttrResultContention = 3 ) type setAttrOperationIntent struct { @@ -72,7 +73,15 @@ func (s *Service) executeSetAttrOperation(ctx context.Context, journal *Operatio return Stat{}, err } if retry == writeConflictRetryLimit-1 { - return Stat{}, errors.WithStack(ErrContentionLimit) + result, marshalErr := json.Marshal(setAttrOperationResult{Version: setAttrOperationVersion, Error: setAttrResultContention}) + if marshalErr != nil { + return Stat{}, errors.Wrap(marshalErr, "encode setattr contention") + } + record, cancelErr := cancelOperationContention(ctx, journal, id, result) + if cancelErr != nil { + return Stat{}, cancelErr + } + return decodeSetAttrOperationResult(record) } if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { return Stat{}, err @@ -246,6 +255,9 @@ func decodeSetAttrOperationResult(record operationRecord) (Stat, error) { if record.State == OperationRejected { return Stat{}, rejectedSetAttrError(result.Error) } + if record.State == OperationCancelled { + return Stat{}, cancelledOperationError(result.Error, setAttrResultContention) + } if record.State != OperationCommitted || result.Error != 0 || result.Stat.Inode == 0 { return Stat{}, errors.WithStack(ErrOperationRecordCorrupt) } diff --git a/internal/filesystem/operation_unlink.go b/internal/filesystem/operation_unlink.go index 895444b19..49868b107 100644 --- a/internal/filesystem/operation_unlink.go +++ b/internal/filesystem/operation_unlink.go @@ -11,14 +11,15 @@ import ( ) const ( - unlinkIntentVersion = 1 - unlinkResultSuccess = 0 - unlinkResultMissing = 1 - unlinkResultNotDir = 2 - unlinkResultIsDir = 3 - unlinkResultNotEmpty = 4 - unlinkEffectLimit = 64 << 10 - unlinkIntentHeader = 10 + unlinkIntentVersion = 1 + unlinkResultSuccess = 0 + unlinkResultMissing = 1 + unlinkResultNotDir = 2 + unlinkResultIsDir = 3 + unlinkResultNotEmpty = 4 + unlinkResultContention = 5 + unlinkEffectLimit = 64 << 10 + unlinkIntentHeader = 10 ) // UnlinkWithOperation records one file removal and its result under id. @@ -75,7 +76,11 @@ func (s *Service) executeUnlinkOperation(ctx context.Context, journal *Operation return err } if retry == writeConflictRetryLimit-1 { - return errors.WithStack(ErrContentionLimit) + record, cancelErr := cancelOperationContention(ctx, journal, id, []byte{unlinkResultContention}) + if cancelErr != nil { + return cancelErr + } + return decodeUnlinkOperationResult(record) } if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { return err @@ -252,6 +257,9 @@ func decodeUnlinkOperationResult(record operationRecord) error { if record.State == OperationCommitted && record.Result[0] == unlinkResultSuccess { return nil } + if record.State == OperationCancelled { + return cancelledOperationError(record.Result[0], unlinkResultContention) + } if record.State != OperationRejected { return errors.WithStack(ErrOperationRecordCorrupt) } diff --git a/internal/filesystem/operation_write.go b/internal/filesystem/operation_write.go index b2fedaa9b..08e97434c 100644 --- a/internal/filesystem/operation_write.go +++ b/internal/filesystem/operation_write.go @@ -118,7 +118,7 @@ func (s *Service) executeWriteOperation(ctx context.Context, journal *OperationJ return 0, err } if retry == writeConflictRetryLimit-1 { - return 0, errors.WithStack(ErrContentionLimit) + return cancelWritePlanningFailure(ctx, journal, id, ErrContentionLimit) } if err = waitWriteConflictRetry(deadlineCtx, retry); err != nil { return 0, err From 7d438a445c17f0c492df91fd7a9da5ab47c5bf85 Mon Sep 17 00:00:00 2001 From: bootjp Date: Mon, 26 Oct 2026 20:25:12 +0900 Subject: [PATCH 35/35] test: allow SQLite writers to wait for FUSE locks Signed-off-by: bootjp --- scripts/test-fuse-sqlite.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/scripts/test-fuse-sqlite.py b/scripts/test-fuse-sqlite.py index 17ddefab2..96e565091 100644 --- a/scripts/test-fuse-sqlite.py +++ b/scripts/test-fuse-sqlite.py @@ -9,22 +9,33 @@ import uuid +BUSY_TIMEOUT_SECONDS = 90 +WRITER_JOIN_SECONDS = 300 + + def increment(path, mode, writes, start, results): + phase = "open" try: - if not start.wait(10): - raise TimeoutError("writer start timed out") - with closing(sqlite3.connect(path, timeout=10)) as connection: + with closing(sqlite3.connect(path, timeout=BUSY_TIMEOUT_SECONDS)) as connection: + # PERSIST is connection-local, so each writer must select it. + # Configure the connections before either starts writing. + phase = "configure journal mode" actual_mode = connection.execute(f"PRAGMA journal_mode={mode}").fetchone()[0] if actual_mode.upper() != mode: raise AssertionError(f"journal mode {actual_mode}, expected {mode}") connection.execute("PRAGMA synchronous=FULL") - for _ in range(writes): + results.put("ready") + phase = "wait for writers" + if not start.wait(BUSY_TIMEOUT_SECONDS): + raise TimeoutError("writer start timed out") + for index in range(writes): + phase = f"write {index + 1}/{writes}" connection.execute("BEGIN IMMEDIATE") connection.execute("UPDATE counter SET value=value+1") connection.commit() results.put(None) except Exception as exc: # The parent reports the exact worker failure. - results.put(repr(exc)) + results.put(f"{phase}: {exc!r}") def run_mode(mount, mode, writers, writes): @@ -46,9 +57,12 @@ def run_mode(mount, mode, writers, writes): ] for process in processes: process.start() + ready = results.get(timeout=BUSY_TIMEOUT_SECONDS) + if ready != "ready": + raise RuntimeError(f"{mode}: writer setup failed: {ready}") start.set() for process in processes: - process.join(120) + process.join(WRITER_JOIN_SECONDS) if process.is_alive(): process.terminate() process.join()