Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
15745f9
test/nginx/request(): use WHATWG Responses
Jul 6, 2026
c03fbd3
re-import tests
Jul 6, 2026
ab17292
just run the relevant test, once
Jul 6, 2026
63934aa
make teh strewam finish
Jul 6, 2026
07dd10d
wip
Jul 6, 2026
a656335
wip
Jul 6, 2026
b560f87
wip
Jul 6, 2026
04f2b8a
passing test
Jul 6, 2026
9571e08
reduce logging
Jul 6, 2026
3282f95
enable all tests
Jul 6, 2026
bb54657
function order
Jul 6, 2026
e90b350
remove unused todo
Jul 6, 2026
4f992d6
update comment
Jul 6, 2026
e949a73
remove one thing
Jul 6, 2026
9f9fa4b
remove more new settings
Jul 6, 2026
9bc1a68
reduce buffer sizes
Jul 6, 2026
dd05e6c
less settings
Jul 6, 2026
e73f72f
less settings
Jul 6, 2026
ec72af4
add TODO
Jul 6, 2026
7e4361d
update ci from next
Jul 7, 2026
69d447d
manually merge changes from next
Jul 7, 2026
1c3487a
Merge branch 'next' into proxy-buffering
Jul 7, 2026
034cbe6
Apply suggestion from @alxndrsn
alxndrsn Jul 7, 2026
135a005
match all .csv paths
Jul 7, 2026
20766c1
use more realistic path
Jul 7, 2026
1212289
resolve todo
Jul 7, 2026
81e3562
Merge branch 'next' into proxy-buffering
Jul 11, 2026
3393e69
remove outdated comment
Jul 11, 2026
42d6d5d
Merge branch 'next' into proxy-buffering
alxndrsn Jul 15, 2026
10fcce3
wip
Jul 21, 2026
a493365
write exact size
Jul 21, 2026
8f25a34
remove comment
Jul 23, 2026
2a80b53
reintroduce nginx config change
Jul 23, 2026
021f2fb
Merge branch 'next' into proxy-buffering
alxndrsn Jul 24, 2026
847ede7
Merge branch 'next' into proxy-buffering
alxndrsn Jul 24, 2026
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
3 changes: 1 addition & 2 deletions files/nginx/odk.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,8 @@ server {
proxy_pass http://service:8383;
proxy_redirect off;

# buffer requests, but not responses, so streaming out works.
Comment thread
alxndrsn marked this conversation as resolved.
proxy_request_buffering on;
proxy_buffering off;
proxy_buffering on;
proxy_read_timeout 2m;
}

Expand Down
52 changes: 52 additions & 0 deletions test/nginx/mock-http-server/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
const { Readable } = require('node:stream');

const express = require('express');

const port = process.env.PORT || 80;
const log = (...args) => console.log('[mock-http-server]', ...args);

const requests = [];
let openProcessorCount = 0;
let completedProcessorCount = 0;

const app = express();
app.set('case sensitive routing', true);
Expand All @@ -29,9 +33,57 @@ app.get('/__mock_http_server/health', (req, res) => res.send('OK'));
app.get('/__mock_http_server/request-log', (req, res) => res.json(requests));
app.get('/__mock_http_server/reset', (req, res) => {
requests.length = 0;
openProcessorCount = 0;
completedProcessorCount = 0;
res.json('OK');
});

app.get(new RegExp('^/v1/.*/100MB\\.csv$'), (req, res) => {
const csvSizeBytes = 100_000_000;

res.set('Content-Disposition', `attachment; filename="100MB.csv"; filename*=UTF-8''100MB.csv`);
res.set('Content-Type', 'text/csv; charset=utf-8');

++openProcessorCount;

async function* generateCsv(targetByteLength) {
let rowCount = 0;
let totalWritten = 0;

const batchSize = Math.pow(2, 18);

const header = Buffer.from('row_number,timestamp,random-number\n', 'utf8');
totalWritten += header.byteLength;
yield header;

while(totalWritten < targetByteLength) {
await new Promise(resolve => setTimeout(resolve, 1));

const batch = Buffer.allocUnsafe(Math.min(batchSize, targetByteLength - totalWritten));
let bufpos = 0;
while(bufpos < batch.length) {
const line = `${++rowCount},${new Date().toISOString()},${Math.random()}\n`;
const bytesWritten = batch.write(line, bufpos, batch.length-bufpos, 'utf8');
bufpos += bytesWritten;
totalWritten += bytesWritten;
}
yield batch;
}

++completedProcessorCount;
}

const randomStream = Readable.from(generateCsv(csvSizeBytes));
randomStream.pipe(res);
req.on('close', () => {
randomStream.destroy();
--openProcessorCount;
});
});
app.get('/__mock_http_server/open-processor-count', (req, res) => {
res.send({ openProcessorCount, completedProcessorCount });
});

