Skip to content

Commit f33f14b

Browse files
committed
Make it more unlikely for content builds to get stuck in a running state
1 parent cbbb5a1 commit f33f14b

File tree

6 files changed

+109
-10
lines changed

6 files changed

+109
-10
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- Added the name of the environment variables to the help output for those options that
1111
use environment variables as a default value.
1212
- Added support for deploying Shiny Express applications.
13+
- Added `--retry` and `--running` flags to the `rsconnect content build run` command.
1314

1415
### Changed
1516
- Improved the error and warning outputs when options conflict by providing the source
@@ -19,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1920
- Updated verbose mode to output the source of all options being used when processing the
2021
CLI command.
2122

23+
### Fixed
24+
- Interrupting a long-running `rsconnect content build run` command with `^C`
25+
will now update the local state file before attempting graceful cleanup. This
26+
should help prevent users from getting stuck a "build already running" state.
27+
See [#467](https://github.com/rstudio/rsconnect-python/issues/467) for details.
28+
2229
## [1.21.0] - 2023-10-26
2330

2431
### Fixed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ mock-test-%: clean-stores
5555
trap "$(MAKE) -C mock_connect down" EXIT; \
5656
CONNECT_CONTENT_BUILD_DIR="rsconnect-build-test" \
5757
CONNECT_SERVER="http://$(HOSTNAME):3939" \
58-
CONNECT_API_KEY="0123456789abcdef0123456789abcdef" \
58+
CONNECT_API_KEY="21232f297a57a5a743894a0e4a801fc3" \
5959
$(MAKE) test-$*
6060
@$(MAKE) -C mock_connect down
6161

mock_connect/data.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
],
1515
"users": [
1616
{
17-
"api_key": "0123456789abcdef0123456789abcdef",
18-
"guid": "29a74070-2c13-4ef9-a898-cfc6bcf0f275",
17+
"api_key": "21232f297a57a5a743894a0e4a801fc3",
18+
"guid": "edf26318-0027-4d9d-bbbb-54703ebb1855",
1919
"username": "admin",
2020
"first_name": "Super",
2121
"last_name": "User",

rsconnect/actions_content.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,17 @@ def build_history(connect_server, guid):
9393
return _content_build_store.get_build_history(guid)
9494

9595

96-
def build_start(connect_server, parallelism, aborted=False, error=False, all=False, poll_wait=2, debug=False):
96+
def build_start(
97+
connect_server,
98+
parallelism,
99+
aborted=False,
100+
error=False,
101+
running=False,
102+
retry=False,
103+
all=False,
104+
poll_wait=2,
105+
debug=False,
106+
):
97107
init_content_build_store(connect_server)
98108
if _content_build_store.get_build_running():
99109
raise RSConnectException("There is already a build running on this server: %s" % connect_server.url)
@@ -105,6 +115,12 @@ def build_start(connect_server, parallelism, aborted=False, error=False, all=Fal
105115
all_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), all_content))
106116
build_add_content(connect_server, all_content)
107117
else:
118+
# --retry is shorthand for --aborted --error --running
119+
if retry:
120+
aborted = True
121+
error = True
122+
running = True
123+
108124
aborted_content = []
109125
if aborted:
110126
logger.info("Adding ABORTED content to build...")
@@ -115,8 +131,14 @@ def build_start(connect_server, parallelism, aborted=False, error=False, all=Fal
115131
logger.info("Adding ERROR content to build...")
116132
error_content = _content_build_store.get_content_items(status=BuildStatus.ERROR)
117133
error_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), error_content))
118-
if len(aborted_content + error_content) > 0:
119-
build_add_content(connect_server, aborted_content + error_content)
134+
running_content = []
135+
if running:
136+
logger.info("Adding RUNNING content to build...")
137+
running_content = _content_build_store.get_content_items(status=BuildStatus.RUNNING)
138+
running_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), running_content))
139+
140+
if len(aborted_content + error_content + running_content) > 0:
141+
build_add_content(connect_server, aborted_content + error_content + running_content)
120142

