Fix/ocisdev 1433 - #748
Fix/ocisdev 1433#7482403905 wants to merge 11 commits into
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
kobergj
left a comment
There was a problem hiding this comment.
Reviewed the public-share manager changes (d4f339c + 95d977d). The direction is right - taking the read-modify-write out of GetPublicShare / GetPublicShareByToken / ListPublicShares, dropping the manager lock before the gateway Stat fan-out, and deleting the signal.Notify(SIGHUP, SIGINT, SIGQUIT) janitor hack (which was suppressing the default signal disposition process-wide) are all clear improvements. Wiring Close through rgrpc.cleanupServices and passing the request context into init() are good too.
One blocking issue in cs3.Write (cache/mtime inconsistency that defeats IfUnmodifiedSince), plus a few smaller things inline.
One process note: no changelog entry for these two commits, although the other commits in this PR each add one - and this change is user-visible (expired shares are no longer purged on access, janitor cadence changed, new shutdown semantics).
| // independent copy (see persistence.Copy), it has to be done explicitly | ||
| // here, or the cache would only pick up our own write once some later | ||
| // external write advances the remote mtime past our stale one. | ||
| if info, statErr := p.s.Stat(ctx, "publicshares.json"); statErr == nil { |
There was a problem hiding this comment.
Blocking. Pairing our content with an mtime from a separate Stat after the upload makes the cache self-inconsistent, and that defeats the IfUnmodifiedSince guard on line 139.
Before this change, p.db.mtime was only ever assigned in Read, right next to the content downloaded at that mtime - they were consistent by construction. With two instances writing the same publicshares.json:
- A:
Read-> cache mtimeM0 - A:
Upload(C_A, IfUnmodifiedSince: M0)-> OK, remote becomesM1 - B, in the window between A's PUT and A's
Stat:Read->M1,Upload(C_B, IfUnmodifiedSince: M1)-> OK, remote becomesM2/C_B - A:
Stat->M2. A now caches mtimeM2with contentC_A - A's next
Read:M2.After(M2) == false-> no refetch -> A keeps servingC_A, so B's share is invisible on A indefinitely - A's next
Write:IfUnmodifiedSince: M2, remote mtime isM2,Afteris false -> precondition passes -> A uploadsC_A+ its mutation, silently dropping B's share
UploadResponse only carries Etag/FileID, so there is no mtime to reuse. Two ways out:
- Invalidate instead of syncing: on success set
p.db.mtime = time.Time{}and clear the cached content, so the nextReadunconditionally refetches. One extra download per write, obviously correct. - Or set
MTimeon theUploadRequest(the metadata storage supports it viaX-OC-Mtime) and cache that value. Content and mtime stay consistent, and it removes thisStat- a full round trip currently held underp.mu.
Either way, statErr should not be swallowed silently.
There was a problem hiding this comment.
// Invalidate the cache to minimize the risk of inconsistency.
p.db.mtime = time.Time{}
p.db.publicShares = persistence.PublicShares{}
| for _, v := range db { | ||
| var changed bool | ||
| for id, v := range db { | ||
| d := v.(map[string]interface{})["share"] |
There was a problem hiding this comment.
These two assertions still panic: v.(map[string]interface{}) and d.(string). This runs in a bare goroutine with no recover(), so one malformed entry in publicshares.json takes the whole service down - and repeats every janitor tick.
The continue added just below shows the intent was to tolerate bad entries, so it seems worth extending it one line up, especially since OCISDEV-877 in this same PR exists because partially-broken entries do occur:
d, ok := v.(map[string]interface{})["share"].(string)
if !ok {
continue
}
var ps link.PublicShare
if err := utils.UnmarshalJSONToProtoV1([]byte(d), &ps); err != nil {
continue
}There was a problem hiding this comment.
The json.go has about 10 v.(map[string]interface{})... unsafe type assertions. This particular safe-assertion fix cannot resolve all of them. We should update all of them or keep them as is.
| return err | ||
| } | ||
|
|
||
| m.mutex.Lock() |
There was a problem hiding this comment.
This holds the exclusive manager lock across a persistence Read and Write - two metadata-storage round trips, bounded at 60s - blocking every public-share read in the process for the duration.
Much better than the previous N sequential read+write cycles under the lock, so not blocking. But for a change whose point is read-path contention, the janitor is now the remaining stop-the-world point. Worth considering: collect the expired IDs under RLock, then take the write lock only for the read-modify-write, and let IfUnmodifiedSince + a retry handle the race.
There was a problem hiding this comment.
We will not use RLock and Lock together here because threre is no database consistency mechanism no the db level. We can overwrite the new public link created between these two requests.
| // that a caller which keeps reading the result after releasing its lock | ||
| // cannot race a writer that later mutates an existing share's fields in | ||
| // place (see manager.UpdatePublicShare). | ||
| func Copy(db PublicShares) PublicShares { |
There was a problem hiding this comment.
This now runs on every read path, so each ListPublicShares / GetPublicShareByToken allocates a fresh copy of the entire share database (N+1 maps). That is a real cost on instances with a large publicshares.json, which are exactly the ones hurting from read-path contention today.
Note that the cs3 cache is already effectively immutable once published: both the refill in Read and Write replace p.db.publicShares with a fresh map rather than mutating it in place. The copy is only needed because the manager's write paths mutate what Read handed them - UpdatePublicShare doing data["share"] = ..., and delete(db, id) in revokePublicShare / cleanupExpiredShares.
So Copy could move into those four write paths and Read could document its result as read-only. Same race fix, allocation-free reads.
There was a problem hiding this comment.
Moved the database copy from the persist label to the manager level only for create and update operations.
| } | ||
| if c.JanitorRunInterval == 0 { | ||
| c.JanitorRunInterval = 60 | ||
| c.JanitorRunInterval = 600 |
There was a problem hiding this comment.
60 -> 600 is a 10x change to the default cleanup cadence, and it is not mentioned in the commit message or a changelog. Intentional? It is defensible now that the read paths no longer purge, but on a stable branch it should be deliberate and documented.
There was a problem hiding this comment.
That is only an intermediate fix. In the future, we need to trigger the janitor from a CLI command.
|
|
||
| const writers = 8 | ||
| const readers = 8 | ||
| const iterations = 500 |
There was a problem hiding this comment.
Nit: 8 writers x 500 iterations is 4000 real disk writes plus 4000 reads - ~15s locally without -race, and noticeably worse in CI with it. ~50 iterations pins the same regression.
| // Overlapping reads should finish close to a single delay - allow | ||
| // generous slack for scheduling noise without letting a real | ||
| // regression pass. | ||
| Expect(time.Since(start)).To(BeNumerically("<", delay*3)) |
There was a problem hiding this comment.
Nit: this asserts on a wall-clock ratio (< 450ms vs 1.2s serialized), which will be flaky on a loaded shared runner. The property being tested is the right one - a counter in slowReadPersistence.Read tracking max observed concurrency (Expect(maxConcurrent).To(BeNumerically(">", 1))) would test it deterministically.
|
|
||
| // Give the Read goroutine time to be inside its (slow) Stat call, | ||
| // holding mu, before Init races it. | ||
| time.Sleep(delay / 5) |
There was a problem hiding this comment.
Nit: time.Sleep(delay / 5) to get the other goroutine inside its Stat, then asserting < delay/2, is the same wall-clock flakiness as the ginkgo test in json_test.go. Signalling from inside the fake Stat (a channel the storage closes on entry) removes the guess.
| return m, nil | ||
| } | ||
|
|
||
| var _ publicshare.ClosableManager = (*manager)(nil) |
There was a problem hiding this comment.
Nit: this interface assertion reads better next to the manager type declaration than between New and commonConfig.
e2e5895 to
6760373
Compare
…n a public shares
The cs3 metadata storage authenticates as a system user before every operation, and that Authenticate call carried no deadline. A gateway that accepted the connection but never answered it - because it was itself waiting on a stalled storage provider - parked the calling goroutine for the lifetime of the process, with no log output at all. The call now runs on its own bounded context and logs a warning when the deadline is exhausted. The context returned to the caller deliberately keeps no deadline: callers run their own RPC on it after getAuthContext returns. Adds the first tests for this package, including one that reproduces the unbounded wait against an in-process gateway that never answers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grpc client connections of the pool were created without keepalive parameters, so a peer that stopped answering on an established connection - a black-holed node, a wedged process - was indistinguishable from a peer that was merely slow, and a request without a deadline waited for the lifetime of the process. The clients now ping the peer while a request is in flight and fail the requests on a connection that does not answer, tunable with GRPC_CLIENT_KEEPALIVE_TIME and GRPC_CLIENT_KEEPALIVE_TIMEOUT. The servers got the matching enforcement policy so they accept those pings. GRPC_MAX_CONNECTION_AGE is removed along with it. It closed healthy connections on a timer, never ended a request that was already in flight because the grace period was left at infinity, and fell back to doing nothing at all whenever its value had no unit suffix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sist label to the manager.
6760373 to
3755f25
Compare
dad8d79 to
0bec0b9
Compare
No description provided.