Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,12 @@ class DocumentationContentSource(
// The loader reads Templates rows, so a template can reference another one (ADFA-5405). It also
// makes the engine's own cache the compiled-template cache, keyed by name: a partial pulled in
// by several pages is compiled once, and dropping a database means invalidating that cache too.
private val templateLoader = DatabaseTemplateLoader { database }

private val pebbleEngine =
PebbleEngine
.Builder()
.loader(DatabaseTemplateLoader { database })
.loader(templateLoader)
.maxRenderedSize(MAX_RENDERED_CHARS)
.build()

Expand Down Expand Up @@ -253,7 +255,24 @@ class DocumentationContentSource(
* @return The matching content, or an outcome indicating that no match was found,
* an ambiguous match exists, or reading failed.
*/
fun lookup(path: String): DocumentationLookup {
fun lookup(path: String): DocumentationLookup = readLocked(path) { database -> readContent(database, path) }

/**
* A Help+Manual `jstopics` file rendered from its page, or [DocumentationLookup.NotFound]. An
* [DocumentationLookup.Ambiguous] result names the page, whose rows are the duplicated ones, so
* the path a caller reports is one whose query reproduces them.
*/
private fun lookupJsTopic(path: String): RequestLookup {
val match = JSTOPIC_PATH.matchEntire(path) ?: return RequestLookup(path, DocumentationLookup.NotFound)
val pagePath = match.groupValues[1] + match.groupValues[2] + ".html"
val lookup = readLocked(path) { database -> renderJsTopic(database, path, pagePath) ?: DocumentationLookup.NotFound }
return RequestLookup(if (lookup is DocumentationLookup.Ambiguous) pagePath else path, lookup)
}

private fun readLocked(
path: String,
block: (SQLiteDatabase) -> DocumentationLookup,
): DocumentationLookup {
// Both of these take the write lock when they act, so they run before the read lock below:
// a ReentrantReadWriteLock does not upgrade.
if (!openIfNeeded()) return DocumentationLookup.NotFound
Expand All @@ -263,7 +282,7 @@ class DocumentationContentSource(
val database = database ?: return@read DocumentationLookup.NotFound

try {
readContent(database, path)
block(database)
} catch (e: Exception) {
log.error("Cannot read '{}'", path, e)
DocumentationLookup.Failed(e)
Expand All @@ -282,9 +301,15 @@ class DocumentationContentSource(
if (raw !is DocumentationLookup.NotFound) return RequestLookup(rawPath, raw)

val decodedPath = decodeRequestPath(rawPath)
if (decodedPath == rawPath) return RequestLookup(rawPath, raw)
if (decodedPath != rawPath) {
val decoded = lookup(decodedPath)
if (decoded !is DocumentationLookup.NotFound) return RequestLookup(decodedPath, decoded)
}

return RequestLookup(decodedPath, lookup(decodedPath))
// Only once both stored forms miss, so a stored jstopics row wins under either (ADFA-6618).
val rendered = lookupJsTopic(rawPath)
if (rendered.lookup !is DocumentationLookup.NotFound || decodedPath == rawPath) return rendered
return lookupJsTopic(decodedPath)
}

/**
Expand Down Expand Up @@ -456,14 +481,53 @@ class DocumentationContentSource(
val compression = cursor.getString(2)
val templateId = cursor.getInt(3)

val chunks = readChunks(database, path, firstChunk)
val decoded = if (compression == "brotli") decompressBrotli(database, chunks) else joinChunks(chunks)
val decoded = decodeRow(database, path, firstChunk, compression)
val bytes = if (templateId > 0) render(database, templateId, decoded, path) else decoded

return DocumentationLookup.Found(DocumentationContent(bytes, mimeType))
}
}

/** A row's content: its chunks joined, and Brotli-decoded when its content type says so. */
private fun decodeRow(
database: SQLiteDatabase,
path: String,
firstChunk: ByteArray,
compression: String,
): ByteArray {
val chunks = readChunks(database, path, firstChunk)
return if (compression == "brotli") decompressBrotli(database, chunks) else joinChunks(chunks)
}

/**
* Help+Manual's in-page navigation loads `jstopics/X.js`, a JavaScript copy of topic `X`. The
* documentation pipeline stores each topic once, as the JSON its page `X.html` renders from
* through [HM_TOPIC_TEMPLATE], and drops the copies (ADFA-6618), so a miss for one is rendered
* from that JSON with [JSTOPIC_TEMPLATE].
*
* @param pagePath The page [path] is the `jstopics` file of.
* @return The rendered copy; [DocumentationLookup.Ambiguous] when the page row is duplicated; or
* null -- the request stays a plain miss -- when the page has no row or is not a
* [HM_TOPIC_TEMPLATE] page, or the database has no [JSTOPIC_TEMPLATE].
*/
private fun renderJsTopic(
database: SQLiteDatabase,
path: String,
pagePath: String,
): DocumentationLookup? {
database.rawQuery(CONTENT_QUERY, arrayOf(pagePath)).use { cursor ->
if (cursor.count > 1) return DocumentationLookup.Ambiguous(cursor.count)
if (!cursor.moveToFirst()) return null
val templateId = cursor.getInt(3)
if (templateId <= 0) return null
val pageTemplate = templateNames.getOrPut(templateId) { templateName(database, templateId, pagePath) }
if (pageTemplate != HM_TOPIC_TEMPLATE || !templateLoader.resourceExists(JSTOPIC_TEMPLATE)) return null

val json = decodeRow(database, pagePath, cursor.getBlob(0), cursor.getString(2))
return DocumentationLookup.Found(DocumentationContent(renderNamed(JSTOPIC_TEMPLATE, json, path), JSTOPIC_MIME_TYPE))
}
}

/**
* Renders a template using the provided JSON context.
*
Expand Down Expand Up @@ -787,5 +851,11 @@ class DocumentationContentSource(
"""

private const val CHUNK_QUERY = "SELECT content FROM Content WHERE path = ? AND languageId = 1"

// A Help+Manual topic's JavaScript copy, beside its page: i/jstopics/X.js for i/X.html.
private val JSTOPIC_PATH = Regex("""(.*/)?jstopics/([^/]+)\.js""")
private const val HM_TOPIC_TEMPLATE = "helpmanual-topic.peb"
private const val JSTOPIC_TEMPLATE = "helpmanual-jstopic.peb"
private const val JSTOPIC_MIME_TYPE = "text/javascript"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,166 @@ class DocumentationContentSourceTest {
assertThat(source().lookup("k/html/basic-syntax.html")).isInstanceOf(DocumentationLookup.Failed::class.java)
}

// ADFA-6618: the pipeline drops Help+Manual's jstopics copies, and the page's JSON stands in.
@Test
fun `a missing jstopics file is rendered from its page's JSON with the jstopics template`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase()

val lookup = jsTopic("i/jstopics/debug-enable.js") as DocumentationLookup.Found

assertThat(lookup.content.bytes.toString(Charsets.UTF_8)).isEqualTo("""hmLoadTopic({hmTitle:"Enable the debugger"});""")
assertThat(lookup.content.mimeType).isEqualTo("text/javascript")
}

@Test
fun `a stored jstopics row is served as it is, with no rendering`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns
jsTopicDatabase(storedCopies = mapOf("i/jstopics/debug-enable.js" to "stored"))

val lookup = jsTopic("i/jstopics/debug-enable.js") as DocumentationLookup.Found

assertThat(lookup.content.bytes.toString(Charsets.UTF_8)).isEqualTo("stored")
}

// The raw form misses and its page would render, but the decoded form has a stored row: it wins.
@Test
fun `a stored jstopics row under the decoded path wins over rendering the raw one`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns
jsTopicDatabase(page = "i/a%20b.html", storedCopies = mapOf("i/jstopics/a b.js" to "stored"))

val lookup = jsTopic("i/jstopics/a%20b.js") as DocumentationLookup.Found

assertThat(lookup.content.bytes.toString(Charsets.UTF_8)).isEqualTo("stored")
}

@Test
fun `a jstopics miss stays not found when the database has no jstopics template`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase(jsTopicTemplate = null)

assertThat(jsTopic("i/jstopics/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
}

@Test
fun `a jstopics miss stays not found when its page is plain HTML`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase(pageTemplateId = 0)

assertThat(jsTopic("i/jstopics/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
}

@Test
fun `a jstopics miss stays not found when its page uses another template`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase(pageTemplate = "page.peb")

assertThat(jsTopic("i/jstopics/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
}

@Test
fun `a jstopics miss stays not found when it has no page`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase()

assertThat(jsTopic("i/jstopics/no-such-topic.js")).isEqualTo(DocumentationLookup.NotFound)
}

// The path is the page's: WebServer quotes it in its "Corrupt database" answer, so a bug report
// carries a query that returns the duplicated rows, not the jstopics path, which has none.
@Test
fun `a duplicated page row is reported as ambiguous, under the page's path`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase(pageRows = 2)

val result = source().lookupRequestPath("i/jstopics/debug-enable.js")

assertThat(result.lookup).isEqualTo(DocumentationLookup.Ambiguous(2))
assertThat(result.queriedPath).isEqualTo("i/debug-enable.html")
}

// Every shipped page is Brotli-compressed: bytes marked brotli that are not fail the decode,
// where joining them as they are would have parsed as JSON and rendered.
@Test
fun `a page marked brotli is decoded before it is rendered`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase(pageCompression = "brotli")

assertThat(jsTopic("i/jstopics/debug-enable.js")).isInstanceOf(DocumentationLookup.Failed::class.java)
}

@Test
fun `a page split across rows is read whole before it is rendered`() {
val json = """{"hmTitle": "Enable the debugger", "pad": "${"x".repeat(DocumentationContentSource.CONTENT_CHUNK_SIZE)}"}"""
val bytes = json.toByteArray()
val first = bytes.copyOfRange(0, DocumentationContentSource.CONTENT_CHUNK_SIZE)
val rest = bytes.copyOfRange(DocumentationContentSource.CONTENT_CHUNK_SIZE, bytes.size)
val database = jsTopicDatabase(pageBytes = first)
every { database.rawQuery(match { it.startsWith("SELECT content FROM Content") }, arrayOf("i/debug-enable.html-1")) } returns
mockk(relaxed = true) {
every { moveToFirst() } returns true
every { getBlob(0) } returns rest
}
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns database

val lookup = jsTopic("i/jstopics/debug-enable.js") as DocumentationLookup.Found

assertThat(lookup.content.bytes.toString(Charsets.UTF_8)).isEqualTo("""hmLoadTopic({hmTitle:"Enable the debugger"});""")
}

@Test
fun `a miss outside jstopics is not looked up as a page`() {
val database = jsTopicDatabase()
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns database

assertThat(jsTopic("i/js/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
assertThat(jsTopic("i/jstopics/sub/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
verify(exactly = 0) { database.rawQuery(match { it.contains("FROM Content") }, match { it[0].endsWith(".html") }) }
}

/** The lookup both transports make for a request path. */
private fun jsTopic(path: String) = source().lookupRequestPath(path).lookup

/**
* A database whose [page] row is templated with [pageTemplateId], named [pageTemplate], and
* holds a topic's JSON; [storedCopies] are stored jstopics rows by path; [jsTopicTemplate] is
* the `helpmanual-jstopic.peb` body, if the database has one. Every other Content path misses.
*/
private fun jsTopicDatabase(
page: String = "i/debug-enable.html",
pageTemplateId: Int = 5,
pageTemplate: String = "helpmanual-topic.peb",
pageRows: Int = 1,
pageCompression: String = "none",
pageBytes: ByteArray = """{"hmTitle": "Enable the debugger"}""".toByteArray(),
jsTopicTemplate: String? = """hmLoadTopic({hmTitle:"{{ hmTitle }}"});""",
storedCopies: Map<String, String> = emptyMap(),
): SQLiteDatabase =
mockk<SQLiteDatabase>(relaxed = true) {
every { rawQuery(match { it.contains("FROM Content") }, any()) } answers { missingRowCursor() }
every { rawQuery(match { it.contains("FROM Content") }, arrayOf(page)) } answers {
contentCursor(bytes = pageBytes, compression = pageCompression, templateId = pageTemplateId, rowCount = pageRows)
}
for ((path, body) in storedCopies) {
every { rawQuery(match { it.contains("FROM Content") }, arrayOf(path)) } answers {
contentCursor(bytes = body.toByteArray(), mimeType = "text/javascript")
}
}
every { rawQuery(match { it.contains("WHERE id = ?") }, arrayOf(pageTemplateId.toString())) } answers {
mockk(relaxed = true) {
every { count } returns 1
every { moveToFirst() } returns true
every { getString(0) } returns pageTemplate
}
}
// One cursor answers both the loader's existence count and its read of the body.
every { rawQuery(match { it.contains("WHERE name = ?") }, arrayOf("helpmanual-jstopic.peb")) } answers {
if (jsTopicTemplate == null) {
missingRowCursor()
} else {
mockk(relaxed = true) {
every { count } returns 1
every { moveToFirst() } returns true
every { getInt(0) } returns 1
every { getBlob(0) } returns jsTopicTemplate.toByteArray()
}
}
}
}

@Test
fun `a newer debug database is swapped in, and the generation says so`() {
val installed = database(contentCursor(bytes = "installed".toByteArray()))
Expand Down
Loading
Loading