From: Dmitry Volyntsev Date: Fri, 20 Feb 2026 23:45:25 +0000 (-0800) Subject: Modules: fixed expire field truncation in shared dict state files. X-Git-Tag: 0.9.6~17 X-Git-Url: http://www.kaiwu.me/postgresql/commit/?a=commitdiff_plain;h=62881dc4afd5f6998dcc04205f1d51eee67121dc;p=njs.git Modules: fixed expire field truncation in shared dict state files. The njs_sprintf buffer for the expire field was sized for 10-digit numbers, but current millisecond timestamps are 13 digits. This caused silent truncation, making entries appear expired on a full restart. The issue has been present since eca03622 (0.9.1), which introduced the shared dictionary state file support. --- diff --git a/nginx/ngx_js_shared_dict.c b/nginx/ngx_js_shared_dict.c index 28eed0da..7eb13a70 100644 --- a/nginx/ngx_js_shared_dict.c +++ b/nginx/ngx_js_shared_dict.c @@ -1877,14 +1877,14 @@ ngx_js_dict_render_json(ngx_js_dict_t *dict, njs_chb_t *chain) } if (dict->timeout) { - len = sizeof(",\"expire\":1000000000"); + len = sizeof(",\"expire\":18446744073709551615"); dst = njs_chb_reserve(chain, len); if (dst == NULL) { return NGX_ERROR; } - p = njs_sprintf(dst, dst + len, ",\"expire\":%ui", - node->expire.key); + p = ngx_slprintf(dst, dst + len, ",\"expire\":%ui", + node->expire.key); njs_chb_written(chain, p - dst); } @@ -2943,6 +2943,14 @@ ngx_js_shared_dict_zone(ngx_conf_t *cf, ngx_command_t *cmd, void *conf, return NGX_CONF_ERROR; } +#if (NGX_PTR_SIZE == 4) + if (timeout != 0 && file.len != 0) { + ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "state file is not supported " + "on 32-bit platform when timeout is used"); + return NGX_CONF_ERROR; + } +#endif + shm_zone = ngx_shared_memory_add(cf, &name, size, tag); if (shm_zone == NULL) { return NGX_CONF_ERROR; diff --git a/nginx/t/js_shared_dict_state.t b/nginx/t/js_shared_dict_state.t index 32eef948..4267528e 100644 --- a/nginx/t/js_shared_dict_state.t +++ b/nginx/t/js_shared_dict_state.t @@ -42,16 +42,11 @@ http { js_import test.js; js_shared_dict_zone zone=bar:64k type=string state=bar.json; - js_shared_dict_zone zone=waka:32k timeout=1000s type=number state=waka.json; server { listen 127.0.0.1:8080; server_name localhost; - location /add { - js_content test.add; - } - location /clear { js_content test.clear; } @@ -64,10 +59,6 @@ http { js_content test.get; } - location /incr { - js_content test.incr; - } - location /pop { js_content test.pop; } @@ -90,30 +81,6 @@ $t->write_file('bar.json', <write_file('test.js', <<'EOF'); - function convertToValue(dict, v) { - if (dict.type == 'number') { - return parseInt(v); - - } else if (v == 'empty') { - v = ''; - } - - return v; - } - - function add(r) { - var dict = ngx.shared[r.args.dict]; - var value = convertToValue(dict, r.args.value); - - if (r.args.timeout) { - var timeout = Number(r.args.timeout); - r.return(200, dict.add(r.args.key, value, timeout)); - - } else { - r.return(200, dict.add(r.args.key, value)); - } - } - function clear(r) { var dict = ngx.shared[r.args.dict]; var result = dict.clear(); @@ -139,21 +106,6 @@ $t->write_file('test.js', <<'EOF'); r.return(200, val); } - function incr(r) { - var dict = ngx.shared[r.args.dict]; - var def = r.args.def ? parseInt(r.args.def) : 0; - - if (r.args.timeout) { - var timeout = Number(r.args.timeout); - var val = dict.incr(r.args.key, parseInt(r.args.by), def, timeout); - r.return(200, val); - - } else { - var val = dict.incr(r.args.key, parseInt(r.args.by), def); - r.return(200, val); - } - } - function pop(r) { var dict = ngx.shared[r.args.dict]; var val = dict.pop(r.args.key); @@ -169,18 +121,16 @@ $t->write_file('test.js', <<'EOF'); function set(r) { var dict = ngx.shared[r.args.dict]; - var value = convertToValue(dict, r.args.value); + var value = r.args.value; - if (r.args.timeout) { - var timeout = Number(r.args.timeout); - r.return(200, dict.set(r.args.key, value, timeout) === dict); - - } else { - r.return(200, dict.set(r.args.key, value) === dict); + if (value == 'empty') { + value = ''; } + + r.return(200, dict.set(r.args.key, value) === dict); } - export default { add, clear, del, get, incr, pop, set }; + export default { clear, del, get, pop, set }; EOF $t->try_run('no js_shared_dict_zone with state=')->plan(11); @@ -195,39 +145,36 @@ like(http_get('/get?dict=bar&key=abc'), qr/def/, 'get bar.abc'); http_get('/set?dict=bar&key=waka&value=foo2'); http_get('/delete?dict=bar&key=bar'); -http_get('/set?dict=waka&key=foo&value=42'); - select undef, undef, undef, 1.1; -$t->reload(); +$t->stop(); +$t->run(); my $bar_state = read_state($t, 'bar.json'); -my $waka_state = read_state($t, 'waka.json'); is($bar_state->{waka}->{value}, 'foo2', 'get bar.waka from state'); +is($bar_state->{waka}->{expire}, undef, + 'no expire field for bar.waka in state in non-timeout case'); is($bar_state->{bar}, undef, 'no bar.bar in state'); -is($waka_state->{foo}->{value}, '42', 'get waka.foo from state'); -like($waka_state->{foo}->{expire}, qr/^\d+$/, 'waka.foo expire'); -http_get('/pop?dict=bar&key=FOO%20%0A'); +like(http_get('/get?dict=bar&key=waka'), qr/foo2/, + 'get bar.waka after restart'); +like(http_get('/get?dict=bar&key=bar'), qr/undefined/, + 'get bar.bar after restart'); -http_get('/incr?dict=waka&key=foo&by=1'); +http_get('/pop?dict=bar&key=FOO%20%0A'); select undef, undef, undef, 1.1; $bar_state = read_state($t, 'bar.json'); -$waka_state = read_state($t, 'waka.json'); is($bar_state->{'FOO \\n'}, undef, 'no bar.FOO \\n in state'); -is($waka_state->{foo}->{value}, '43', 'get waka.foo from state'); http_get('/clear?dict=bar'); select undef, undef, undef, 1.1; -$bar_state = read_state($t, 'bar.json'); - -is($bar_state->{waka}, undef, 'no bar.waka in state'); +is($t->read_file('bar.json'), "{}", 'empty dict saved as empty state'); ############################################################################### diff --git a/nginx/t/js_shared_dict_state_timeout.t b/nginx/t/js_shared_dict_state_timeout.t new file mode 100644 index 00000000..245b084f --- /dev/null +++ b/nginx/t/js_shared_dict_state_timeout.t @@ -0,0 +1,231 @@ +#!/usr/bin/perl + +# (C) Dmitry Volyntsev +# (C) F5, Inc. + +# Tests for js_shared_dict_zone directive, state= with timeout= parameters. + +############################################################################### + +use warnings; +use strict; + +use Test::More; +use Socket qw/ CRLF /; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +eval { require JSON::PP; }; +plan(skip_all => "JSON::PP not installed") if $@; + +my $t = Test::Nginx->new()->has(qw/http/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + + js_shared_dict_zone zone=waka:32k timeout=1000s type=number state=waka.json; + js_shared_dict_zone zone=exp:32k timeout=1000s type=string state=exp.json; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /get { + js_content test.get; + } + + location /incr { + js_content test.incr; + } + + location /set { + js_content test.set; + } + } +} + +EOF + +my $now_ms = time() * 1000; +my $past_expire = $now_ms - 3600000; +my $future_expire = $now_ms + 3600000; + +$t->write_file('exp.json', <write_file('test.js', <<'EOF'); + function convertToValue(dict, v) { + if (dict.type == 'number') { + return parseInt(v); + + } else if (v == 'empty') { + v = ''; + } + + return v; + } + + function get(r) { + var dict = ngx.shared[r.args.dict]; + var val = dict.get(r.args.key); + + if (val == '') { + val = 'empty'; + + } else if (val === undefined) { + val = 'undefined'; + } + + r.return(200, val); + } + + function incr(r) { + var dict = ngx.shared[r.args.dict]; + var def = r.args.def ? parseInt(r.args.def) : 0; + + if (r.args.timeout) { + var timeout = Number(r.args.timeout); + var val = dict.incr(r.args.key, parseInt(r.args.by), def, timeout); + r.return(200, val); + + } else { + var val = dict.incr(r.args.key, parseInt(r.args.by), def); + r.return(200, val); + } + } + + function set(r) { + var dict = ngx.shared[r.args.dict]; + var value = convertToValue(dict, r.args.value); + + if (r.args.timeout) { + var timeout = Number(r.args.timeout); + r.return(200, dict.set(r.args.key, value, timeout) === dict); + + } else { + r.return(200, dict.set(r.args.key, value) === dict); + } + } + + export default { get, incr, set }; +EOF + +$t->try_run('js_shared_dict_zone state with timeout no support on 32-bit') + ->plan(13); + +############################################################################### + +http_get('/set?dict=waka&key=foo&value=42'); + +select undef, undef, undef, 1.1; + +$t->reload(); + +my $waka_state = read_state($t, 'waka.json'); + +is($waka_state->{foo}->{value}, '42', 'get waka.foo from state'); +like($waka_state->{foo}->{expire}, qr/^\d+$/, 'waka.foo expire'); + +http_get('/incr?dict=waka&key=foo&by=1'); + +select undef, undef, undef, 1.1; + +$waka_state = read_state($t, 'waka.json'); + +is($waka_state->{foo}->{value}, '43', 'get waka.foo from state'); + +like(http_get('/get?dict=exp&key=past'), qr/undefined/, + 'expired entry cleaned on load'); + +like(http_get('/get?dict=exp&key=future'), qr/here/, + 'non-expired entry survives load'); + +like(http_get('/get?dict=exp&key=noexp'), qr/fresh/, + 'expire=0 entry accessible after load'); + +my $before_set = time_ms(); +http_get('/set?dict=waka&key=exp_test&value=99&timeout=30000'); +my $after_set = time_ms(); + +select undef, undef, undef, 1.1; + +$waka_state = read_state($t, 'waka.json'); +my $exp_val = $waka_state->{exp_test}->{expire}; +ok($exp_val >= $before_set + 25000 && $exp_val <= $after_set + 35000, + 'expire field value in correct range for 30s timeout'); + +my $expire_before = $waka_state->{exp_test}->{expire}; + +$t->stop(); +$t->run(); + +like(http_get('/get?dict=waka&key=exp_test'), qr/99/, + 'value survives restart with valid expire'); +like(http_get('/get?dict=exp&key=future'), qr/here/, + 'non-expired entry survives restart'); +like(http_get('/get?dict=exp&key=noexp'), qr/fresh/, + 'expire=0 entry survives restart'); + +select undef, undef, undef, 1.1; + +$waka_state = read_state($t, 'waka.json'); +is($waka_state->{exp_test}->{expire}, $expire_before, + 'expire value preserved after restart'); + +my $exp_state = read_state($t, 'exp.json'); +is($exp_state->{past}, undef, 'expired entry removed from state file'); + +ok(defined $exp_state->{noexp}->{expire} + && $exp_state->{noexp}->{expire} > 0, + 'expire=0 entry gets expire assigned in state'); + +############################################################################### + +sub time_ms { + return time() * 1000; +} + +sub decode_json { + my $json; + eval { $json = JSON::PP::decode_json(shift) }; + + if ($@) { + return ""; + } + + return $json; +} + +sub read_state { + my ($self, $file) = @_; + my $json = $self->read_file($file); + + if ($json) { + $json = decode_json($json); + } + + return $json; +} + +###############################################################################