121143
content_items = _content_build_store.get_content_items(status=BuildStatus.NEEDS_BUILD)
122144
if len(content_items) == 0:
@@ -172,13 +194,28 @@ def build_start(connect_server, parallelism, aborted=False, error=False, all=Fal
172194
exit(1)
173195
except KeyboardInterrupt:
174196
ContentBuildStore._BUILD_ABORTED = True
197+
logger.info("Content build interrupted...")
198+
logger.info(
199+
"Content that was in the RUNNING state may still be building on the "
200+
+ "Connect server. Server builds will not be interrupted."
201+
)
202+
logger.info(
203+
"To find content items that _may_ still be running on the server, "
204+
+ "use: rsconnect content build ls --status RUNNING"
205+
)
206+
logger.info(
207+
"To retry the content build, including items that were interrupted "
208+
+ "or failed, use: rsconnect content build run --retry"
209+
)
175210
finally:
211+
# make sure that we always mark the build as complete but note
212+
# there's no guarantee that the content_executor or build_monitor
213+
# were allowed to shut down gracefully, they may have been interrupted.
214+
_content_build_store.set_build_running(False)
176215
if content_executor:
177216
content_executor.shutdown(wait=False)
178217
if build_monitor:
179218
build_monitor.shutdown()
180-
# make sure that we always mark the build as complete once we finish our cleanup
181-
_content_build_store.set_build_running(False)
182219

183220

184221
def _monitor_build(connect_server, content_items):

rsconnect/main.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2386,6 +2386,13 @@ def get_build_logs(
23862386
)
23872387
@click.option("--aborted", is_flag=True, help="Build content that is in the ABORTED state.")
23882388
@click.option("--error", is_flag=True, help="Build content that is in the ERROR state.")
2389+
@click.option("--running", is_flag=True, help="Build content that is in the RUNNING state.")
2390+
@click.option(
2391+
"--retry",
2392+
is_flag=True,
2393+
help="Build all content that is in the NEEDS_BUILD, ABORTED, ERROR, or RUNNING state. "
2394+
+ "Shorthand for `--aborted --error --running`.",
2395+
)
23892396
@click.option("--all", is_flag=True, help="Build all content, even if it is already marked as COMPLETE.")
23902397
@click.option(
23912398
"--poll-wait",
@@ -2416,6 +2423,8 @@ def start_content_build(
24162423
parallelism: int,
24172424
aborted: bool,
24182425
error: bool,
2426+
running: bool,
2427+
retry: bool,
24192428
all: bool,
24202429
poll_wait: float,
24212430
format: str,
@@ -2427,7 +2436,7 @@ def start_content_build(
24272436
logger.set_log_output_format(format)
24282437
with cli_feedback("", stderr=True):
24292438
ce = RSConnectExecutor(ctx, name, server, api_key, insecure, cacert, logger=None).validate_server()
2430-
build_start(ce.remote_server, parallelism, aborted, error, all, poll_wait, debug)
2439+
build_start(ce.remote_server, parallelism, aborted, error, running, retry, all, poll_wait, debug)
24312440

24322441

24332442
@cli.group(no_args_is_help=True, help="Interact with Posit Connect's system API.")

tests/test_main_content.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77

88
from rsconnect.main import cli
99
from rsconnect import VERSION
10+
from rsconnect.api import RSConnectServer
1011
from rsconnect.models import BuildStatus
11-
from rsconnect.metadata import _normalize_server_url
12+
from rsconnect.metadata import ContentBuildStore, _normalize_server_url
1213

1314
from .utils import apply_common_args, require_api_key, require_connect
1415

@@ -116,6 +117,51 @@ def test_build(self):
116117
self.assertTrue(len(listing) == 1)
117118
self.assertEqual(listing[0]["rsconnect_build_status"], BuildStatus.COMPLETE)
118119

120+
def test_build_retry(self):
121+
connect_server = require_connect()
122+
api_key = require_api_key()
123+
runner = CliRunner()
124+
125+
# add a content item
126+
args = ["content", "build", "add", "-g", _content_guids[0]]
127+
apply_common_args(args, server=connect_server, key=api_key)
128+
result = runner.invoke(cli, args)
129+
self.assertEqual(result.exit_code, 0, result.output)
130+
self.assertTrue(
131+
os.path.exists("%s/%s.json" % (_test_build_dir, _normalize_server_url(os.environ.get("CONNECT_SERVER"))))
132+
)
133+
134+
# list the "tracked" content
135+
args = ["content", "build", "ls", "-g", _content_guids[0]]
136+
apply_common_args(args, server=connect_server, key=api_key)
137+
result = runner.invoke(cli, args)
138+
self.assertEqual(result.exit_code, 0, result.output)
139+
listing = json.loads(result.output)
140+
self.assertTrue(len(listing) == 1)
141+
self.assertEqual(listing[0]["guid"], _content_guids[0])
142+
self.assertEqual(listing[0]["bundle_id"], "176")
143+
self.assertEqual(listing[0]["rsconnect_build_status"], BuildStatus.NEEDS_BUILD)
144+
145+
# set the content build status to RUNNING so it looks like it was interrupted
146+
# and the cleanup did not have time to finish, otherwise it would be marked as ABORTED
147+
store = ContentBuildStore(RSConnectServer(connect_server, api_key))
148+
store.set_content_item_build_status(_content_guids[0], BuildStatus.RUNNING)
149+
150+
# run the build
151+
args = ["content", "build", "run", "--retry"]
152+
apply_common_args(args, server=connect_server, key=api_key)
153+
result = runner.invoke(cli, args)
154+
self.assertEqual(result.exit_code, 0, result.output)
155+
156+
# check that the build succeeded
157+
args = ["content", "build", "ls", "-g", _content_guids[0]]
158+
apply_common_args(args, server=connect_server, key=api_key)
159+
result = runner.invoke(cli, args)
160+
self.assertEqual(result.exit_code, 0, result.output)
161+
listing = json.loads(result.output)
162+
self.assertTrue(len(listing) == 1)
163+
self.assertEqual(listing[0]["rsconnect_build_status"], BuildStatus.COMPLETE)
164+
119165
def test_build_rm(self):
120166
connect_server = require_connect()
121167
api_key = require_api_key()

0 commit comments

Comments
 (0)