Skip to content

Commit e688348

Browse files
committed
Read fresh / write targeted per stage; run XML-RPC recovery in parallel
Removes the threaded, mutated SiteModel from the provisioning pipeline: each stage now reads the site fresh by local id and writes back only the column it changed (persistApiRootUrl / a new persistXmlRpcUrl), so the parallel branches can't clobber one another and there's no stale-model write (#22905). With writes targeted, XML-RPC endpoint recovery moves out of the application-password card into the pipeline as a parallel branch off ensureAuth -- independent of the REST capability probe, since it needs only the credentials. The card becomes a pure renderer that reads the recovered xmlRpcUrl fresh. Adds SiteSqlUtils.updateXmlRpcUrl and a SiteXmlRpcUrlRecoverer mirroring SiteApiRestUrlRecoverer.
1 parent 2473ae9 commit e688348

6 files changed

Lines changed: 290 additions & 133 deletions

File tree

WordPress/src/main/java/org/wordpress/android/repositories/SiteProvisioningSource.kt

Lines changed: 74 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package org.wordpress.android.repositories
22

33
import kotlinx.coroutines.CoroutineScope
44
import kotlinx.coroutines.Job
5+
import kotlinx.coroutines.async
6+
import kotlinx.coroutines.coroutineScope
57
import kotlinx.coroutines.flow.MutableStateFlow
68
import kotlinx.coroutines.flow.StateFlow
79
import kotlinx.coroutines.launch
@@ -12,6 +14,7 @@ import org.wordpress.android.fluxc.utils.AppLogWrapper
1214
import org.wordpress.android.modules.APPLICATION_SCOPE
1315
import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper
1416
import org.wordpress.android.ui.accounts.login.SiteApiRestUrlRecoverer
17+
import org.wordpress.android.ui.accounts.login.SiteXmlRpcUrlRecoverer
1518
import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordValidator
1619
import org.wordpress.android.util.AppLog
1720
import org.wordpress.android.util.NetworkUtilsWrapper
@@ -22,28 +25,35 @@ import javax.inject.Singleton
2225

2326
/**
2427
* The single source of truth for getting a site ready to use: it provisions
25-
* application-password credentials, recovers the REST API root, and detects
26-
* editor capabilities — **in that order, each stage awaited before the next**.
28+
* application-password credentials, recovers the REST API root, recovers the
29+
* XML-RPC endpoint (self-hosted), and detects editor capabilities.
2730
*
28-
* Those three things have a genuine ordering dependency (you can't probe the
29-
* REST API of a private Atomic host until you've minted a credential for it),
30-
* and they used to be spread across the connectivity banner, the editor
31-
* preloader, and the application-password card, each triggering its slice of
32-
* the work independently and racing the others. Running them as one serialized
33-
* per-site pipeline makes the race structurally impossible: [detectCapabilities]
34-
* is downstream of [ensureAuth], so the probe can never run before the mint.
31+
* Capability detection can't run until a credential exists (you can't probe a
32+
* private Atomic host's REST API unauthenticated), so **auth is awaited first**;
33+
* after that, the REST-capability branch and the XML-RPC branch are independent
34+
* and run **in parallel**. Routing every consumer (connectivity banner, editor
35+
* preloader, application-password card) through this one pipeline means the
36+
* first-login race is structurally impossible — the probe is downstream of the
37+
* mint — and there's one shared, deduplicated run per site instead of each
38+
* consumer racing the others.
39+
*
40+
* ### No model is held across stages
41+
* Stages take a **`siteLocalId`**, read the `SiteModel` fresh from the store at
42+
* the point of use, and write back **only the one column they changed**
43+
* (`persistApiRootUrl` / `persistXmlRpcUrl`). Nothing keeps a mutated `SiteModel`
44+
* around, so the two parallel branches can't clobber each other and there's no
45+
* stale-model write (see #22905). The passed `SiteModel` is used for its id only.
3546
*
3647
* Per site there is at most one in-flight pipeline (single-flight, keyed by
37-
* [SiteModel.id]); concurrent callers join it rather than starting a second —
38-
* which also subsumes the application-password card's old single-flight guard
39-
* (two concurrent mints hit a 409 that destroys the winner's credentials).
48+
* [SiteModel.id]); concurrent callers join it — which also subsumes the
49+
* application-password card's old single-flight guard (two concurrent mints hit
50+
* a 409 that destroys the winner's credentials).
4051
*
4152
* ## Entry points
42-
* - [stateFor] — reactive: returns a shared [StateFlow]; the first access runs
43-
* the pipeline, later accesses reuse a [SiteReadiness.Ready] result.
53+
* - [stateFor] — reactive: returns a shared [StateFlow]; first access runs the
54+
* pipeline, later accesses reuse a [SiteReadiness.Ready] result.
4455
* - [await] — one-shot: runs the pipeline (if needed) and returns the result.
45-
* - [invalidate] — forces a re-run, bypassing the once-per-site gate
46-
* (pull-to-refresh, retry).
56+
* - [invalidate] — forces a re-run (pull-to-refresh, retry).
4757
* - [clear] — cancels all work and drops all state; wire into sign-out.
4858
*/
4959
@Singleton
@@ -54,6 +64,7 @@ class SiteProvisioningSource @Inject constructor(
5464
private val applicationPasswordValidator: ApplicationPasswordValidator,
5565
private val wpApiClientProvider: WpApiClientProvider,
5666
private val siteApiRestUrlRecoverer: SiteApiRestUrlRecoverer,
67+
private val siteXmlRpcUrlRecoverer: SiteXmlRpcUrlRecoverer,
5768
private val editorSettingsRepository: EditorSettingsRepository,
5869
private val networkUtilsWrapper: NetworkUtilsWrapper,
5970
private val appLogWrapper: AppLogWrapper,
@@ -64,18 +75,18 @@ class SiteProvisioningSource @Inject constructor(
6475

6576
// Sites whose pipeline reached Ready this process — the dedup gate. Only a fully-ready site
6677
// latches; auth-needed / unreachable / transient outcomes are left to re-run on the next
67-
// access (so a later resume re-mints or re-probes). Reset by invalidate / clear.
78+
// access. Reset by invalidate / clear.
6879
private val ready = ConcurrentHashMap.newKeySet<Int>()
6980

7081
/**
7182
* The shared readiness state for [site]. The first call starts the pipeline;
7283
* later calls return the same flow without re-running once it reached
73-
* [SiteReadiness.Ready]. Collect it to react to provisioning / capability changes.
84+
* [SiteReadiness.Ready]. Only [SiteModel.id] is read from [site].
7485
*/
7586
@Synchronized
7687
fun stateFor(site: SiteModel): StateFlow<SiteReadiness> {
7788
val flow = flowFor(site.id)
78-
if (shouldRun(site.id)) launchPipeline(site)
89+
if (shouldRun(site.id)) launchPipeline(site.id)
7990
return flow
8091
}
8192

@@ -98,7 +109,7 @@ class SiteProvisioningSource @Inject constructor(
98109
fun invalidate(site: SiteModel) {
99110
if (jobs[site.id]?.isActive == true) return
100111
ready.remove(site.id)
101-
launchPipeline(site)
112+
launchPipeline(site.id)
102113
}
103114

104115
/** Cancels all in-flight pipelines and drops all cached state (sign-out). */
@@ -111,13 +122,13 @@ class SiteProvisioningSource @Inject constructor(
111122
}
112123

113124
@Synchronized
114-
private fun launchPipeline(site: SiteModel) {
115-
jobs[site.id]?.cancel()
116-
val flow = flowFor(site.id)
117-
jobs[site.id] = appScope.launch {
118-
val readiness = runPipeline(site)
125+
private fun launchPipeline(siteLocalId: Int) {
126+
jobs[siteLocalId]?.cancel()
127+
val flow = flowFor(siteLocalId)
128+
jobs[siteLocalId] = appScope.launch {
129+
val readiness = runPipeline(siteLocalId)
119130
flow.value = readiness
120-
if (readiness is SiteReadiness.Ready) ready.add(site.id)
131+
if (readiness is SiteReadiness.Ready) ready.add(siteLocalId)
121132
}
122133
}
123134

@@ -127,39 +138,40 @@ class SiteProvisioningSource @Inject constructor(
127138
private fun shouldRun(siteLocalId: Int): Boolean =
128139
jobs[siteLocalId]?.isActive != true && siteLocalId !in ready
129140

130-
private suspend fun runPipeline(site: SiteModel): SiteReadiness {
131-
// Re-read from the store so we provision against the persisted SiteModel and mutate that
132-
// instance in place — later stages (URL recovery, capability probe) see the fresh creds.
133-
val storedSite = siteStore.sites.firstOrNull { it.id == site.id } ?: site
134-
return when (val auth = ensureAuth(storedSite)) {
135-
SiteAuthState.Provisioned -> {
136-
recoverRestUrl(storedSite)
137-
detectCapabilities(storedSite)
141+
private suspend fun runPipeline(siteLocalId: Int): SiteReadiness =
142+
when (val auth = ensureAuth(siteLocalId)) {
143+
SiteAuthState.Provisioned -> coroutineScope {
144+
// Post-auth, the REST-capability chain and the XML-RPC recovery are independent —
145+
// each reads the site fresh and writes only its own column — so run them in
146+
// parallel. recoverRestUrl precedes detectCapabilities within its branch because
147+
// the probe needs the recovered REST root.
148+
val capabilities = async { recoverRestUrl(siteLocalId); detectCapabilities(siteLocalId) }
149+
val xmlRpc = async { recoverXmlRpc(siteLocalId) }
150+
xmlRpc.await()
151+
capabilities.await()
138152
}
139153
else -> SiteReadiness.NeedsAuth(auth)
140154
}
141-
}
142155

143156
/**
144157
* Stage 1 — ensure the site has working application-password credentials.
145158
* Validates stored creds with Basic auth against the direct host; on a
146159
* confirmed rejection wipes them and mints fresh ones via the FluxC Jetpack
147-
* tunnel (the only path that works for Atomic / Jetpack-WPCom-REST sites).
160+
* tunnel. The mint persists the credentials, so later stages read them back.
148161
*/
149-
private suspend fun ensureAuth(site: SiteModel): SiteAuthState {
162+
private suspend fun ensureAuth(siteLocalId: Int): SiteAuthState {
163+
val site = siteStore.getSiteByLocalId(siteLocalId)
164+
?: return SiteAuthState.Unprovisionable(hadCredentials = false)
150165
val hadCredentials = !applicationPasswordLoginHelper.siteHasBadCredentials(site)
151166
if (hadCredentials) {
152167
when (applicationPasswordValidator.validate(site)) {
153168
ApplicationPasswordValidator.Outcome.Valid ->
154169
return SiteAuthState.Provisioned
155170
ApplicationPasswordValidator.Outcome.NetworkUnavailable -> {
156-
// Don't punish flaky networks — treat as in-progress and retry next run.
157171
appLogWrapper.d(AppLog.T.MAIN, "A_P: Validation network error for ${site.url}")
158172
return SiteAuthState.Provisioning
159173
}
160174
ApplicationPasswordValidator.Outcome.Invalid -> {
161-
// Stored creds are stale (revoked, deleted) — clear them so the mint below
162-
// creates fresh ones, and invalidate the cached client.
163175
appLogWrapper.d(AppLog.T.MAIN, "A_P: Stored creds invalid for ${site.url}, clearing")
164176
siteStore.deleteStoredApplicationPasswordCredentials(site)
165177
wpApiClientProvider.clearSelfHostedClient(site.id)
@@ -180,15 +192,29 @@ class SiteProvisioningSource @Inject constructor(
180192
}
181193

182194
/**
183-
* Stage 2 — recover the REST API root for Atomic sites minted through the
184-
* Jetpack tunnel, which never runs discovery and so leaves `wpApiRestUrl`
185-
* null. One owner for the heal that the card and preloader used to duplicate.
195+
* Stage 2a — recover the REST API root for Atomic sites minted through the
196+
* Jetpack tunnel (which never runs discovery and leaves `wpApiRestUrl` null).
197+
* Persists the one column; the capability probe re-reads it.
186198
*/
187-
private suspend fun recoverRestUrl(site: SiteModel) {
199+
private suspend fun recoverRestUrl(siteLocalId: Int) {
200+
val site = siteStore.getSiteByLocalId(siteLocalId) ?: return
188201
if (!site.wpApiRestUrl.isNullOrEmpty()) return
189202
siteApiRestUrlRecoverer.discoverApiRootUrl(site.url)?.let { apiRootUrl ->
190-
site.wpApiRestUrl = apiRootUrl
191-
siteApiRestUrlRecoverer.persistApiRootUrl(site.id, apiRootUrl)
203+
siteApiRestUrlRecoverer.persistApiRootUrl(siteLocalId, apiRootUrl)
204+
}
205+
}
206+
207+
/**
208+
* Stage 2b (parallel) — recover the XML-RPC endpoint for true self-hosted
209+
* sites that don't have one. Discovers + authenticates against it, and on
210+
* success persists the one column; the application-password card re-reads it.
211+
*/
212+
private suspend fun recoverXmlRpc(siteLocalId: Int) {
213+
val site = siteStore.getSiteByLocalId(siteLocalId) ?: return
214+
// WP.com / Atomic / Jetpack-WPCom-REST sites talk REST end-to-end and don't use XML-RPC.
215+
if (site.isUsingWpComRestApi || !site.xmlRpcUrl.isNullOrEmpty()) return
216+
siteXmlRpcUrlRecoverer.discoverAndVerifyXmlRpcUrl(site)?.let { endpoint ->
217+
siteXmlRpcUrlRecoverer.persistXmlRpcUrl(siteLocalId, endpoint)
192218
}
193219
}
194220

@@ -198,7 +224,8 @@ class SiteProvisioningSource @Inject constructor(
198224
* guaranteed present: a failure here is a real transport problem, not a
199225
* pending mint.
200226
*/
201-
private suspend fun detectCapabilities(site: SiteModel): SiteReadiness {
227+
private suspend fun detectCapabilities(siteLocalId: Int): SiteReadiness {
228+
val site = siteStore.getSiteByLocalId(siteLocalId) ?: return SiteReadiness.Unreachable
202229
val ok = editorSettingsRepository.fetchEditorCapabilitiesForSite(site)
203230
val hasCache = editorSettingsRepository.hasCachedCapabilities(site)
204231
return when {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package org.wordpress.android.ui.accounts.login
2+
3+
import kotlinx.coroutines.CoroutineDispatcher
4+
import kotlinx.coroutines.withContext
5+
import org.wordpress.android.fluxc.model.SiteModel
6+
import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder
7+
import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient
8+
import org.wordpress.android.fluxc.persistence.SiteSqlUtils
9+
import org.wordpress.android.fluxc.utils.AppLogWrapper
10+
import org.wordpress.android.modules.BG_THREAD
11+
import org.wordpress.android.util.AppLog
12+
import javax.inject.Inject
13+
import javax.inject.Named
14+
import javax.inject.Singleton
15+
16+
/**
17+
* Heals [SiteModel.xmlRpcUrl] for true self-hosted sites whose XML-RPC endpoint was never
18+
* discovered (or was previously gated). The REST counterpart is [SiteApiRestUrlRecoverer]; this
19+
* follows the same shape so callers never hold a mutated [SiteModel]:
20+
*
21+
* - [discoverAndVerifyXmlRpcUrl] discovers the endpoint and confirms it works with an authenticated
22+
* call, returning the verified URL (or `null` if discovery/verification fails).
23+
* - [persistXmlRpcUrl] writes only that one column to the DB row for `localId`.
24+
*/
25+
@Singleton
26+
class SiteXmlRpcUrlRecoverer @Inject constructor(
27+
private val selfHostedEndpointFinder: SelfHostedEndpointFinder,
28+
private val siteXMLRPCClient: SiteXMLRPCClient,
29+
private val siteSqlUtils: SiteSqlUtils,
30+
private val appLogWrapper: AppLogWrapper,
31+
@param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
32+
) {
33+
@Suppress("SwallowedException")
34+
suspend fun discoverAndVerifyXmlRpcUrl(site: SiteModel): String? = withContext(bgDispatcher) {
35+
try {
36+
val endpoint = selfHostedEndpointFinder.verifyOrDiscoverXMLRPCEndpoint(site.url)
37+
val result = siteXMLRPCClient.fetchSites(
38+
endpoint,
39+
site.apiRestUsernamePlain,
40+
site.apiRestPasswordPlain,
41+
)
42+
if (result.isError) {
43+
appLogWrapper.w(AppLog.T.API, "XML-RPC verification failed for ${site.url}")
44+
null
45+
} else {
46+
endpoint
47+
}
48+
} catch (e: SelfHostedEndpointFinder.DiscoveryException) {
49+
appLogWrapper.w(AppLog.T.API, "XML-RPC discovery failed for ${site.url}")
50+
null
51+
}
52+
}
53+
54+
suspend fun persistXmlRpcUrl(localId: Int, xmlRpcUrl: String): Boolean = withContext(bgDispatcher) {
55+
val rowsUpdated = siteSqlUtils.updateXmlRpcUrl(localId, xmlRpcUrl)
56+
if (rowsUpdated == 0) {
57+
appLogWrapper.w(AppLog.T.API, "Cannot persist xmlRpcUrl: no site with localId=$localId")
58+
false
59+
} else {
60+
appLogWrapper.d(AppLog.T.API, "Persisted xmlRpcUrl=$xmlRpcUrl for localId=$localId")
61+
true
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)