app.get('/v1/reflect-headers', (req, res) => res.json(req.headers));

// Central-Backend can set Cache headers and those should have highest precedence
Expand Down
5 changes: 5 additions & 0 deletions test/nginx/src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module.exports = {
assertSentryReceived,
requestSentryMock,
resetSentryMock,
sleep,
};

async function assertSentryReceived(...expectedRequests) {
Expand Down Expand Up @@ -56,3 +57,7 @@ function requestSentryMock(opts) {
req.end();
});
}

function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
77 changes: 77 additions & 0 deletions test/nginx/src/mocha/nginx.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
assertSentryReceived,
requestSentryMock,
resetSentryMock,
sleep,
} = require('../lib');
const request = require('./request');

Expand Down Expand Up @@ -436,6 +437,82 @@ function standardTestSuite({ fetchHttp, fetchHttp6, apiFetch, apiFetch6, forward
});
});

describe('response buffering', () => {
it('should buffer responses in nginx, not backend services', async function() {
// NOTE the final check should pass before the test times out. If timeout
// is reached, there may be back-pressure on the NodeJS server. This
// would imply that nginx buffering is not working correctly.
// !!! ONLY CHANGE THIS TIMEOUT IF THE ABOVE COMMENT IS WELL UNDERSTOOD !!!
const testTimeout = 5_000;
Comment thread
alxndrsn marked this conversation as resolved.
this.timeout(testTimeout);

let controller;

try {
// given
controller = new AbortController();
const { signal } = controller;

// when
const res = await apiFetch('/v1/projects/123/forms/some_form_id/attachments/100MB.csv', { signal });
// then
assert.equal(res.status, 200);

// when
const reader = res.body.getReader();
const initialRead = await reader.read();
let bytesRead = initialRead.value.length;
// then
assert.isFalse(initialRead.done);
assert.isAtMost(bytesRead, 16_384);
assert.equal(new TextDecoder('utf8').decode(initialRead.value).split('\n', 1)[0], 'row_number,timestamp,random-number');
// and
assert.deepEqual(await getOpenProcessorCount(), { openProcessorCount:1, completedProcessorCount:0 });

// when
await untilOpenProcessorCountIs({ timeout:testTimeout, openProcessorCount:0, completedProcessorCount:1 });
Comment thread
alxndrsn marked this conversation as resolved.
// and
while(true) {
const { done, value } = await reader.read();
if(done) break;
bytesRead += value.length;
}
// then
assert.equal(bytesRead, 100_000_000);
} finally {
controller.abort();
}
});

async function getOpenProcessorCount() {
const res = await request(`http://localhost:8383/__mock_http_server/open-processor-count`);
assert.isTrue(res.ok);
return await res.json();
}

async function untilOpenProcessorCountIs({ timeout, ...expected }) {
let timeoutId;
try {
let timedOut;
timeoutId = setTimeout(() => { timedOut = true; }, timeout);

while(true) {
const { openProcessorCount, completedProcessorCount } = await getOpenProcessorCount();
if(openProcessorCount === expected.openProcessorCount &&
completedProcessorCount === expected.completedProcessorCount) {
break;
}

if(timedOut) throw new Error(`Timeout of ${timeout} ms exceeded.`);

await sleep(100);
}
} finally {
clearTimeout(timeoutId);
}
}
});

it('should serve generated client-config.json', async () => {
// when
const res = await apiFetch('/client-config.json');
Expand Down
Loading