Skip to content

Commit e5c99e9

Browse files
committed
(GH-231) Make document validation asynchronous
The document validation/diagnostics part of the language server is executed on every document open or change event. While normally this is ok, when typing fast on large documents, this can create slowness. Particularly as we add more features which require longer running diagnostic processes. This commit uses a thread safe queue system which will only allow the document to exist in the queue once. Safeguards are also added which will ignore validation results if the document is changed mid-validation.
1 parent 486cb56 commit e5c99e9

File tree

6 files changed

+336
-114
lines changed

6 files changed

+336
-114
lines changed

client/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how
77
## Unreleased
88

99
- ([GH-225](https://github.com/jpogran/puppet-vscode/issues/225)) Readd Local Workspace comand line option
10+
- ([GH-231](https://github.com/jpogran/puppet-vscode/issues/231)) Make Document Validation asynchronous
1011

1112
## 0.9.0 - 2018-02-01
1213

server/lib/puppet-languageserver.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
require 'languageserver/languageserver'
66
require 'puppet-vscode'
77

8-
%w[json_rpc_handler message_router server_capabilities document_validator puppet_parser_helper puppet_helper
8+
%w[json_rpc_handler message_router validation_queue server_capabilities document_validator puppet_parser_helper puppet_helper
99
facter_helper completion_provider hover_provider definition_provider puppet_monkey_patches].each do |lib|
1010
begin
1111
require "puppet-languageserver/#{lib}"

server/lib/puppet-languageserver/message_router.rb

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -297,14 +297,7 @@ def receive_notification(method, params)
297297
content = params['textDocument']['text']
298298
doc_version = params['textDocument']['version']
299299
documents.set_document(file_uri, content, doc_version)
300-
case document_type(file_uri)
301-
when :manifest
302-
reply_diagnostics(file_uri, PuppetLanguageServer::DocumentValidator.validate(content, @workspace))
303-
when :epp
304-
reply_diagnostics(file_uri, PuppetLanguageServer::DocumentValidator.validate_epp(content, @workspace))
305-
else
306-
reply_diagnostics(file_uri, [])
307-
end
300+
PuppetLanguageServer::ValidationQueue.enqueue(file_uri, doc_version, @workspace, self)
308301

309302
when 'textDocument/didClose'
310303
PuppetLanguageServer.log_message(:info, 'Received textDocument/didClose notification.')
@@ -317,14 +310,7 @@ def receive_notification(method, params)
317310
content = params['contentChanges'][0]['text'] # TODO: Bad hardcoding zero
318311
doc_version = params['textDocument']['version']
319312
documents.set_document(file_uri, content, doc_version)
320-
case document_type(file_uri)
321-
when :manifest
322-
reply_diagnostics(file_uri, PuppetLanguageServer::DocumentValidator.validate(content, @workspace))
323-
when :epp
324-
reply_diagnostics(file_uri, PuppetLanguageServer::DocumentValidator.validate_epp(content, @workspace))
325-
else
326-
reply_diagnostics(file_uri, [])
327-
end
313+
PuppetLanguageServer::ValidationQueue.enqueue(file_uri, doc_version, @workspace, self)
328314

329315
when 'textDocument/didSave'
330316
PuppetLanguageServer.log_message(:info, 'Received textDocument/didSave notification.')
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
module PuppetLanguageServer
2+
# Module for enqueing and running document level validation asynchronously
3+
# When adding a document to be validation, it will remove any validation requests for the same
4+
# document in the queue so that only the latest document needs to be processed.
5+
#
6+
# It will also ignore sending back validation results to the client if the document is
7+
# updated during the validation process
8+
module ValidationQueue
9+
@queue = []
10+
@queue_mutex = Mutex.new
11+
@queue_thread = nil
12+
13+
# Enqueue a file to be validated
14+
def self.enqueue(file_uri, doc_version, workspace, connection_object)
15+
document_type = connection_object.document_type(file_uri)
16+
17+
unless %i[manifest epp].include?(document_type)
18+
# Can't validate these types so just emit an empty validation result
19+
connection_object.reply_diagnostics(file_uri, [])
20+
return
21+
end
22+
23+
@queue_mutex.synchronize do
24+
@queue.reject! { |item| item['file_uri'] == file_uri }
25+
26+
@queue << {
27+
'file_uri' => file_uri,
28+
'doc_version' => doc_version,
29+
'document_type' => document_type,
30+
'workspace' => workspace,
31+
'connection_object' => connection_object
32+
}
33+
end
34+
35+
if @queue_thread.nil? || !@queue_thread.alive?
36+
@queue_thread = Thread.new do
37+
begin
38+
worker
39+
rescue => err # rubocop:disable Style/RescueStandardError
40+
PuppetLanguageServer.log_message(:error, "Error in ValidationQueue Thread: #{err}")
41+
raise
42+
end
43+
end
44+
end
45+
46+
nil
47+
end
48+
49+
# Synchronously validate a file
50+
def self.validate_sync(file_uri, doc_version, workspace, connection_object)
51+
document_type = connection_object.document_type(file_uri)
52+
content = documents.document(file_uri, doc_version)
53+
return nil if content.nil?
54+
result = validate(document_type, content, workspace)
55+
56+
# Send the response
57+
connection_object.reply_diagnostics(file_uri, result)
58+
end
59+
60+
# Helper method to the Document Store
61+
def self.documents
62+
PuppetLanguageServer::DocumentStore
63+
end
64+
65+
# Wait for the queue to become empty
66+
def self.drain_queue
67+
return if @queue_thread.nil? || !@queue_thread.alive?
68+
@queue_thread.join
69+
nil
70+
end
71+
72+
# Testing helper resets the queue and prepopulates it with
73+
# a known arbitrary configuration.
74+
# ONLY USE THIS FOR TESTING!
75+
def self.reset_queue(initial_state = [])
76+
@queue_mutex.synchronize do
77+
@queue = initial_state
78+
end
79+
end
80+
81+
# Validate a document
82+
def self.validate(document_type, content, workspace)
83+
# Perform validation
84+
case document_type
85+
when :manifest
86+
PuppetLanguageServer::DocumentValidator.validate(content, workspace)
87+
when :epp
88+
PuppetLanguageServer::DocumentValidator.validate_epp(content, workspace)
89+
else
90+
[]
91+
end
92+
end
93+
private_class_method :validate
94+
95+
# Thread worker which processes all jobs in the queue and validates each document
96+
# serially
97+
def self.worker
98+
work_item = nil
99+
loop do
100+
@queue_mutex.synchronize do
101+
return if @queue.empty?
102+
work_item = @queue.shift
103+
end
104+
return if work_item.nil?
105+
106+
file_uri = work_item['file_uri']
107+
doc_version = work_item['doc_version']
108+
connection_object = work_item['connection_object']
109+
document_type = work_item['document_type']
110+
workspace = work_item['workspace']
111+
112+
# Check if the document is the latest version
113+
content = documents.document(file_uri, doc_version)
114+
if content.nil?
115+
PuppetLanguageServer.log_message(:debug, "ValidationQueue Thread: Ignoring #{work_item['file_uri']} as it is not the latest version or has been removed")
116+
return
117+
end
118+
119+
# Perform validation
120+
result = validate(document_type, content, workspace)
121+
122+
# Check if the document is still latest version
123+
current_version = documents.document_version(file_uri)
124+
if current_version != doc_version
125+
PuppetLanguageServer.log_message(:debug, "ValidationQueue Thread: Ignoring #{work_item['file_uri']} as has changed version from #{doc_version} to #{current_version}")
126+
return
127+
end
128+
129+
# Send the response
130+
connection_object.reply_diagnostics(file_uri, result)
131+
end
132+
end
133+
private_class_method :worker
134+
end
135+
end

server/spec/languageserver/unit/puppet-languageserver/message_router_spec.rb

Lines changed: 14 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -515,9 +515,7 @@
515515

516516
# textDocument/didOpen - https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md#textDocument_didOpen
517517
context 'given a textDocument/didOpen notification' do
518-
let(:validation_result) { ['validation_result'] }
519-
520-
shared_examples_for "an opened document with validation errors" do |file_uri, file_content|
518+
shared_examples_for "an opened document with enqueued validation" do |file_uri, file_content|
521519
let(:notification_method) { 'textDocument/didOpen' }
522520
let(:notification_params) { {
523521
'textDocument' => {
@@ -533,30 +531,8 @@
533531
expect(subject.documents.document(file_uri)).to eq(file_content)
534532
end
535533

536-
it 'should reply with diagnostic information on the file' do
537-
expect(subject).to receive(:reply_diagnostics).with(file_uri,validation_result).and_return(true)
538-
subject.receive_notification(notification_method, notification_params)
539-
end
540-
end
541-
542-
shared_examples_for "an opened document with no validation errors" do |file_uri, file_content|
543-
let(:notification_method) { 'textDocument/didOpen' }
544-
let(:notification_params) { {
545-
'textDocument' => {
546-
'uri' => file_uri,
547-
'languageId' => 'puppet',
548-
'version' => 1,
549-
'text' => file_content,
550-
}
551-
}}
552-
553-
it 'should add the document to the document store' do
554-
subject.receive_notification(notification_method, notification_params)
555-
expect(subject.documents.document(file_uri)).to eq(file_content)
556-
end
557-
558-
it 'should reply with empty diagnostic information on the file' do
559-
expect(subject).to receive(:reply_diagnostics).with(file_uri,[]).and_return(true)
534+
it 'should enqueue the file for validation' do
535+
expect(PuppetLanguageServer::ValidationQueue).to receive(:enqueue).with(file_uri, 1, Object, Object)
560536
subject.receive_notification(notification_method, notification_params)
561537
end
562538
end
@@ -566,33 +542,19 @@
566542
end
567543

568544
context 'for a puppet manifest file' do
569-
before(:each) do
570-
expect(PuppetLanguageServer::DocumentValidator).to receive(:validate).and_return(validation_result)
571-
allow(subject).to receive(:reply_diagnostics).and_return(true)
572-
end
573-
it_should_behave_like "an opened document with validation errors", MANIFEST_FILENAME, ERROR_CAUSING_FILE_CONTENT
545+
it_should_behave_like "an opened document with enqueued validation", MANIFEST_FILENAME, ERROR_CAUSING_FILE_CONTENT
574546
end
575547

576548
context 'for a Puppetfile file' do
577-
before(:each) do
578-
allow(subject).to receive(:reply_diagnostics).and_return(true)
579-
end
580-
it_should_behave_like "an opened document with no validation errors", PUPPETFILE_FILENAME, ERROR_CAUSING_FILE_CONTENT
549+
it_should_behave_like "an opened document with enqueued validation", PUPPETFILE_FILENAME, ERROR_CAUSING_FILE_CONTENT
581550
end
582551

583552
context 'for an EPP template file' do
584-
before(:each) do
585-
expect(PuppetLanguageServer::DocumentValidator).to receive(:validate_epp).and_return(validation_result)
586-
allow(subject).to receive(:reply_diagnostics).and_return(true)
587-
end
588-
it_should_behave_like "an opened document with validation errors", EPP_FILENAME, ERROR_CAUSING_FILE_CONTENT
553+
it_should_behave_like "an opened document with enqueued validation", EPP_FILENAME, ERROR_CAUSING_FILE_CONTENT
589554
end
590555

591556
context 'for an unknown file' do
592-
before(:each) do
593-
allow(subject).to receive(:reply_diagnostics).and_return(true)
594-
end
595-
it_should_behave_like "an opened document with no validation errors", UNKNOWN_FILENAME, ERROR_CAUSING_FILE_CONTENT
557+
it_should_behave_like "an opened document with enqueued validation", UNKNOWN_FILENAME, ERROR_CAUSING_FILE_CONTENT
596558
end
597559
end
598560

@@ -618,37 +580,7 @@
618580

619581
# textDocument/didChange - https://github.com/Microsoft/language-server-protocol/blob/master/protocol.md#didchangetextdocument-notification
620582
context 'given a textDocument/didChange notification and a TextDocumentSyncKind of Full' do
621-
let(:validation_result) { ['validation_result'] }
622-
623-
shared_examples_for "a changed document with validation errors" do |file_uri, new_file_content|
624-
let(:notification_params) { {
625-
'textDocument' => {
626-
'uri' => file_uri,
627-
'version' => 2,
628-
},
629-
'contentChanges' => [
630-
{
631-
'range' => nil,
632-
'rangeLength' => nil,
633-
'text' => new_file_content,
634-
}
635-
]
636-
}}
637-
let(:notification_method) { 'textDocument/didChange' }
638-
let(:new_file_content ) { 'new_file_content' }
639-
640-
it 'should update the document in the document store' do
641-
subject.receive_notification(notification_method, notification_params)
642-
expect(subject.documents.document(file_uri)).to eq(new_file_content)
643-
end
644-
645-
it 'should reply with diagnostic information on the file' do
646-
expect(subject).to receive(:reply_diagnostics).with(file_uri, validation_result).and_return(true)
647-
subject.receive_notification(notification_method, notification_params)
648-
end
649-
end
650-
651-
shared_examples_for "a changed document with no validation errors" do |file_uri, new_file_content|
583+
shared_examples_for "a changed document with enqueued validation" do |file_uri, new_file_content|
652584
let(:notification_params) { {
653585
'textDocument' => {
654586
'uri' => file_uri,
@@ -670,8 +602,8 @@
670602
expect(subject.documents.document(file_uri)).to eq(new_file_content)
671603
end
672604

673-
it 'should reply with empty diagnostic information on the file' do
674-
expect(subject).to receive(:reply_diagnostics).with(file_uri, []).and_return(true)
605+
it 'should enqueue the file for validation' do
606+
expect(PuppetLanguageServer::ValidationQueue).to receive(:enqueue).with(file_uri, 2, Object, Object)
675607
subject.receive_notification(notification_method, notification_params)
676608
end
677609
end
@@ -681,34 +613,19 @@
681613
end
682614

683615
context 'for a puppet manifest file' do
684-
before(:each) do
685-
expect(PuppetLanguageServer::DocumentValidator).to receive(:validate).and_return(validation_result)
686-
allow(subject).to receive(:reply_diagnostics).and_return(true)
687-
end
688-
it_should_behave_like "a changed document with validation errors", MANIFEST_FILENAME, ERROR_CAUSING_FILE_CONTENT
616+
it_should_behave_like "a changed document with enqueued validation", MANIFEST_FILENAME, ERROR_CAUSING_FILE_CONTENT
689617
end
690618

691619
context 'for a Puppetfile file' do
692-
before(:each) do
693-
allow(subject).to receive(:reply_diagnostics).and_return(true)
694-
end
695-
it_should_behave_like "a changed document with no validation errors", PUPPETFILE_FILENAME, ERROR_CAUSING_FILE_CONTENT
620+
it_should_behave_like "a changed document with enqueued validation", PUPPETFILE_FILENAME, ERROR_CAUSING_FILE_CONTENT
696621
end
697622

698623
context 'for an EPP template file' do
699-
before(:each) do
700-
expect(PuppetLanguageServer::DocumentValidator).to receive(:validate_epp).and_return(validation_result)
701-
allow(subject).to receive(:reply_diagnostics).and_return(true)
702-
end
703-
it_should_behave_like "a changed document with validation errors", EPP_FILENAME, ERROR_CAUSING_FILE_CONTENT
624+
it_should_behave_like "a changed document with enqueued validation", EPP_FILENAME, ERROR_CAUSING_FILE_CONTENT
704625
end
705626

706627
context 'for a file the server does not understand' do
707-
before(:each) do
708-
expect(PuppetLanguageServer::DocumentValidator).to receive(:validate).exactly(0).times
709-
allow(subject).to receive(:reply_diagnostics).and_return(true)
710-
end
711-
it_should_behave_like "a changed document with no validation errors", UNKNOWN_FILENAME, ERROR_CAUSING_FILE_CONTENT
628+
it_should_behave_like "a changed document with enqueued validation", UNKNOWN_FILENAME, ERROR_CAUSING_FILE_CONTENT
712629
end
713630
end
714631

0 commit comments

Comments
 (0)